From cce5bd38bba8ced1a0e00ec471dffa3835d3b671 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 11:55:52 +0100 Subject: [PATCH 01/21] Add Base API Clients for building authenticated HTTP clients Move Base API Clients from kognic-base-api-client-python into kognic-auth-python: - BaseApiClient (sync, requests-based) with lazy session initialization - BaseAsyncApiClient (async, httpx-based) extending HttpxAuthAsyncClient Features: - OAuth2 authentication with automatic token refresh - Automatic JSON serialization for jsonable objects - Retry logic for transient errors (502, 503, 504) - Sunset header handling (logs warnings for deprecated endpoints) - Enhanced error messages with response body details Also adds: - _protocols.py: Protocol definitions for duck typing - _serde.py: Serialization/deserialization utilities - _sunset.py: Sunset header handling - _user_agent.py: User-Agent string construction Co-Authored-By: Claude Opus 4.5 --- .gitignore | 2 +- README.md | 47 ++++++ src/kognic/auth/_protocols.py | 31 ++++ src/kognic/auth/_serde.py | 76 +++++++++ src/kognic/auth/_sunset.py | 52 ++++++ src/kognic/auth/_user_agent.py | 25 +++ src/kognic/auth/httpx/__init__.py | 4 + src/kognic/auth/httpx/base_client.py | 126 ++++++++++++++ src/kognic/auth/requests/__init__.py | 4 + src/kognic/auth/requests/base_client.py | 216 ++++++++++++++++++++++++ tests/test_base_client_async.py | 36 ++++ tests/test_base_client_sync.py | 92 ++++++++++ tests/test_serde.py | 104 ++++++++++++ tests/test_sunset.py | 105 ++++++++++++ tests/test_user_agent.py | 29 ++++ 15 files changed, 948 insertions(+), 1 deletion(-) create mode 100644 src/kognic/auth/_protocols.py create mode 100644 src/kognic/auth/_serde.py create mode 100644 src/kognic/auth/_sunset.py create mode 100644 src/kognic/auth/_user_agent.py create mode 100644 src/kognic/auth/httpx/base_client.py create mode 100644 src/kognic/auth/requests/base_client.py create mode 100644 tests/test_base_client_async.py create mode 100644 tests/test_base_client_sync.py create mode 100644 tests/test_serde.py create mode 100644 tests/test_sunset.py create mode 100644 tests/test_user_agent.py diff --git a/.gitignore b/.gitignore index e4bad8a..781ed64 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,4 @@ **/key.json src/kognic/auth/_version.py -.claude +.claude/ diff --git a/README.md b/README.md index 1e1767a..bbc2cba 100644 --- a/README.md +++ b/README.md @@ -164,5 +164,52 @@ kognic-auth call https://app.kognic.com/v1/projects --format=table - `0` - Success (HTTP 2xx) - `1` - Error (HTTP error, missing credentials, invalid input, etc.) +## Base API Clients + +For building API clients that need authenticated HTTP requests, use the V2 base clients. +These provide a requests/httpx-compatible interface with enhancements: + +- OAuth2 authentication with automatic token refresh +- Automatic JSON serialization for jsonable objects +- Retry logic for transient errors (502, 503, 504) +- Sunset header handling (logs warnings for deprecated endpoints) +- Enhanced error messages with response body details + +### Sync Client (requests) + +```python +from kognic.auth.requests import BaseApiClient + +class MyApiClient(BaseApiClient): + def get_resource(self, resource_id: str): + response = self.session.get(f"https://api.app.kognic.com/v1/resources/{resource_id}") + return response.json() + +# Usage with environment variables +client = MyApiClient() + +# Or with explicit credentials +client = MyApiClient(client_id="...", client_secret="...") + +# Or with credentials file +client = MyApiClient(auth="~/.config/kognic/credentials.json") +``` + +### Async Client (httpx) + +```python +from kognic.auth.httpx import BaseAsyncApiClient + +class MyAsyncApiClient(BaseAsyncApiClient): + async def get_resource(self, resource_id: str): + session = await self.session + response = await session.get(f"https://api.app.kognic.com/v1/resources/{resource_id}") + return response.json() + +# Usage as async context manager +async with MyAsyncApiClient() as client: + resource = await client.get_resource("123") +``` + ## Changelog See Github releases from v3.1.0, historic changelog is available in CHANGELOG.md diff --git a/src/kognic/auth/_protocols.py b/src/kognic/auth/_protocols.py new file mode 100644 index 0000000..3e10d2a --- /dev/null +++ b/src/kognic/auth/_protocols.py @@ -0,0 +1,31 @@ +"""Protocol definitions for URL, Request, and Response types.""" + +from typing import Dict, Protocol, Union, runtime_checkable + + +@runtime_checkable +class Url(Protocol): + """Protocol for URL objects (httpx URL).""" + + scheme: str + host: str + path: str + + +@runtime_checkable +class Request(Protocol): + """Protocol for HTTP request objects.""" + + method: str + url: Union[str, Url] + + +@runtime_checkable +class Response(Protocol): + """Protocol for HTTP response objects.""" + + headers: Dict[str, str] + request: Request + + def json(self) -> dict: + raise NotImplementedError diff --git a/src/kognic/auth/_serde.py b/src/kognic/auth/_serde.py new file mode 100644 index 0000000..045dd46 --- /dev/null +++ b/src/kognic/auth/_serde.py @@ -0,0 +1,76 @@ +"""Serialization and deserialization utilities for HTTP request/response bodies.""" + +from typing import Any, Dict, List, Optional, Type, Union + +ENVELOPED_KEY = "data" + + +def serialize_body(body: Any) -> Any: + """Serialize request body to JSON-compatible format. + + Supports: + - None, dict, list, primitives (passed through) + - Objects with serialize_to_json() method (duck typing) + + Raises: + ValueError: If body is str or bytes (not supported) + TypeError: If body type is not supported + """ + if body is None: + return None + if isinstance(body, (str, bytes)): + raise ValueError("str and bytes data is not supported") + if isinstance(body, (dict, list, int, float, bool)): + return body + if hasattr(body, "serialize_to_json") and callable(body.serialize_to_json): + return body.serialize_to_json() + raise TypeError(f"Cannot serialize body of type {type(body).__name__}. Expected dict, list, or Serializable.") + + +def deserialize( + response: Union[Any, Dict[str, Any], List], + cls: Optional[Type] = None, + enveloped_key: Optional[str] = ENVELOPED_KEY, +) -> Any: + """Deserialize a response from the API. + + Designed to work with httpx and requests response objects by duck typing. + + Args: + response: Response object (with .json() method) or dict/list + cls: Optional type hint for the expected return type. For basic types + (dict, list) the data is returned as-is. For model classes, + kognic-common must be installed. + enveloped_key: By Kognic convention, data is enveloped in a key. + Default is 'data'. Set to None to skip envelope extraction. + + Returns: + Deserialized data + + Raises: + ValueError: If enveloped_key is specified but not found in response + """ + # Extract JSON from response object or use directly if already a dict/list + try: + response_json = response.json() + except AttributeError: + response_json = response + + # Extract data from envelope if specified + if enveloped_key is not None: + if enveloped_key not in response_json: + raise ValueError( + f"Expected enveloped key '{enveloped_key}' not found in response json. " + f"Found keys: {response_json.keys()}" + ) + data = response_json[enveloped_key] + else: + data = response_json + + # Return raw data if no class specified + if cls is None: + return data + + # For basic types, just return the data (type hints are not enforced at runtime) + if cls in (dict, list) or getattr(cls, "__origin__", None) in (dict, list): + return data diff --git a/src/kognic/auth/_sunset.py b/src/kognic/auth/_sunset.py new file mode 100644 index 0000000..8b29a30 --- /dev/null +++ b/src/kognic/auth/_sunset.py @@ -0,0 +1,52 @@ +"""Sunset header handling for deprecated API endpoints.""" + +import logging +from datetime import datetime +from typing import TYPE_CHECKING, Optional, Union + +if TYPE_CHECKING: + from ._protocols import Response, Url + +SUNSET_HEADER = "sunset-date" +SUNSET_DIFF_THRESHOLD = 14 * 60 * 60 * 24 # two weeks + +# Expected format of sunset date, e.g. 2024-02-22T16:21:20.880547Z +DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" + +logger = logging.getLogger(__name__) + + +def handle_sunset(response: "Response") -> None: + """Check for Sunset header and log warnings/errors. + + Args: + response: The HTTP response object (requests.Response or httpx.Response) + """ + sunset_string = response.headers.get(SUNSET_HEADER) + sunset_date = _parse_date(sunset_string) if sunset_string else None + if not sunset_date: + return None + + now = datetime.now() + diff = sunset_date - now + + log_method = logger.warning if diff.total_seconds() > SUNSET_DIFF_THRESHOLD else logger.error + log_method( + f"Endpoint has been deprecated and will be removed at {sunset_date}. Please update your client. " + f"Endpoint: {response.request.method} {_parse_url(response.request.url)}" + ) + + +def _parse_date(date: str) -> Optional[datetime]: + """Parse sunset date string to datetime.""" + try: + return datetime.strptime(date, DATETIME_FMT) + except ValueError: + return None + + +def _parse_url(url: Union[str, "Url"]) -> str: + """Extract clean URL without query parameters.""" + if isinstance(url, str): + return url.split("?")[0] + return f"{url.scheme}://{url.host}{url.path}" diff --git a/src/kognic/auth/_user_agent.py b/src/kognic/auth/_user_agent.py new file mode 100644 index 0000000..697fe2c --- /dev/null +++ b/src/kognic/auth/_user_agent.py @@ -0,0 +1,25 @@ +"""User-Agent string handling for HTTP clients.""" + +import sys +from typing import Optional + +from kognic.auth import __version__ + +_PY_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + +def get_user_agent(http_lib_version: str, client_name: Optional[str] = None) -> str: + """Build User-Agent string for HTTP requests. + + Args: + http_lib_version: The HTTP library and version (e.g., "requests/2.31.0") + client_name: Optional client name to append + + Returns: + User-Agent string like "kognic-auth/1.0.0 python/3.11.0 requests/2.31.0 MyClient" + """ + base = f"kognic-auth/{__version__} python/{_PY_VERSION} {http_lib_version}" + + if client_name: + return f"{base} {client_name}" + return base diff --git a/src/kognic/auth/httpx/__init__.py b/src/kognic/auth/httpx/__init__.py index e69de29..44429d4 100644 --- a/src/kognic/auth/httpx/__init__.py +++ b/src/kognic/auth/httpx/__init__.py @@ -0,0 +1,4 @@ +from kognic.auth.httpx.async_client import HttpxAuthAsyncClient +from kognic.auth.httpx.base_client import BaseAsyncApiClient + +__all__ = ["HttpxAuthAsyncClient", "BaseAsyncApiClient"] diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py new file mode 100644 index 0000000..f7e0936 --- /dev/null +++ b/src/kognic/auth/httpx/base_client.py @@ -0,0 +1,126 @@ +"""Base async API client V2 using httpx/OAuth2 client.""" + +import asyncio +import logging +from typing import Optional, Union + +import httpx + +from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH +from kognic.auth._serde import serialize_body +from kognic.auth._sunset import handle_sunset +from kognic.auth._user_agent import get_user_agent +from kognic.auth.httpx.async_client import HttpxAuthAsyncClient + +logger = logging.getLogger(__name__) + + +def _handle_http_error(resp: httpx.Response): + """Try to get the error message from the response and raise with that message.""" + try: + resp.raise_for_status() + except httpx.HTTPStatusError as e: + try: + js = resp.json() + err_message = js.get("message", js) + except ValueError: + err_message = resp.text + full_msg = ( + f"Got HttpError with status={resp.status_code} in call to {resp.url}.\n" + f"Got error in response: '{err_message}'" + ) + raise httpx.HTTPStatusError(full_msg, request=resp.request, response=resp) from e + + +class BaseAsyncApiClient(HttpxAuthAsyncClient): + """Base async API client with OAuth2 authentication using httpx. + + Extends HttpxAuthAsyncClient with: + - Automatic JSON serialization + - Retry logic for transient errors (502, 503, 504) + - Sunset header handling + - Enhanced error messages + + The interface is consistent with httpx - use the underlying client methods. + Calls return the response object. Use response.json() to get the data. + + Example: + async with BaseAsyncApiClient() as client: + session = await client.session + response = await session.get("https://api.app.kognic.com/v1/resources") + data = response.json() + """ + + def __init__( + self, + *, + auth: Optional[Union[str, tuple]] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + auth_host: str = DEFAULT_HOST, + token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + client_name: Optional[str] = "auto", + ): + """Initialize the async API client. + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + client_id: OAuth2 client ID (alternative to auth) + client_secret: OAuth2 client secret (alternative to auth) + auth_host: Authentication server base URL + token_endpoint: Relative path to token endpoint + client_name: Name added to User-Agent. Use "auto" for class name, None for no name. + """ + if client_name == "auto": + client_name = self.__class__.__name__ + + # Use a custom transport to set the number of retries for connection errors + transport = httpx.AsyncHTTPTransport(retries=3) + + headers = {"User-Agent": get_user_agent(f"python-httpx/{httpx.__version__}", client_name)} + + super().__init__( + auth=auth, + client_id=client_id, + client_secret=client_secret, + host=auth_host, + token_endpoint=token_endpoint, + transport=transport, + headers=headers, + ) + + # Monkey patch the request method to handle sunset, errors and custom error handling + client_request = self._oauth_client.request + + async def request(method, url, **kwargs): + # Accept anything jsonable as json, serialize it + json = kwargs.pop("json", None) + if json is not None: + kwargs["json"] = serialize_body(json) + + # Wrap the request in simple retry logic for transient errors + async def call_with_simple_retry(attempts): + resp = await client_request(method, url, **kwargs) + if attempts == 0: + return resp + if resp.status_code in (502, 503, 504): + logger.warning(f"Server {resp.status_code} error for request to url={url}\nRetrying in 5s...") + await asyncio.sleep(5) + return await call_with_simple_retry(attempts - 1) + return resp + + resp = await call_with_simple_retry(3) + + handle_sunset(resp) + _handle_http_error(resp) + return resp + + self._oauth_client.request = request + + async def __aenter__(self) -> "BaseAsyncApiClient": + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Async context manager exit.""" + await self.close() diff --git a/src/kognic/auth/requests/__init__.py b/src/kognic/auth/requests/__init__.py index e69de29..41352a0 100644 --- a/src/kognic/auth/requests/__init__.py +++ b/src/kognic/auth/requests/__init__.py @@ -0,0 +1,4 @@ +from kognic.auth.requests.auth_session import RequestsAuthSession +from kognic.auth.requests.base_client import BaseApiClient + +__all__ = ["RequestsAuthSession", "BaseApiClient"] diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py new file mode 100644 index 0000000..13def5a --- /dev/null +++ b/src/kognic/auth/requests/base_client.py @@ -0,0 +1,216 @@ +"""Base API client V2 using requests/OAuth2 session.""" + +import logging +from functools import lru_cache +from threading import Lock +from typing import Optional, Tuple, Union + +import requests +from requests import Session +from requests.adapters import HTTPAdapter, Retry + +from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH +from kognic.auth._serde import serialize_body +from kognic.auth._sunset import handle_sunset +from kognic.auth._user_agent import get_user_agent +from kognic.auth.credentials_parser import resolve_credentials +from kognic.auth.requests.auth_session import RequestsAuthSession + +logger = logging.getLogger(__name__) + + +@lru_cache(maxsize=None) +def _create_cached_oauth_session( + auth_tuple: Optional[Tuple[str, str]], + auth_host: str, + token_endpoint: str, +) -> Session: + """Create and cache an OAuth session by credentials. + + Caching avoids creating multiple sessions for the same credentials. + """ + if auth_tuple: + client_id, client_secret = auth_tuple + return RequestsAuthSession( + client_id=client_id, + client_secret=client_secret, + host=auth_host, + token_endpoint=token_endpoint, + ).session + else: + return RequestsAuthSession( + host=auth_host, + token_endpoint=token_endpoint, + ).session + + +DEFAULT_RETRY = Retry(total=3, connect=3, read=3, backoff_factor=0.5, status_forcelist=[502, 503, 504]) + + +def _check_response(resp: requests.Response): + """Handle sunset headers and raise for status with enhanced error messages.""" + handle_sunset(resp) + try: + resp.raise_for_status() + except requests.HTTPError as e: + try: + js = resp.json() + err_message = js.get("message", js) + except ValueError: + err_message = resp.text + raise requests.HTTPError( + f"Got HttpError with status={resp.status_code} in call to {resp.url}.\n" + f"Got error in response: '{err_message}'", + response=resp, + request=resp.request, + ) from e + + +def _resolve_auth_tuple( + auth: Optional[Union[str, tuple]], + client_id: Optional[str], + client_secret: Optional[str], +) -> Optional[Tuple[str, str]]: + """Resolve auth parameters to a (client_id, client_secret) tuple for caching.""" + + resolved_id, resolved_secret = resolve_credentials(auth, client_id, client_secret) + if resolved_id and resolved_secret: + return resolved_id, resolved_secret + return None + + +def create_session( + *, + auth: Optional[Union[str, tuple]] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + auth_host: str = DEFAULT_HOST, + token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + client_name: Optional[str] = None, +) -> Session: + """Create a requests session with enhancements. + + - OAuth2 authentication with automatic token refresh + - Accept-Encoding: gzip header + - Automatic JSON serialization for jsonable objects + - Default retry logic for transient errors + - Sunset header handling + - Always call raise_for_status with enhanced error messages + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + client_id: OAuth2 client ID (alternative to auth) + client_secret: OAuth2 client secret (alternative to auth) + auth_host: Authentication server base URL + token_endpoint: Relative path to token endpoint + client_name: Name added to User-Agent header + + Returns: + Configured requests Session + """ + # Resolve credentials and get cached OAuth session + auth_tuple = _resolve_auth_tuple(auth, client_id, client_secret) + session = _create_cached_oauth_session(auth_tuple, auth_host, token_endpoint) + + session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) + + session.headers["Accept-Encoding"] = "gzip" + session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) + session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) + + # Monkey patch to serialize JSON and validate paths + vanilla_prep = session.prepare_request + + def prepare_request(req, *args, **kwargs): + if req.url.startswith("/"): + raise ValueError(f"Path must not start with /, got {req.url}") + + # Accept anything jsonable as json, serialize it + if req.json is not None: + req.json = serialize_body(req.json) + + return vanilla_prep(req, *args, **kwargs) + + session.prepare_request = prepare_request + + # Monkey patch to always raise for status and handle errors + vanilla_send = session.send + + def send_request(req, *args, **kwargs): + resp = vanilla_send(req, *args, **kwargs) + _check_response(resp) + return resp + + session.send = send_request + return session + + +class BaseApiClient: + """Base API client with OAuth2 authentication using requests. + + Provides a requests Session with: + - OAuth2 authentication with automatic token refresh + - Automatic JSON serialization + - Retry logic for transient errors (502, 503, 504) + - Sunset header handling + - Enhanced error messages + + The interface is consistent with requests - use session.get(), session.post(), etc. + Calls return the response object. Use response.json() to get the data. + + Example: + client = BaseApiClient() + response = client.session.get("https://api.app.kognic.com/v1/resources") + data = response.json() + """ + + def __init__( + self, + *, + auth: Optional[Union[str, tuple]] = None, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + auth_host: str = DEFAULT_HOST, + token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + client_name: Optional[str] = "auto", + ): + """Initialize the API client. + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + client_id: OAuth2 client ID (alternative to auth) + client_secret: OAuth2 client secret (alternative to auth) + auth_host: Authentication server base URL + token_endpoint: Relative path to token endpoint + client_name: Name added to User-Agent. Use "auto" for class name, None for no name. + """ + self._session: Optional[Session] = None + self._auth = auth + self._client_id = client_id + self._client_secret = client_secret + self._auth_host = auth_host + self._token_endpoint = token_endpoint + self._lock = Lock() + + if client_name == "auto": + client_name = self.__class__.__name__ + self._client_name = client_name + + @property + def session(self) -> Session: + """Get the authenticated requests Session. + + Session is lazily initialized on first access. + """ + if self._session is None: + with self._lock: + if self._session is None: + self._session = create_session( + auth=self._auth, + client_id=self._client_id, + client_secret=self._client_secret, + auth_host=self._auth_host, + token_endpoint=self._token_endpoint, + client_name=self._client_name, + ) + return self._session diff --git a/tests/test_base_client_async.py b/tests/test_base_client_async.py new file mode 100644 index 0000000..22f2b0f --- /dev/null +++ b/tests/test_base_client_async.py @@ -0,0 +1,36 @@ +"""Unit tests for BaseAsyncApiClient (async client).""" + +import unittest +from unittest.mock import patch + + +class TestBaseAsyncApiClient(unittest.TestCase): + @patch("kognic.auth.httpx.base_client.HttpxAuthAsyncClient.__init__", return_value=None) + def test_client_name_auto(self, mock_init): + from kognic.auth.httpx.base_client import BaseAsyncApiClient + + # Need to manually set _oauth_client since we mocked __init__ + with patch.object(BaseAsyncApiClient, "__init__", lambda self, **kwargs: None): + client = BaseAsyncApiClient.__new__(BaseAsyncApiClient) + # Simulate what __init__ would do for client_name + client_name = "auto" + if client_name == "auto": + client_name = client.__class__.__name__ + self.assertEqual(client_name, "BaseAsyncApiClient") + + def test_inherits_from_httpx_auth_client(self): + from kognic.auth.httpx.async_client import HttpxAuthAsyncClient + from kognic.auth.httpx.base_client import BaseAsyncApiClient + + self.assertTrue(issubclass(BaseAsyncApiClient, HttpxAuthAsyncClient)) + + def test_has_context_manager_methods(self): + from kognic.auth.httpx.base_client import BaseAsyncApiClient + + self.assertTrue(hasattr(BaseAsyncApiClient, "__aenter__")) + self.assertTrue(hasattr(BaseAsyncApiClient, "__aexit__")) + self.assertTrue(hasattr(BaseAsyncApiClient, "close")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py new file mode 100644 index 0000000..92b54c3 --- /dev/null +++ b/tests/test_base_client_sync.py @@ -0,0 +1,92 @@ +"""Unit tests for BaseApiClient (sync client).""" + +import unittest +from unittest.mock import MagicMock, patch + +from kognic.auth._sunset import DATETIME_FMT, handle_sunset + + +class TestSunsetHeaderHandling(unittest.TestCase): + def _make_mock_response(self, url: str, headers: dict, method: str = "GET"): + response = MagicMock() + response.request.url = url + response.request.method = method + response.headers = headers + response.status_code = 200 + return response + + def test_no_sunset_header(self): + response = self._make_mock_response("https://api.example.com/v1/test", {}) + # Should not log anything + with patch("kognic.auth._sunset.logger") as mock_logger: + handle_sunset(response) + mock_logger.warning.assert_not_called() + mock_logger.error.assert_not_called() + + def test_sunset_header_logs_warning_when_far(self): + # Sunset date far in the future (> 2 weeks) + from datetime import datetime, timedelta + + future_date = datetime.now() + timedelta(days=30) + sunset_str = future_date.strftime(DATETIME_FMT) + + response = self._make_mock_response("https://api.example.com/v1/sunset-test", {"sunset-date": sunset_str}) + + with patch("kognic.auth._sunset.logger") as mock_logger: + handle_sunset(response) + mock_logger.warning.assert_called_once() + call_args = mock_logger.warning.call_args[0][0] + self.assertIn("deprecated", call_args) + self.assertIn("sunset-test", call_args) + + def test_sunset_header_logs_error_when_close(self): + # Sunset date close (< 2 weeks) + from datetime import datetime, timedelta + + close_date = datetime.now() + timedelta(days=7) + sunset_str = close_date.strftime(DATETIME_FMT) + + response = self._make_mock_response("https://api.example.com/v1/sunset-test", {"sunset-date": sunset_str}) + + with patch("kognic.auth._sunset.logger") as mock_logger: + handle_sunset(response) + mock_logger.error.assert_called_once() + + +class TestBaseApiClient(unittest.TestCase): + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + def test_session_lazy_init(self, mock_session_class): + from kognic.auth.requests.base_client import BaseApiClient + + mock_instance = MagicMock() + mock_instance.session = MagicMock() + mock_session_class.return_value = mock_instance + + client = BaseApiClient(client_id="test", client_secret="secret") + + # Session should not be created yet + self.assertIsNone(client._session) + + # Access session to trigger lazy init + _ = client.session + + # Now session should be created + mock_session_class.assert_called_once() + + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + def test_client_name_auto(self, mock_session_class): + from kognic.auth.requests.base_client import BaseApiClient + + mock_instance = MagicMock() + mock_instance.session = MagicMock() + mock_session_class.return_value = mock_instance + + class MyCustomClient(BaseApiClient): + pass + + client = MyCustomClient(client_id="test", client_secret="secret") + self.assertEqual(client._client_name, "MyCustomClient") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_serde.py b/tests/test_serde.py new file mode 100644 index 0000000..1c47e52 --- /dev/null +++ b/tests/test_serde.py @@ -0,0 +1,104 @@ +"""Unit tests for serialization and deserialization utilities.""" + +import unittest +from typing import Dict + +from httpx import Response + +from kognic.auth._serde import deserialize, serialize_body + + +class TestSerializeBody(unittest.TestCase): + def test_serialize_none(self): + self.assertIsNone(serialize_body(None)) + + def test_serialize_dict(self): + data = {"key": "value"} + self.assertEqual(serialize_body(data), data) + + def test_serialize_nested_dict(self): + data = {"key": {"nested_key": "value"}} + self.assertEqual(serialize_body(data), data) + + def test_serialize_list(self): + data = ["apa", "bepa"] + self.assertEqual(serialize_body(data), data) + + def test_serialize_str_raises(self): + with self.assertRaises(ValueError): + serialize_body('{"key": "value"}') + + def test_serialize_bytes_raises(self): + with self.assertRaises(ValueError): + serialize_body(b'{"key": "value"}') + + def test_serialize_object_with_method(self): + class MyModel: + def serialize_to_json(self): + return {"serialized": True} + + self.assertEqual(serialize_body(MyModel()), {"serialized": True}) + + def test_serialize_unsupported_type_raises(self): + class UnsupportedType: + pass + + with self.assertRaises(TypeError): + serialize_body(UnsupportedType()) + + +class TestDeserialize(unittest.TestCase): + def test_deserialize_to_Dict(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=Dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_dict(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_raw(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=None) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_with_custom_envelope_key(self): + resp = {"custom_key": {"key": "value"}} + val = deserialize(resp, cls=Dict[str, str], enveloped_key="custom_key") + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_from_dict(self): + resp = {"data": {"key": "value"}} + val = deserialize(resp, cls=Dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_list(self): + resp = Response(200, json={"data": [1, 2, 3]}) + val = deserialize(resp, cls=list[int]) + self.assertEqual(val, [1, 2, 3]) + + def test_deserialize_to_list_of_dicts(self): + resp = Response(200, json={"data": [{"key": "value"}]}) + val = deserialize(resp, cls=list[Dict[str, str]]) + self.assertEqual(val, [{"key": "value"}]) + + def test_deserialize_empty_list(self): + resp = Response(200, json={"data": []}) + val = deserialize(resp, cls=list[Dict[str, str]]) + self.assertEqual(val, []) + + def test_deserialize_no_envelope(self): + resp = Response(200, json={"key": "value"}) + val = deserialize(resp, cls=None, enveloped_key=None) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_missing_envelope_key_raises(self): + resp = Response(200, json={"wrong_key": {"key": "value"}}) + with self.assertRaises(ValueError) as context: + deserialize(resp, cls=None) + self.assertIn("Expected enveloped key 'data' not found", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sunset.py b/tests/test_sunset.py new file mode 100644 index 0000000..e771a6b --- /dev/null +++ b/tests/test_sunset.py @@ -0,0 +1,105 @@ +from datetime import datetime, timedelta +from unittest import TestCase + +import httpx +import pytest +import requests + +from kognic.auth._sunset import DATETIME_FMT, handle_sunset + +SUNSET_HEADER = "sunset-date" + + +def date_to_str(date: datetime): + return date.strftime(DATETIME_FMT) + + +SUNSET_DATE_LONG_TIME_AGO = "2024-02-22T16:21:20.880547Z" # => error +SUNSET_DATE_5_DAYS_AGO = date_to_str(datetime.now()) # now => error +SUNSET_DATE_IN_13_DAYS = date_to_str(datetime.now() + timedelta(days=13)) # in 13 days => error +SUNSET_DATE_IN_15_DAYS = date_to_str(datetime.now() + timedelta(days=15)) # in 15 days => warning +SUNSET_DATE_WRONG_FORMAT = "2024-02-22T16:21:20Z" # => error + +url = "https://example.com/endpoint?key=1" + + +def make_requests_response(sunset_date: str | None) -> requests.Response: + response = requests.Response() + if sunset_date: + response.headers[SUNSET_HEADER] = sunset_date + response.request = requests.Request("GET", url) + return response + + +def make_httpx_response(sunset_date: str | None) -> httpx.Response: + headers = {SUNSET_HEADER: sunset_date} if sunset_date else None + return httpx.Response(status_code=200, headers=headers, request=httpx.Request("GET", url)) + + +def run_test_with_response(caplog, response, expected_log_level: str | None): + handle_sunset(response) + if expected_log_level: + log_record = caplog.records[0] + assert log_record.levelname == expected_log_level + assert "Endpoint has been deprecated and will be removed at" in log_record.message + assert log_record.message.endswith(f"Endpoint: GET {str(response.request.url).split('?')[0]}") + + +class TestSunsetDateRequests(TestCase): + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def test_when_sunset_date_not_set(self): + response = make_requests_response(None) + run_test_with_response(self._caplog, response, None) + + def test_when_sunset_date_invalid(self): + response = make_requests_response(SUNSET_DATE_WRONG_FORMAT) + run_test_with_response(self._caplog, response, None) + + def test_when_sunset_date_long_time_ago(self): + response = make_requests_response(SUNSET_DATE_LONG_TIME_AGO) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_5_days_ago(self): + response = make_requests_response(SUNSET_DATE_5_DAYS_AGO) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_in_13_days(self): + response = make_requests_response(SUNSET_DATE_IN_13_DAYS) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_in_15_days(self): + response = make_requests_response(SUNSET_DATE_IN_15_DAYS) + run_test_with_response(self._caplog, response, "WARNING") + + +class TestSunsetDateHttpx(TestCase): + @pytest.fixture(autouse=True) + def inject_fixtures(self, caplog): + self._caplog = caplog + + def test_when_sunset_date_not_set(self): + response = make_httpx_response(None) + run_test_with_response(self._caplog, response, None) + + def test_when_sunset_date_invalid(self): + response = make_httpx_response(SUNSET_DATE_WRONG_FORMAT) + run_test_with_response(self._caplog, response, None) + + def test_when_sunset_date_long_time_ago(self): + response = make_httpx_response(SUNSET_DATE_LONG_TIME_AGO) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_5_days_ago(self): + response = make_httpx_response(SUNSET_DATE_5_DAYS_AGO) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_in_13_days(self): + response = make_httpx_response(SUNSET_DATE_IN_13_DAYS) + run_test_with_response(self._caplog, response, "ERROR") + + def test_when_sunset_date_in_15_days(self): + response = make_httpx_response(SUNSET_DATE_IN_15_DAYS) + run_test_with_response(self._caplog, response, "WARNING") diff --git a/tests/test_user_agent.py b/tests/test_user_agent.py new file mode 100644 index 0000000..5f4ad7f --- /dev/null +++ b/tests/test_user_agent.py @@ -0,0 +1,29 @@ +"""Unit tests for User-Agent handling.""" + +import unittest + +from kognic.auth import __version__ +from kognic.auth._user_agent import _PY_VERSION, get_user_agent + + +class TestGetUserAgent(unittest.TestCase): + def test_basic_user_agent(self): + ua = get_user_agent("requests/2.31.0") + self.assertEqual(ua, f"kognic-auth/{__version__} python/{_PY_VERSION} requests/2.31.0") + + def test_user_agent_with_client_name(self): + ua = get_user_agent("python-httpx/0.28.1", "MyClient") + self.assertEqual(ua, f"kognic-auth/{__version__} python/{_PY_VERSION} python-httpx/0.28.1 MyClient") + + def test_user_agent_with_none_client_name(self): + ua = get_user_agent("requests/2.31.0", None) + self.assertEqual(ua, f"kognic-auth/{__version__} python/{_PY_VERSION} requests/2.31.0") + + def test_user_agent_with_empty_client_name(self): + ua = get_user_agent("requests/2.31.0", "") + # Empty string is falsy, so no client name appended + self.assertEqual(ua, f"kognic-auth/{__version__} python/{_PY_VERSION} requests/2.31.0") + + +if __name__ == "__main__": + unittest.main() From 22d2475320d6edba41eefeee651987df85deb37e Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 13:52:30 +0100 Subject: [PATCH 02/21] Improve serde: recursive serialization, to_json method, split tests - Add recursive serialization for nested objects in containers - Rename serialize_to_json to to_json for consistency - to_json takes precedence over to_dict - Split test_serde.py into test_serialization.py and test_deserialization.py - Rename _serde.py to serde.py (public module) Co-Authored-By: Claude Opus 4.5 --- README.md | 4 +- src/kognic/auth/_serde.py | 76 ------------ src/kognic/auth/httpx/base_client.py | 2 +- src/kognic/auth/requests/base_client.py | 2 +- src/kognic/auth/serde.py | 127 +++++++++++++++++++++ tests/test_deserialization.py | 146 ++++++++++++++++++++++++ tests/test_serde.py | 104 ----------------- tests/test_serialization.py | 118 +++++++++++++++++++ 8 files changed, 395 insertions(+), 184 deletions(-) delete mode 100644 src/kognic/auth/_serde.py create mode 100644 src/kognic/auth/serde.py create mode 100644 tests/test_deserialization.py delete mode 100644 tests/test_serde.py create mode 100644 tests/test_serialization.py diff --git a/README.md b/README.md index bbc2cba..d014cf0 100644 --- a/README.md +++ b/README.md @@ -166,8 +166,8 @@ kognic-auth call https://app.kognic.com/v1/projects --format=table ## Base API Clients -For building API clients that need authenticated HTTP requests, use the V2 base clients. -These provide a requests/httpx-compatible interface with enhancements: +For building API clients that need authenticated HTTP requests, use the base clients. +These provide a `requests`/`httpx`-compatible interface with enhancements: - OAuth2 authentication with automatic token refresh - Automatic JSON serialization for jsonable objects diff --git a/src/kognic/auth/_serde.py b/src/kognic/auth/_serde.py deleted file mode 100644 index 045dd46..0000000 --- a/src/kognic/auth/_serde.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Serialization and deserialization utilities for HTTP request/response bodies.""" - -from typing import Any, Dict, List, Optional, Type, Union - -ENVELOPED_KEY = "data" - - -def serialize_body(body: Any) -> Any: - """Serialize request body to JSON-compatible format. - - Supports: - - None, dict, list, primitives (passed through) - - Objects with serialize_to_json() method (duck typing) - - Raises: - ValueError: If body is str or bytes (not supported) - TypeError: If body type is not supported - """ - if body is None: - return None - if isinstance(body, (str, bytes)): - raise ValueError("str and bytes data is not supported") - if isinstance(body, (dict, list, int, float, bool)): - return body - if hasattr(body, "serialize_to_json") and callable(body.serialize_to_json): - return body.serialize_to_json() - raise TypeError(f"Cannot serialize body of type {type(body).__name__}. Expected dict, list, or Serializable.") - - -def deserialize( - response: Union[Any, Dict[str, Any], List], - cls: Optional[Type] = None, - enveloped_key: Optional[str] = ENVELOPED_KEY, -) -> Any: - """Deserialize a response from the API. - - Designed to work with httpx and requests response objects by duck typing. - - Args: - response: Response object (with .json() method) or dict/list - cls: Optional type hint for the expected return type. For basic types - (dict, list) the data is returned as-is. For model classes, - kognic-common must be installed. - enveloped_key: By Kognic convention, data is enveloped in a key. - Default is 'data'. Set to None to skip envelope extraction. - - Returns: - Deserialized data - - Raises: - ValueError: If enveloped_key is specified but not found in response - """ - # Extract JSON from response object or use directly if already a dict/list - try: - response_json = response.json() - except AttributeError: - response_json = response - - # Extract data from envelope if specified - if enveloped_key is not None: - if enveloped_key not in response_json: - raise ValueError( - f"Expected enveloped key '{enveloped_key}' not found in response json. " - f"Found keys: {response_json.keys()}" - ) - data = response_json[enveloped_key] - else: - data = response_json - - # Return raw data if no class specified - if cls is None: - return data - - # For basic types, just return the data (type hints are not enforced at runtime) - if cls in (dict, list) or getattr(cls, "__origin__", None) in (dict, list): - return data diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index f7e0936..d2de656 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -7,10 +7,10 @@ import httpx from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH -from kognic.auth._serde import serialize_body from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent from kognic.auth.httpx.async_client import HttpxAuthAsyncClient +from kognic.auth.serde import serialize_body logger = logging.getLogger(__name__) diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 13def5a..95e06e5 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -10,11 +10,11 @@ from requests.adapters import HTTPAdapter, Retry from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH -from kognic.auth._serde import serialize_body from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent from kognic.auth.credentials_parser import resolve_credentials from kognic.auth.requests.auth_session import RequestsAuthSession +from kognic.auth.serde import serialize_body logger = logging.getLogger(__name__) diff --git a/src/kognic/auth/serde.py b/src/kognic/auth/serde.py new file mode 100644 index 0000000..3bf25f2 --- /dev/null +++ b/src/kognic/auth/serde.py @@ -0,0 +1,127 @@ +"""Serialization and deserialization utilities for HTTP request/response bodies.""" + +from typing import Any, Dict, List, Optional, Type, Union + +ENVELOPED_KEY = "data" + + +def serialize_body(body: Any) -> Any: + """Serialize request body to JSON-compatible format. + + Supports: + - None, dict, list, primitives (passed through) + - Objects with to_json() or to_dict method (duck typing) + - Nested objects inside containers are recursively serialized + + Raises: + ValueError: If body is str or bytes at top level (not supported as request body) + TypeError: If body type is not supported + """ + if body is None: + return None + if isinstance(body, (str, bytes)): + raise ValueError("str and bytes data is not supported") + return _serialize_value(body) + + +def _serialize_value(value: Any) -> Any: + """Recursively serialize a value (used internally for container contents).""" + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, bytes): + raise ValueError("bytes data is not supported") + if isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + if isinstance(value, list): + return [_serialize_value(item) for item in value] + if hasattr(value, "to_json") and callable(value.to_json): + return _serialize_value(value.to_json()) + if hasattr(value, "to_dict") and callable(value.to_dict): + return _serialize_value(value.to_dict()) + raise TypeError( + f"Cannot serialize value of type {type(value).__name__}. Expected dict, list, primitive, or Serializable." + ) + + +def deserialize( + response: Union[Any, Dict[str, Any], List], + cls: Optional[Type] = None, + enveloped_key: Optional[str] = ENVELOPED_KEY, +) -> Any: + """Deserialize a response from the API. + + Designed to work with httpx and requests response objects by duck typing. + + Args: + response: Response object (with .json() method) or dict/list + cls: Optional type hint for the expected return type. For basic types + (dict, list) the data is returned as-is. For model classes, + kognic-common must be installed. + enveloped_key: By Kognic convention, data is enveloped in a key. + Default is 'data'. Set to None to skip envelope extraction. + + Returns: + Deserialized data + + Raises: + ValueError: If enveloped_key is specified but not found in response + """ + # Extract JSON from response object or use directly if already a dict/list + try: + response_json = response.json() + except AttributeError: + response_json = response + + # Extract data from envelope if specified + if enveloped_key is not None: + if enveloped_key not in response_json: + raise ValueError( + f"Expected enveloped key '{enveloped_key}' not found in response json. " + f"Found keys: {response_json.keys()}" + ) + data = response_json[enveloped_key] + else: + data = response_json + + # Return raw data if no class specified + if cls is None: + return data + + # For basic types (dict, list without inner type), return the data as-is + if cls in (dict, list): + return data + + # Handle generic types like Dict[str, str] or list[MyModel] + origin = getattr(cls, "__origin__", None) + if origin is dict: + return data + + # Handle list[SomeClass] - deserialize each item + if origin is list: + args = getattr(cls, "__args__", ()) + if not args: + return data + inner_cls = args[0] + # If inner type is a basic type, return as-is + if inner_cls in (dict, list, str, int, float, bool) or getattr(inner_cls, "__origin__", None) is not None: + return data + # Deserialize each item using inner class + return [_deserialize_object(item, inner_cls) for item in data] + + # Single object deserialization + return _deserialize_object(data, cls) + + +def _deserialize_object(data: Any, cls: Type) -> Any: + """Deserialize a single object using duck-typed from_dict/from_json.""" + if hasattr(cls, "from_dict") and callable(cls.from_dict): + return cls.from_dict(data) + + if hasattr(cls, "from_json") and callable(cls.from_json): + return cls.from_json(data) + + raise TypeError( + f"Cannot deserialize to {cls.__name__}. " f"Class must have a from_dict() or from_json() class method." + ) diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py new file mode 100644 index 0000000..9dc71b2 --- /dev/null +++ b/tests/test_deserialization.py @@ -0,0 +1,146 @@ +"""Unit tests for deserialization utilities.""" + +import unittest +from typing import Dict + +from httpx import Response + +from kognic.auth.serde import deserialize + + +class TestDeserialize(unittest.TestCase): + def test_deserialize_to_Dict(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=Dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_dict(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_raw(self): + resp = Response(200, json={"data": {"key": "value"}}) + val = deserialize(resp, cls=None) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_with_custom_envelope_key(self): + resp = {"custom_key": {"key": "value"}} + val = deserialize(resp, cls=Dict[str, str], enveloped_key="custom_key") + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_from_dict(self): + resp = {"data": {"key": "value"}} + val = deserialize(resp, cls=Dict[str, str]) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_to_list(self): + resp = Response(200, json={"data": [1, 2, 3]}) + val = deserialize(resp, cls=list[int]) + self.assertEqual(val, [1, 2, 3]) + + def test_deserialize_to_list_of_dicts(self): + resp = Response(200, json={"data": [{"key": "value"}]}) + val = deserialize(resp, cls=list[Dict[str, str]]) + self.assertEqual(val, [{"key": "value"}]) + + def test_deserialize_empty_list(self): + resp = Response(200, json={"data": []}) + val = deserialize(resp, cls=list[Dict[str, str]]) + self.assertEqual(val, []) + + def test_deserialize_no_envelope(self): + resp = Response(200, json={"key": "value"}) + val = deserialize(resp, cls=None, enveloped_key=None) + self.assertEqual(val, {"key": "value"}) + + def test_deserialize_missing_envelope_key_raises(self): + resp = Response(200, json={"wrong_key": {"key": "value"}}) + with self.assertRaises(ValueError) as context: + deserialize(resp, cls=None) + self.assertIn("Expected enveloped key 'data' not found", str(context.exception)) + + def test_deserialize_to_class_with_from_dict(self): + class MyModel: + def __init__(self, key: str): + self.key = key + + @classmethod + def from_dict(cls, data: dict): + return cls(key=data["key"]) + + resp = Response(200, json={"data": {"key": "value"}}) + result = deserialize(resp, cls=MyModel) + self.assertIsInstance(result, MyModel) + self.assertEqual(result.key, "value") + + def test_deserialize_to_class_with_from_json(self): + class MyModel: + def __init__(self, key: str): + self.key = key + + @classmethod + def from_json(cls, data: dict): + return cls(key=data["key"]) + + resp = Response(200, json={"data": {"key": "value"}}) + result = deserialize(resp, cls=MyModel) + self.assertIsInstance(result, MyModel) + self.assertEqual(result.key, "value") + + def test_deserialize_list_to_class_with_from_dict(self): + class MyModel: + def __init__(self, key: str): + self.key = key + + @classmethod + def from_dict(cls, data: dict): + return cls(key=data["key"]) + + resp = Response(200, json={"data": [{"key": "a"}, {"key": "b"}]}) + result = deserialize(resp, cls=list[MyModel]) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], MyModel) + self.assertEqual(result[0].key, "a") + self.assertEqual(result[1].key, "b") + + def test_deserialize_list_to_class_with_from_json(self): + class MyModel: + def __init__(self, key: str): + self.key = key + + @classmethod + def from_json(cls, data: dict): + return cls(key=data["key"]) + + resp = Response(200, json={"data": [{"key": "x"}, {"key": "y"}]}) + result = deserialize(resp, cls=list[MyModel]) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertEqual(result[0].key, "x") + self.assertEqual(result[1].key, "y") + + def test_deserialize_empty_list_to_class(self): + class MyModel: + @classmethod + def from_dict(cls, data: dict): + return cls() + + resp = Response(200, json={"data": []}) + result = deserialize(resp, cls=list[MyModel]) + self.assertEqual(result, []) + + def test_deserialize_unsupported_class_raises(self): + class UnsupportedModel: + pass + + resp = Response(200, json={"data": {"key": "value"}}) + with self.assertRaises(TypeError) as context: + deserialize(resp, cls=UnsupportedModel) + self.assertIn("Cannot deserialize to UnsupportedModel", str(context.exception)) + self.assertIn("from_dict()", str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_serde.py b/tests/test_serde.py deleted file mode 100644 index 1c47e52..0000000 --- a/tests/test_serde.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Unit tests for serialization and deserialization utilities.""" - -import unittest -from typing import Dict - -from httpx import Response - -from kognic.auth._serde import deserialize, serialize_body - - -class TestSerializeBody(unittest.TestCase): - def test_serialize_none(self): - self.assertIsNone(serialize_body(None)) - - def test_serialize_dict(self): - data = {"key": "value"} - self.assertEqual(serialize_body(data), data) - - def test_serialize_nested_dict(self): - data = {"key": {"nested_key": "value"}} - self.assertEqual(serialize_body(data), data) - - def test_serialize_list(self): - data = ["apa", "bepa"] - self.assertEqual(serialize_body(data), data) - - def test_serialize_str_raises(self): - with self.assertRaises(ValueError): - serialize_body('{"key": "value"}') - - def test_serialize_bytes_raises(self): - with self.assertRaises(ValueError): - serialize_body(b'{"key": "value"}') - - def test_serialize_object_with_method(self): - class MyModel: - def serialize_to_json(self): - return {"serialized": True} - - self.assertEqual(serialize_body(MyModel()), {"serialized": True}) - - def test_serialize_unsupported_type_raises(self): - class UnsupportedType: - pass - - with self.assertRaises(TypeError): - serialize_body(UnsupportedType()) - - -class TestDeserialize(unittest.TestCase): - def test_deserialize_to_Dict(self): - resp = Response(200, json={"data": {"key": "value"}}) - val = deserialize(resp, cls=Dict[str, str]) - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_to_dict(self): - resp = Response(200, json={"data": {"key": "value"}}) - val = deserialize(resp, cls=dict[str, str]) - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_to_raw(self): - resp = Response(200, json={"data": {"key": "value"}}) - val = deserialize(resp, cls=None) - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_with_custom_envelope_key(self): - resp = {"custom_key": {"key": "value"}} - val = deserialize(resp, cls=Dict[str, str], enveloped_key="custom_key") - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_from_dict(self): - resp = {"data": {"key": "value"}} - val = deserialize(resp, cls=Dict[str, str]) - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_to_list(self): - resp = Response(200, json={"data": [1, 2, 3]}) - val = deserialize(resp, cls=list[int]) - self.assertEqual(val, [1, 2, 3]) - - def test_deserialize_to_list_of_dicts(self): - resp = Response(200, json={"data": [{"key": "value"}]}) - val = deserialize(resp, cls=list[Dict[str, str]]) - self.assertEqual(val, [{"key": "value"}]) - - def test_deserialize_empty_list(self): - resp = Response(200, json={"data": []}) - val = deserialize(resp, cls=list[Dict[str, str]]) - self.assertEqual(val, []) - - def test_deserialize_no_envelope(self): - resp = Response(200, json={"key": "value"}) - val = deserialize(resp, cls=None, enveloped_key=None) - self.assertEqual(val, {"key": "value"}) - - def test_deserialize_missing_envelope_key_raises(self): - resp = Response(200, json={"wrong_key": {"key": "value"}}) - with self.assertRaises(ValueError) as context: - deserialize(resp, cls=None) - self.assertIn("Expected enveloped key 'data' not found", str(context.exception)) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..42702b2 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,118 @@ +"""Unit tests for serialization utilities.""" + +import unittest + +from kognic.auth.serde import serialize_body + + +class TestSerializeBody(unittest.TestCase): + def test_serialize_none(self): + self.assertIsNone(serialize_body(None)) + + def test_serialize_dict(self): + data = {"key": "value"} + self.assertEqual(serialize_body(data), data) + + def test_serialize_nested_dict(self): + data = {"key": {"nested_key": "value"}} + self.assertEqual(serialize_body(data), data) + + def test_serialize_list(self): + data = ["apa", "bepa"] + self.assertEqual(serialize_body(data), data) + + def test_serialize_str_raises(self): + with self.assertRaises(ValueError): + serialize_body('{"key": "value"}') + + def test_serialize_bytes_raises(self): + with self.assertRaises(ValueError): + serialize_body(b'{"key": "value"}') + + def test_serialize_object_with_serialize_to_json(self): + class MyModel: + def to_json(self): + return {"serialized": True} + + self.assertEqual(serialize_body(MyModel()), {"serialized": True}) + + def test_serialize_object_with_to_dict(self): + class MyModel: + def to_dict(self): + return {"key": "value"} + + self.assertEqual(serialize_body(MyModel()), {"key": "value"}) + + def test_serialize_to_json_takes_precedence_over_to_dict(self): + class MyModel: + def to_json(self): + return {"from": "to_json"} + + def to_dict(self): + return {"from": "to_dict"} + + self.assertEqual(serialize_body(MyModel()), {"from": "to_json"}) + + def test_serialize_unsupported_type_raises(self): + class UnsupportedType: + pass + + with self.assertRaises(TypeError): + serialize_body(UnsupportedType()) + + def test_serialize_dict_with_strings(self): + data = {"key": "value", "nested": {"inner": "string"}} + self.assertEqual(serialize_body(data), data) + + def test_serialize_list_with_strings(self): + data = ["a", "b", "c"] + self.assertEqual(serialize_body(data), data) + + def test_serialize_nested_object_in_dict(self): + class Inner: + def to_dict(self): + return {"inner": "value"} + + data = {"outer": Inner()} + result = serialize_body(data) + self.assertEqual(result, {"outer": {"inner": "value"}}) + + def test_serialize_nested_object_in_list(self): + class Item: + def __init__(self, name): + self.name = name + + def to_dict(self): + return {"name": self.name} + + data = [Item("a"), Item("b")] + result = serialize_body(data) + self.assertEqual(result, [{"name": "a"}, {"name": "b"}]) + + def test_serialize_deeply_nested(self): + class Inner: + def to_dict(self): + return {"level": "inner"} + + class Outer: + def __init__(self): + self.inner = Inner() + + def to_dict(self): + return {"level": "outer", "child": self.inner} + + result = serialize_body(Outer()) + self.assertEqual(result, {"level": "outer", "child": {"level": "inner"}}) + + def test_serialize_mixed_list(self): + class Item: + def to_dict(self): + return {"type": "object"} + + data = ["string", 42, Item(), {"key": "value"}] + result = serialize_body(data) + self.assertEqual(result, ["string", 42, {"type": "object"}, {"key": "value"}]) + + +if __name__ == "__main__": + unittest.main() From 33f9a724f97a61e5922d442ffc6cba2fb38fdd14 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 14:10:18 +0100 Subject: [PATCH 03/21] Add Pydantic v2 support and improve type checking - Add model_dump/model_validate support for Pydantic v2 models - Add robust type checking using MutableSequence/MutableMapping - Add pydantic>=2 to dev dependencies - Fix Python 3.9 compatibility (use Optional[str] instead of str | None) - Add test_serde_pydantic.py with 11 Pydantic tests - Add test_type_helpers.py with 12 type helper tests Co-Authored-By: Claude Opus 4.5 --- pyproject.toml | 1 + src/kognic/auth/serde.py | 56 ++++++++++++++++----- tests/test_serde_pydantic.py | 98 ++++++++++++++++++++++++++++++++++++ tests/test_sunset.py | 7 +-- tests/test_type_helpers.py | 49 ++++++++++++++++++ 5 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 tests/test_serde_pydantic.py create mode 100644 tests/test_type_helpers.py diff --git a/pyproject.toml b/pyproject.toml index 8a8c4f3..dd5e0d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,7 @@ full = [ dev = [ "httpx>=0.20,<1", "requests>=2.20,<3", + "pydantic>=2", "pytest", ] diff --git a/src/kognic/auth/serde.py b/src/kognic/auth/serde.py index 3bf25f2..5a305c1 100644 --- a/src/kognic/auth/serde.py +++ b/src/kognic/auth/serde.py @@ -1,10 +1,33 @@ """Serialization and deserialization utilities for HTTP request/response bodies.""" +from collections.abc import MutableMapping, MutableSequence from typing import Any, Dict, List, Optional, Type, Union ENVELOPED_KEY = "data" +def _is_list_type(cls: Type) -> bool: + """Check if cls is a list-like type (list, List, List[T], or MutableSequence subclass).""" + try: + return issubclass(cls.__origin__, MutableSequence) + except (AttributeError, TypeError): + try: + return issubclass(cls, MutableSequence) + except TypeError: + return False + + +def _is_dict_type(cls: Type) -> bool: + """Check if cls is a dict-like type (dict, Dict, Dict[K,V], or MutableMapping subclass).""" + try: + return issubclass(cls.__origin__, MutableMapping) + except (AttributeError, TypeError): + try: + return issubclass(cls, MutableMapping) + except TypeError: + return False + + def serialize_body(body: Any) -> Any: """Serialize request body to JSON-compatible format. @@ -36,6 +59,8 @@ def _serialize_value(value: Any) -> Any: return {k: _serialize_value(v) for k, v in value.items()} if isinstance(value, list): return [_serialize_value(item) for item in value] + if hasattr(value, "model_dump") and callable(value.model_dump): # Pydantic v2 + return _serialize_value(value.model_dump()) if hasattr(value, "to_json") and callable(value.to_json): return _serialize_value(value.to_json()) if hasattr(value, "to_dict") and callable(value.to_dict): @@ -89,23 +114,18 @@ def deserialize( if cls is None: return data - # For basic types (dict, list without inner type), return the data as-is - if cls in (dict, list): + # For dict-like types, return the data as-is + if _is_dict_type(cls): return data - # Handle generic types like Dict[str, str] or list[MyModel] - origin = getattr(cls, "__origin__", None) - if origin is dict: - return data - - # Handle list[SomeClass] - deserialize each item - if origin is list: + # Handle list-like types + if _is_list_type(cls): args = getattr(cls, "__args__", ()) if not args: return data inner_cls = args[0] - # If inner type is a basic type, return as-is - if inner_cls in (dict, list, str, int, float, bool) or getattr(inner_cls, "__origin__", None) is not None: + # If inner type is a basic type or generic, return as-is + if inner_cls in (dict, list, str, int, float, bool) or _is_dict_type(inner_cls) or _is_list_type(inner_cls): return data # Deserialize each item using inner class return [_deserialize_object(item, inner_cls) for item in data] @@ -115,7 +135,16 @@ def deserialize( def _deserialize_object(data: Any, cls: Type) -> Any: - """Deserialize a single object using duck-typed from_dict/from_json.""" + """Deserialize a single object using duck-typed methods. + + Supports: + - Pydantic v2 models (model_validate) + - Classes with from_dict() class method + - Classes with from_json() class method + """ + if hasattr(cls, "model_validate") and callable(cls.model_validate): # Pydantic v2 + return cls.model_validate(data) + if hasattr(cls, "from_dict") and callable(cls.from_dict): return cls.from_dict(data) @@ -123,5 +152,6 @@ def _deserialize_object(data: Any, cls: Type) -> Any: return cls.from_json(data) raise TypeError( - f"Cannot deserialize to {cls.__name__}. " f"Class must have a from_dict() or from_json() class method." + f"Cannot deserialize to {cls.__name__}. " + f"Class must have model_validate(), from_dict(), or from_json() class method." ) diff --git a/tests/test_serde_pydantic.py b/tests/test_serde_pydantic.py new file mode 100644 index 0000000..edb17d5 --- /dev/null +++ b/tests/test_serde_pydantic.py @@ -0,0 +1,98 @@ +"""Unit tests for Pydantic v2 integration with serde.""" + +import unittest +from typing import Optional + +from httpx import Response +from pydantic import BaseModel + +from kognic.auth.serde import deserialize, serialize_body + + +class SimpleModel(BaseModel): + name: str + value: int + + +class NestedModel(BaseModel): + title: str + item: SimpleModel + + +class OptionalFieldModel(BaseModel): + name: str + description: Optional[str] = None + + +class TestPydanticSerialization(unittest.TestCase): + def test_serialize_pydantic_model(self): + model = SimpleModel(name="test", value=42) + result = serialize_body(model) + self.assertEqual(result, {"name": "test", "value": 42}) + + def test_serialize_nested_pydantic_model(self): + model = NestedModel(title="parent", item=SimpleModel(name="child", value=1)) + result = serialize_body(model) + self.assertEqual(result, {"title": "parent", "item": {"name": "child", "value": 1}}) + + def test_serialize_pydantic_model_in_dict(self): + data = {"model": SimpleModel(name="test", value=42)} + result = serialize_body(data) + self.assertEqual(result, {"model": {"name": "test", "value": 42}}) + + def test_serialize_pydantic_model_in_list(self): + data = [SimpleModel(name="a", value=1), SimpleModel(name="b", value=2)] + result = serialize_body(data) + self.assertEqual(result, [{"name": "a", "value": 1}, {"name": "b", "value": 2}]) + + def test_serialize_pydantic_with_optional_field(self): + model = OptionalFieldModel(name="test") + result = serialize_body(model) + self.assertEqual(result, {"name": "test", "description": None}) + + +class TestPydanticDeserialization(unittest.TestCase): + def test_deserialize_to_pydantic_model(self): + resp = Response(200, json={"data": {"name": "test", "value": 42}}) + result = deserialize(resp, cls=SimpleModel) + self.assertIsInstance(result, SimpleModel) + self.assertEqual(result.name, "test") + self.assertEqual(result.value, 42) + + def test_deserialize_list_to_pydantic_models(self): + resp = Response(200, json={"data": [{"name": "a", "value": 1}, {"name": "b", "value": 2}]}) + result = deserialize(resp, cls=list[SimpleModel]) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertIsInstance(result[0], SimpleModel) + self.assertEqual(result[0].name, "a") + self.assertEqual(result[1].name, "b") + + def test_deserialize_nested_pydantic_model(self): + resp = Response(200, json={"data": {"title": "parent", "item": {"name": "child", "value": 1}}}) + result = deserialize(resp, cls=NestedModel) + self.assertIsInstance(result, NestedModel) + self.assertEqual(result.title, "parent") + self.assertIsInstance(result.item, SimpleModel) + self.assertEqual(result.item.name, "child") + + def test_deserialize_pydantic_with_optional_field_missing(self): + resp = Response(200, json={"data": {"name": "test"}}) + result = deserialize(resp, cls=OptionalFieldModel) + self.assertIsInstance(result, OptionalFieldModel) + self.assertEqual(result.name, "test") + self.assertIsNone(result.description) + + def test_deserialize_pydantic_with_optional_field_present(self): + resp = Response(200, json={"data": {"name": "test", "description": "desc"}}) + result = deserialize(resp, cls=OptionalFieldModel) + self.assertEqual(result.description, "desc") + + def test_deserialize_empty_list_to_pydantic(self): + resp = Response(200, json={"data": []}) + result = deserialize(resp, cls=list[SimpleModel]) + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sunset.py b/tests/test_sunset.py index e771a6b..089b5c5 100644 --- a/tests/test_sunset.py +++ b/tests/test_sunset.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta +from typing import Optional from unittest import TestCase import httpx @@ -23,7 +24,7 @@ def date_to_str(date: datetime): url = "https://example.com/endpoint?key=1" -def make_requests_response(sunset_date: str | None) -> requests.Response: +def make_requests_response(sunset_date: Optional[str]) -> requests.Response: response = requests.Response() if sunset_date: response.headers[SUNSET_HEADER] = sunset_date @@ -31,12 +32,12 @@ def make_requests_response(sunset_date: str | None) -> requests.Response: return response -def make_httpx_response(sunset_date: str | None) -> httpx.Response: +def make_httpx_response(sunset_date: Optional[str]) -> httpx.Response: headers = {SUNSET_HEADER: sunset_date} if sunset_date else None return httpx.Response(status_code=200, headers=headers, request=httpx.Request("GET", url)) -def run_test_with_response(caplog, response, expected_log_level: str | None): +def run_test_with_response(caplog, response, expected_log_level: Optional[str]): handle_sunset(response) if expected_log_level: log_record = caplog.records[0] diff --git a/tests/test_type_helpers.py b/tests/test_type_helpers.py new file mode 100644 index 0000000..4634b1c --- /dev/null +++ b/tests/test_type_helpers.py @@ -0,0 +1,49 @@ +"""Unit tests for type checking helper functions.""" + +import unittest +from collections import UserDict, UserList +from typing import Dict, List + +from kognic.auth.serde import _is_dict_type, _is_list_type + + +class TestTypeHelpers(unittest.TestCase): + def test_is_list_type_with_list(self): + self.assertTrue(_is_list_type(list)) + + def test_is_list_type_with_List(self): + self.assertTrue(_is_list_type(List)) + + def test_is_list_type_with_List_generic(self): + self.assertTrue(_is_list_type(List[str])) + + def test_is_list_type_with_list_generic(self): + self.assertTrue(_is_list_type(list[str])) + + def test_is_list_type_with_UserList(self): + self.assertTrue(_is_list_type(UserList)) + + def test_is_list_type_with_dict(self): + self.assertFalse(_is_list_type(dict)) + + def test_is_dict_type_with_dict(self): + self.assertTrue(_is_dict_type(dict)) + + def test_is_dict_type_with_Dict(self): + self.assertTrue(_is_dict_type(Dict)) + + def test_is_dict_type_with_Dict_generic(self): + self.assertTrue(_is_dict_type(Dict[str, str])) + + def test_is_dict_type_with_dict_generic(self): + self.assertTrue(_is_dict_type(dict[str, str])) + + def test_is_dict_type_with_UserDict(self): + self.assertTrue(_is_dict_type(UserDict)) + + def test_is_dict_type_with_list(self): + self.assertFalse(_is_dict_type(list)) + + +if __name__ == "__main__": + unittest.main() From 35021e33519508c8ab4e30895ff6bfaec4059576 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 14:18:31 +0100 Subject: [PATCH 04/21] Add serialization examples to README Document serialize_body() and deserialize() with examples for: - Pydantic models - Custom classes with to_dict()/from_dict() - Nested objects in containers - Envelope extraction Co-Authored-By: Claude Opus 4.5 --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index d014cf0..cb759b0 100644 --- a/README.md +++ b/README.md @@ -211,5 +211,64 @@ async with MyAsyncApiClient() as client: resource = await client.get_resource("123") ``` +## Serialization & Deserialization + +The `kognic.auth.serde` module provides utilities for serializing request bodies and deserializing responses. + +### Serialization + +`serialize_body()` converts objects to JSON-compatible dicts. Supports: +- Pydantic v2 models (`model_dump()`) +- Objects with `to_json()` or `to_dict()` methods +- Nested objects in dicts/lists are recursively serialized + +```python +from pydantic import BaseModel +from kognic.auth.serde import serialize_body + +class CreateRequest(BaseModel): + name: str + value: int + +# Pydantic models +request = CreateRequest(name="test", value=42) +serialize_body(request) # {"name": "test", "value": 42} + +# Nested in containers +serialize_body({"items": [request]}) # {"items": [{"name": "test", "value": 42}]} + +# Custom classes with to_dict() +class MyModel: + def to_dict(self): + return {"key": "value"} + +serialize_body(MyModel()) # {"key": "value"} +``` + +### Deserialization + +`deserialize()` extracts and converts API responses. Supports: +- Pydantic v2 models (`model_validate()`) +- Classes with `from_dict()` or `from_json()` methods +- Automatic envelope extraction (default key: `"data"`) + +```python +from kognic.auth.serde import deserialize + +# Deserialize to Pydantic model +response = client.session.get("https://api.app.kognic.com/v1/resource/123") +resource = deserialize(response, cls=ResourceModel) + +# Deserialize list of models +response = client.session.get("https://api.app.kognic.com/v1/resources") +resources = deserialize(response, cls=list[ResourceModel]) + +# Custom envelope key +data = deserialize(response, cls=MyModel, enveloped_key="result") + +# No envelope +data = deserialize(response, cls=MyModel, enveloped_key=None) +``` + ## Changelog See Github releases from v3.1.0, historic changelog is available in CHANGELOG.md From 2094bb153f15a178a14fc5e305c0235f9679f9b9 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 14:23:25 +0100 Subject: [PATCH 05/21] Use uv for CI instead of pip - Use astral-sh/setup-uv@v5 action with caching - Use uv python install for Python version - Use uv sync --all-extras for dependencies - Use uv run pytest for testing Co-Authored-By: Claude Opus 4.5 --- .github/workflows/python-package.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index cb1315a..2050852 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -32,16 +32,13 @@ jobs: with: # needed for setuptools_scm to work fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - cache-dependency-path: 'pyproject.toml' + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} - name: Install dependencies - run: | - python -m pip install --upgrade pip pytest - python -m pip install ".[full]" + run: uv sync --all-extras - name: Test with pytest - run: | - pytest tests/* + run: uv run pytest tests/ From e9686e59cc37c6c3192a8913e08dcc13b5fbdec4 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Tue, 10 Feb 2026 14:29:54 +0100 Subject: [PATCH 06/21] fix publish flow to publish release candidate --- .github/workflows/python-publish.yml | 8 +- uv.lock | 169 +++++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index d603100..765e539 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -8,10 +8,10 @@ name: Upload Python Package -on: +on: push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' permissions: contents: read @@ -27,7 +27,7 @@ jobs: - uses: actions/checkout@v6 with: # needed for setuptools_scm to work - fetch-depth: 0 + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/uv.lock b/uv.lock index 7e7f828..cb43126 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.kognic.io/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.12.1" @@ -414,6 +423,7 @@ requests = [ [package.dev-dependencies] dev = [ { name = "httpx" }, + { name = "pydantic" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.kognic.io/simple" }, marker = "python_full_version >= '3.10'" }, { name = "requests" }, @@ -432,6 +442,7 @@ provides-extras = ["httpx", "requests", "full"] [package.metadata.requires-dev] dev = [ { name = "httpx", specifier = ">=0.20,<1" }, + { name = "pydantic", specifier = ">=2" }, { name = "pytest" }, { name = "requests", specifier = ">=2.20,<3" }, ] @@ -478,6 +489,152 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999, upload-time = "2025-11-04T13:42:03.885Z" }, + { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745, upload-time = "2025-11-04T13:42:06.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220, upload-time = "2025-11-04T13:42:08.457Z" }, + { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296, upload-time = "2025-11-04T13:42:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548, upload-time = "2025-11-04T13:42:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571, upload-time = "2025-11-04T13:42:16.208Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175, upload-time = "2025-11-04T13:42:18.911Z" }, + { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203, upload-time = "2025-11-04T13:42:22.578Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191, upload-time = "2025-11-04T13:42:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907, upload-time = "2025-11-04T13:42:27.683Z" }, + { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174, upload-time = "2025-11-04T13:42:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353, upload-time = "2025-11-04T13:42:33.111Z" }, + { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698, upload-time = "2025-11-04T13:42:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -607,6 +764,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.kognic.io/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" From 97c12d58270d3f7dc6f239a549b091114aae13df Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Wed, 11 Feb 2026 07:37:29 +0100 Subject: [PATCH 07/21] Add overridable json_serializer parameter to base clients Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/httpx/base_client.py | 6 ++++-- src/kognic/auth/requests/base_client.py | 9 +++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index d2de656..8e885db 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Optional, Union +from typing import Any, Callable, Optional, Union import httpx @@ -60,6 +60,7 @@ def __init__( auth_host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", + json_serializer: Callable[[Any], Any] = serialize_body, ): """Initialize the async API client. @@ -70,6 +71,7 @@ def __init__( auth_host: Authentication server base URL token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. + json_serializer: Callable to serialize request bodies. Defaults to serialize_body. """ if client_name == "auto": client_name = self.__class__.__name__ @@ -96,7 +98,7 @@ async def request(method, url, **kwargs): # Accept anything jsonable as json, serialize it json = kwargs.pop("json", None) if json is not None: - kwargs["json"] = serialize_body(json) + kwargs["json"] = json_serializer(json) # Wrap the request in simple retry logic for transient errors async def call_with_simple_retry(attempts): diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 95e06e5..92619eb 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -3,7 +3,7 @@ import logging from functools import lru_cache from threading import Lock -from typing import Optional, Tuple, Union +from typing import Any, Callable, Optional, Tuple, Union import requests from requests import Session @@ -87,6 +87,7 @@ def create_session( auth_host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = None, + json_serializer: Callable[[Any], Any] = serialize_body, ) -> Session: """Create a requests session with enhancements. @@ -127,7 +128,7 @@ def prepare_request(req, *args, **kwargs): # Accept anything jsonable as json, serialize it if req.json is not None: - req.json = serialize_body(req.json) + req.json = json_serializer(req.json) return vanilla_prep(req, *args, **kwargs) @@ -173,6 +174,7 @@ def __init__( auth_host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", + json_serializer: Callable[[Any], Any] = serialize_body, ): """Initialize the API client. @@ -183,6 +185,7 @@ def __init__( auth_host: Authentication server base URL token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. + json_serializer: Callable to serialize request bodies. Defaults to serialize_body. """ self._session: Optional[Session] = None self._auth = auth @@ -190,6 +193,7 @@ def __init__( self._client_secret = client_secret self._auth_host = auth_host self._token_endpoint = token_endpoint + self._json_serializer = json_serializer self._lock = Lock() if client_name == "auto": @@ -212,5 +216,6 @@ def session(self) -> Session: auth_host=self._auth_host, token_endpoint=self._token_endpoint, client_name=self._client_name, + json_serializer=self._json_serializer, ) return self._session From 6df5be732dcfd64851876072bf9f70c55ab328e8 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Wed, 11 Feb 2026 09:05:33 +0100 Subject: [PATCH 08/21] interface polishing --- src/kognic/auth/httpx/base_client.py | 12 +++------ src/kognic/auth/requests/base_client.py | 34 ++++++++----------------- tests/test_base_client_sync.py | 4 +-- 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index 8e885db..4f5804d 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -55,10 +55,8 @@ def __init__( self, *, auth: Optional[Union[str, tuple]] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, auth_host: str = DEFAULT_HOST, - token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, ): @@ -66,10 +64,8 @@ def __init__( Args: auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple - client_id: OAuth2 client ID (alternative to auth) - client_secret: OAuth2 client secret (alternative to auth) auth_host: Authentication server base URL - token_endpoint: Relative path to token endpoint + auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. json_serializer: Callable to serialize request bodies. Defaults to serialize_body. """ @@ -83,10 +79,8 @@ def __init__( super().__init__( auth=auth, - client_id=client_id, - client_secret=client_secret, host=auth_host, - token_endpoint=token_endpoint, + token_endpoint=auth_token_endpoint, transport=transport, headers=headers, ) diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 92619eb..f32a915 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -23,7 +23,7 @@ def _create_cached_oauth_session( auth_tuple: Optional[Tuple[str, str]], auth_host: str, - token_endpoint: str, + auth_token_endpoint: str, ) -> Session: """Create and cache an OAuth session by credentials. @@ -35,12 +35,12 @@ def _create_cached_oauth_session( client_id=client_id, client_secret=client_secret, host=auth_host, - token_endpoint=token_endpoint, + token_endpoint=auth_token_endpoint, ).session else: return RequestsAuthSession( host=auth_host, - token_endpoint=token_endpoint, + token_endpoint=auth_token_endpoint, ).session @@ -82,10 +82,8 @@ def _resolve_auth_tuple( def create_session( *, auth: Optional[Union[str, tuple]] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, auth_host: str = DEFAULT_HOST, - token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = None, json_serializer: Callable[[Any], Any] = serialize_body, ) -> Session: @@ -100,18 +98,16 @@ def create_session( Args: auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple - client_id: OAuth2 client ID (alternative to auth) - client_secret: OAuth2 client secret (alternative to auth) auth_host: Authentication server base URL - token_endpoint: Relative path to token endpoint + auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent header Returns: Configured requests Session """ # Resolve credentials and get cached OAuth session - auth_tuple = _resolve_auth_tuple(auth, client_id, client_secret) - session = _create_cached_oauth_session(auth_tuple, auth_host, token_endpoint) + auth_tuple = _resolve_auth_tuple(auth, client_id=None, client_secret=None) + session = _create_cached_oauth_session(auth_tuple, auth_host, auth_token_endpoint) session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) @@ -169,10 +165,8 @@ def __init__( self, *, auth: Optional[Union[str, tuple]] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, auth_host: str = DEFAULT_HOST, - token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, + auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, ): @@ -180,19 +174,15 @@ def __init__( Args: auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple - client_id: OAuth2 client ID (alternative to auth) - client_secret: OAuth2 client secret (alternative to auth) auth_host: Authentication server base URL - token_endpoint: Relative path to token endpoint + auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. json_serializer: Callable to serialize request bodies. Defaults to serialize_body. """ self._session: Optional[Session] = None self._auth = auth - self._client_id = client_id - self._client_secret = client_secret self._auth_host = auth_host - self._token_endpoint = token_endpoint + self._auth_token_endpoint = auth_token_endpoint self._json_serializer = json_serializer self._lock = Lock() @@ -211,10 +201,8 @@ def session(self) -> Session: if self._session is None: self._session = create_session( auth=self._auth, - client_id=self._client_id, - client_secret=self._client_secret, auth_host=self._auth_host, - token_endpoint=self._token_endpoint, + auth_token_endpoint=self._auth_token_endpoint, client_name=self._client_name, json_serializer=self._json_serializer, ) diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py index 92b54c3..7300f21 100644 --- a/tests/test_base_client_sync.py +++ b/tests/test_base_client_sync.py @@ -62,7 +62,7 @@ def test_session_lazy_init(self, mock_session_class): mock_instance.session = MagicMock() mock_session_class.return_value = mock_instance - client = BaseApiClient(client_id="test", client_secret="secret") + client = BaseApiClient(auth=("test", "secret")) # Session should not be created yet self.assertIsNone(client._session) @@ -84,7 +84,7 @@ def test_client_name_auto(self, mock_session_class): class MyCustomClient(BaseApiClient): pass - client = MyCustomClient(client_id="test", client_secret="secret") + client = MyCustomClient(auth=("test", "secret")) self.assertEqual(client._client_name, "MyCustomClient") From 0fb659ad78efcd302782ee4759d58c0d366fa985 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Wed, 11 Feb 2026 09:29:18 +0100 Subject: [PATCH 09/21] Fix sunset UTC handling, add credentials tests, polish interfaces - Fix sunset date parsing to use UTC-aware datetimes - Support sunset dates without microseconds - Add comprehensive credentials_parser tests - Forward **kwargs to httpx client in BaseAsyncApiClient - Document json_serializer in create_session docstring - Fix ruff target-version to match requires-python (py39) - Remove legacy ANNOTELL env var fallbacks Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- src/kognic/auth/_sunset.py | 19 ++- src/kognic/auth/credentials_parser.py | 12 -- src/kognic/auth/httpx/base_client.py | 9 +- src/kognic/auth/requests/base_client.py | 1 + tests/test_credentials_parser.py | 188 ++++++++++++++++++++++++ tests/test_sunset.py | 19 ++- 7 files changed, 221 insertions(+), 29 deletions(-) create mode 100644 tests/test_credentials_parser.py diff --git a/pyproject.toml b/pyproject.toml index dd5e0d9..dbf9562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ dev = [ [tool.ruff] line-length = 120 -target-version = "py38" +target-version = "py39" [tool.ruff.lint] select = ["E", "F", "B", "W", "I001", "PTH"] diff --git a/src/kognic/auth/_sunset.py b/src/kognic/auth/_sunset.py index 8b29a30..75113c5 100644 --- a/src/kognic/auth/_sunset.py +++ b/src/kognic/auth/_sunset.py @@ -1,7 +1,7 @@ """Sunset header handling for deprecated API endpoints.""" import logging -from datetime import datetime +from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional, Union if TYPE_CHECKING: @@ -10,8 +10,9 @@ SUNSET_HEADER = "sunset-date" SUNSET_DIFF_THRESHOLD = 14 * 60 * 60 * 24 # two weeks -# Expected format of sunset date, e.g. 2024-02-22T16:21:20.880547Z +# Expected formats of sunset date DATETIME_FMT = "%Y-%m-%dT%H:%M:%S.%fZ" +DATETIME_FMT_NO_MICRO = "%Y-%m-%dT%H:%M:%SZ" logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def handle_sunset(response: "Response") -> None: if not sunset_date: return None - now = datetime.now() + now = datetime.now(tz=timezone.utc) diff = sunset_date - now log_method = logger.warning if diff.total_seconds() > SUNSET_DIFF_THRESHOLD else logger.error @@ -38,11 +39,13 @@ def handle_sunset(response: "Response") -> None: def _parse_date(date: str) -> Optional[datetime]: - """Parse sunset date string to datetime.""" - try: - return datetime.strptime(date, DATETIME_FMT) - except ValueError: - return None + """Parse sunset date string to UTC datetime.""" + for fmt in (DATETIME_FMT, DATETIME_FMT_NO_MICRO): + try: + return datetime.strptime(date, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + return None def _parse_url(url: Union[str, "Url"]) -> str: diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index cb92a65..8866aa0 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -67,18 +67,6 @@ def get_credentials_from_env(): client_id = os.getenv("KOGNIC_CLIENT_ID") client_secret = os.getenv("KOGNIC_CLIENT_SECRET") - if client_id and client_secret: - return client_id, client_secret - - # fallbacks - creds = os.getenv("ANNOTELL_CREDENTIALS") - if creds: - client_credentials = parse_credentials(creds) - return client_credentials.client_id, client_credentials.client_secret - - client_id = os.getenv("ANNOTELL_CLIENT_ID") - client_secret = os.getenv("ANNOTELL_CLIENT_SECRET") - return client_id, client_secret diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index 4f5804d..c95b1e4 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -59,6 +59,7 @@ def __init__( auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", json_serializer: Callable[[Any], Any] = serialize_body, + **kwargs, ): """Initialize the async API client. @@ -68,21 +69,23 @@ def __init__( auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent. Use "auto" for class name, None for no name. json_serializer: Callable to serialize request bodies. Defaults to serialize_body. + **kwargs: Additional arguments passed to the underlying httpx client (e.g. timeout, verify). """ if client_name == "auto": client_name = self.__class__.__name__ # Use a custom transport to set the number of retries for connection errors - transport = httpx.AsyncHTTPTransport(retries=3) + kwargs.setdefault("transport", httpx.AsyncHTTPTransport(retries=3)) - headers = {"User-Agent": get_user_agent(f"python-httpx/{httpx.__version__}", client_name)} + headers = kwargs.pop("headers", {}) + headers.setdefault("User-Agent", get_user_agent(f"python-httpx/{httpx.__version__}", client_name)) super().__init__( auth=auth, host=auth_host, token_endpoint=auth_token_endpoint, - transport=transport, headers=headers, + **kwargs, ) # Monkey patch the request method to handle sunset, errors and custom error handling diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index f32a915..f94fd41 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -101,6 +101,7 @@ def create_session( auth_host: Authentication server base URL auth_token_endpoint: Relative path to token endpoint client_name: Name added to User-Agent header + json_serializer: Callable to serialize request bodies. Defaults to serialize_body. Returns: Configured requests Session diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py new file mode 100644 index 0000000..63bfdad --- /dev/null +++ b/tests/test_credentials_parser.py @@ -0,0 +1,188 @@ +"""Unit tests for credentials_parser module.""" + +import json +import os +import unittest +from unittest.mock import patch + +from kognic.auth.credentials_parser import ( + ApiCredentials, + get_credentials, + get_credentials_from_env, + parse_credentials, + resolve_credentials, +) + +VALID_CREDENTIALS_DICT = { + "clientId": "test_id", + "clientSecret": "test_secret", + "email": "test@kognic.com", + "userId": 1, + "issuer": "auth.kognic.test", +} + + +class TestParseCredentials(unittest.TestCase): + def test_parse_from_dict(self): + creds = parse_credentials(VALID_CREDENTIALS_DICT) + self.assertEqual(creds.client_id, "test_id") + self.assertEqual(creds.client_secret, "test_secret") + self.assertEqual(creds.email, "test@kognic.com") + self.assertEqual(creds.user_id, 1) + self.assertEqual(creds.issuer, "auth.kognic.test") + + def test_parse_from_file(self, tmp_path=None): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(VALID_CREDENTIALS_DICT, f) + f.flush() + path = f.name + + try: + creds = parse_credentials(path) + self.assertEqual(creds.client_id, "test_id") + self.assertEqual(creds.client_secret, "test_secret") + finally: + os.unlink(path) + + def test_parse_file_not_found(self): + with self.assertRaises(FileNotFoundError): + parse_credentials("/nonexistent/path/creds.json") + + def test_parse_missing_key_raises(self): + incomplete = {"clientId": "test_id", "clientSecret": "test_secret"} + with self.assertRaises(KeyError) as ctx: + parse_credentials(incomplete) + self.assertIn("email", str(ctx.exception)) + + +class TestGetCredentials(unittest.TestCase): + def test_json_file_path(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(VALID_CREDENTIALS_DICT, f) + path = f.name + + try: + creds = get_credentials(path) + self.assertEqual(creds.client_id, "test_id") + finally: + os.unlink(path) + + def test_non_json_file_raises(self): + with self.assertRaises(ValueError) as ctx: + get_credentials("/some/path/creds.yaml") + self.assertIn("must be json", str(ctx.exception)) + + def test_api_credentials_passthrough(self): + creds = ApiCredentials( + client_id="id", + client_secret="secret", + email="a@b.com", + user_id=1, + issuer="issuer", + ) + result = get_credentials(creds) + self.assertIs(result, creds) + + def test_unsupported_type_raises(self): + with self.assertRaises(ValueError): + get_credentials(12345) + + +class TestGetCredentialsFromEnv(unittest.TestCase): + @patch.dict(os.environ, {}, clear=True) + def test_no_env_vars_returns_none(self): + client_id, client_secret = get_credentials_from_env() + self.assertIsNone(client_id) + self.assertIsNone(client_secret) + + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) + def test_client_id_and_secret_env_vars(self): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "env_id") + self.assertEqual(client_secret, "env_secret") + + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id"}, clear=True) + def test_only_client_id_returns_none_secret(self): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "env_id") + self.assertIsNone(client_secret) + + def test_kognic_credentials_file(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(VALID_CREDENTIALS_DICT, f) + path = f.name + + try: + with patch.dict(os.environ, {"KOGNIC_CREDENTIALS": path}, clear=True): + client_id, client_secret = get_credentials_from_env() + self.assertEqual(client_id, "test_id") + self.assertEqual(client_secret, "test_secret") + finally: + os.unlink(path) + + @patch.dict( + os.environ, + {"KOGNIC_CREDENTIALS": "/nonexistent.json", "KOGNIC_CLIENT_ID": "fallback_id"}, + clear=True, + ) + def test_kognic_credentials_takes_precedence_over_client_id(self): + with self.assertRaises(FileNotFoundError): + get_credentials_from_env() + + +class TestResolveCredentials(unittest.TestCase): + def test_auth_tuple(self): + client_id, client_secret = resolve_credentials(auth=("tuple_id", "tuple_secret")) + self.assertEqual(client_id, "tuple_id") + self.assertEqual(client_secret, "tuple_secret") + + def test_auth_tuple_wrong_length_raises(self): + with self.assertRaises(ValueError) as ctx: + resolve_credentials(auth=("only_one",)) + self.assertIn("tuple", str(ctx.exception)) + + def test_explicit_client_id_and_secret(self): + client_id, client_secret = resolve_credentials(client_id="explicit_id", client_secret="explicit_secret") + self.assertEqual(client_id, "explicit_id") + self.assertEqual(client_secret, "explicit_secret") + + def test_auth_and_client_id_raises(self): + with self.assertRaises(ValueError) as ctx: + resolve_credentials(auth=("id", "secret"), client_id="other_id", client_secret="other_secret") + self.assertIn("Choose either", str(ctx.exception)) + + def test_auth_file_path(self): + import tempfile + + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(VALID_CREDENTIALS_DICT, f) + path = f.name + + try: + client_id, client_secret = resolve_credentials(auth=path) + self.assertEqual(client_id, "test_id") + self.assertEqual(client_secret, "test_secret") + finally: + os.unlink(path) + + @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) + def test_falls_back_to_env(self): + client_id, client_secret = resolve_credentials() + self.assertEqual(client_id, "env_id") + self.assertEqual(client_secret, "env_secret") + + @patch.dict(os.environ, {}, clear=True) + def test_no_credentials_returns_none(self): + client_id, client_secret = resolve_credentials() + self.assertIsNone(client_id) + self.assertIsNone(client_secret) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sunset.py b/tests/test_sunset.py index 089b5c5..94a9844 100644 --- a/tests/test_sunset.py +++ b/tests/test_sunset.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Optional from unittest import TestCase @@ -16,10 +16,11 @@ def date_to_str(date: datetime): SUNSET_DATE_LONG_TIME_AGO = "2024-02-22T16:21:20.880547Z" # => error -SUNSET_DATE_5_DAYS_AGO = date_to_str(datetime.now()) # now => error -SUNSET_DATE_IN_13_DAYS = date_to_str(datetime.now() + timedelta(days=13)) # in 13 days => error -SUNSET_DATE_IN_15_DAYS = date_to_str(datetime.now() + timedelta(days=15)) # in 15 days => warning -SUNSET_DATE_WRONG_FORMAT = "2024-02-22T16:21:20Z" # => error +SUNSET_DATE_5_DAYS_AGO = date_to_str(datetime.now(tz=timezone.utc)) # now => error +SUNSET_DATE_IN_13_DAYS = date_to_str(datetime.now(tz=timezone.utc) + timedelta(days=13)) # in 13 days => error +SUNSET_DATE_IN_15_DAYS = date_to_str(datetime.now(tz=timezone.utc) + timedelta(days=15)) # in 15 days => warning +SUNSET_DATE_NO_MICROSECONDS = "2024-02-22T16:21:20Z" # no microseconds => error (long time ago) +SUNSET_DATE_WRONG_FORMAT = "22/02/2024 16:21:20" # unsupported format => ignored url = "https://example.com/endpoint?key=1" @@ -59,6 +60,10 @@ def test_when_sunset_date_invalid(self): response = make_requests_response(SUNSET_DATE_WRONG_FORMAT) run_test_with_response(self._caplog, response, None) + def test_when_sunset_date_no_microseconds(self): + response = make_requests_response(SUNSET_DATE_NO_MICROSECONDS) + run_test_with_response(self._caplog, response, "ERROR") + def test_when_sunset_date_long_time_ago(self): response = make_requests_response(SUNSET_DATE_LONG_TIME_AGO) run_test_with_response(self._caplog, response, "ERROR") @@ -89,6 +94,10 @@ def test_when_sunset_date_invalid(self): response = make_httpx_response(SUNSET_DATE_WRONG_FORMAT) run_test_with_response(self._caplog, response, None) + def test_when_sunset_date_no_microseconds(self): + response = make_httpx_response(SUNSET_DATE_NO_MICROSECONDS) + run_test_with_response(self._caplog, response, "ERROR") + def test_when_sunset_date_long_time_ago(self): response = make_httpx_response(SUNSET_DATE_LONG_TIME_AGO) run_test_with_response(self._caplog, response, "ERROR") From 5af9793d58ca77005e5cb4253f51ce11061dbd07 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:32:11 +0100 Subject: [PATCH 10/21] Fix sync/async asymmetries: path validation, docstrings, unused imports, param order - Remove unused `PathLike` import from httpx/base_client.py - Align `_update_token` parameter order (access_token, refresh_token) in async client - Add path validation (reject leading `/`) to async base client matching sync - Normalize docstrings from `:param` to `Args:` style across auth sessions - Remove redundant explicit `Accept-Encoding: gzip` header from sync client - Run ruff fix + format Co-Authored-By: Claude Opus 4.6 --- README.md | 38 ++-- src/kognic/auth/cli/call.py | 14 +- src/kognic/auth/cli/get_access_token.py | 18 +- src/kognic/auth/config.py | 92 ---------- src/kognic/auth/env_config.py | 92 ++++++++++ src/kognic/auth/httpx/async_client.py | 22 +-- src/kognic/auth/httpx/base_client.py | 31 ++++ src/kognic/auth/requests/auth_session.py | 17 +- src/kognic/auth/requests/base_client.py | 49 +++-- tests/test_base_client_async.py | 48 +++++ tests/test_base_client_sync.py | 104 +++++++++++ tests/test_cli.py | 222 +++++++++++------------ tests/test_config.py | 96 +++++----- 13 files changed, 520 insertions(+), 323 deletions(-) delete mode 100644 src/kognic/auth/config.py create mode 100644 src/kognic/auth/env_config.py diff --git a/README.md b/README.md index cb759b0..731b24c 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ sess.get("https://api.app.kognic.com") The package provides a command-line interface for generating access tokens and making authenticated API calls. This is great for LLM use cases, the `kognic-auth call` is a lightweight curl, that hides any complexity of authentication and context management, so you can just focus on the API call you want to make. This also avoids tokens being leaked to the shell history, -as you can use named contexts and config files to manage your credentials. +as you can use named environments and config files to manage your credentials. The interface is currently marked experimental, and breaking changes may be made without a major version bump. Feedback is welcome to help stabilize the design. ### Configuration file -The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named contexts for different environments, each with its own host, auth server, and credentials. +The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named environments, each with its own host, auth server, and credentials. ```json { @@ -60,28 +60,28 @@ The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. Th } ``` -Each context has the following fields: -- `host` - The API hostname, used by `call` to automatically match a context based on the request URL. +Each environment has the following fields: +- `host` - The API hostname, used by `call` to automatically match an environment based on the request URL. - `auth_server` - The OAuth server URL used to fetch tokens. - `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables. -`default_context` specifies which context to use as a fallback when no `--context` flag is given and no URL match is found. +`default_context` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found. ### get-access-token Generate an access token for Kognic API authentication. ```bash -kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--context NAME] [--config FILE] +kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--config FILE] ``` **Options:** - `--server` - Authentication server URL (default: `https://auth.app.kognic.com`) - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. -- `--context` - Use a named context from the config file. +- `--env` - Use a named environment from the config file. - `--config` - Config file path (default: `~/.config/kognic/config.json`) -When `--context` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the context values. +When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values. **Examples:** ```bash @@ -91,11 +91,11 @@ kognic-auth get-access-token # Using a credentials file kognic-auth get-access-token --credentials ~/.config/kognic/credentials.json -# Using a named context -kognic-auth get-access-token --context demo +# Using a named environment +kognic-auth get-access-token --env demo -# Using a context but overriding the server -kognic-auth get-access-token --context demo --server https://custom.server +# Using an environment but overriding the server +kognic-auth get-access-token --env demo --server https://custom.server ``` ### call @@ -103,7 +103,7 @@ kognic-auth get-access-token --context demo --server https://custom.server Make an authenticated HTTP request to a Kognic API. ```bash -kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--context NAME] [--config FILE] +kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env NAME] [--config FILE] ``` **Options:** @@ -112,18 +112,18 @@ kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--cont - `-d`, `--data` - Request body (JSON string) - `-H`, `--header` - Header in `Key: Value` format (repeatable) - `--format` - Output format (default: `json`). See [Output formats](#output-formats) below. -- `--context` - Force a specific context (skip URL-based matching) +- `--env` - Force a specific environment (skip URL-based matching) - `--config` - Config file path (default: `~/.config/kognic/config.json`) -When `--context` is not provided, the context is automatically resolved by matching the request URL's hostname against the `host` field of each context in the config file. +When `--env` is not provided, the environment is automatically resolved by matching the request URL's hostname against the `host` field of each environment in the config file. **Examples:** ```bash -# GET request (default method), context auto-resolved from URL hostname +# GET request (default method), environment auto-resolved from URL hostname kognic-auth call https://app.kognic.com/v1/projects -# Explicit context -kognic-auth call https://demo.kognic.com/v1/projects --context demo +# Explicit environment +kognic-auth call https://demo.kognic.com/v1/projects --env demo # POST with JSON body kognic-auth call https://app.kognic.com/v1/projects -X POST -d '{"name": "test"}' @@ -189,7 +189,7 @@ class MyApiClient(BaseApiClient): client = MyApiClient() # Or with explicit credentials -client = MyApiClient(client_id="...", client_secret="...") +client = MyApiClient(auth=("my-client-id", "my-client-secret")) # Or with credentials file client = MyApiClient(auth="~/.config/kognic/credentials.json") diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/call.py index a3fc944..f78205b 100644 --- a/src/kognic/auth/cli/call.py +++ b/src/kognic/auth/cli/call.py @@ -7,7 +7,7 @@ import sys from typing import Any -from kognic.auth.config import DEFAULT_CONFIG_PATH, load_config, resolve_context +from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config, resolve_environment from kognic.auth.requests.auth_session import RequestsAuthSession COMMAND = "call" @@ -41,9 +41,7 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: default=DEFAULT_CONFIG_PATH, help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", ) - call_parser.add_argument( - "--context", dest="context_name", help="Force a specific context (skip URL-based matching)" - ) + call_parser.add_argument("--env", dest="env_name", help="Force a specific environment (skip URL-based matching)") call_parser.add_argument( "--format", dest="output_format", @@ -164,12 +162,12 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: def run(parsed: argparse.Namespace) -> int: try: - config = load_config(parsed.config) - context = resolve_context(config, parsed.url, parsed.context_name) + config = load_kognic_env_config(parsed.config) + env = resolve_environment(config, parsed.url, parsed.env_name) session = RequestsAuthSession( - auth=context.credentials, - host=context.auth_server, + auth=env.credentials, + host=env.auth_server, ) headers = _parse_headers(parsed.headers) or {} diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 355166a..eb4bfa0 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -4,7 +4,7 @@ import sys from kognic.auth import DEFAULT_HOST -from kognic.auth.config import DEFAULT_CONFIG_PATH, load_config +from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession COMMAND = "get-access-token" @@ -31,9 +31,9 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", ) token_parser.add_argument( - "--context", - dest="context_name", - help="Use a specific context from the config file", + "--env", + dest="env_name", + help="Use a specific environment from the config file", ) @@ -42,12 +42,12 @@ def run(parsed: argparse.Namespace) -> int: host = parsed.server credentials = parsed.credentials - if parsed.context_name: - config = load_config(parsed.config) - if parsed.context_name not in config.contexts: - print(f"Error: Unknown context: {parsed.context_name}", file=sys.stderr) + if parsed.env_name: + config = load_kognic_env_config(parsed.config) + if parsed.env_name not in config.environments: + print(f"Error: Unknown environment: {parsed.env_name}", file=sys.stderr) return 1 - ctx = config.contexts[parsed.context_name] + ctx = config.environments[parsed.env_name] if host is None: host = ctx.auth_server if credentials is None: diff --git a/src/kognic/auth/config.py b/src/kognic/auth/config.py deleted file mode 100644 index 2c80da1..0000000 --- a/src/kognic/auth/config.py +++ /dev/null @@ -1,92 +0,0 @@ -import json -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional -from urllib.parse import urlparse - -from kognic.auth import DEFAULT_HOST - -DEFAULT_CONFIG_PATH = str(Path("~") / ".config" / "kognic" / "config.json") - - -@dataclass -class Context: - name: str - host: str - auth_server: str - credentials: Optional[str] = None - - -@dataclass -class Config: - contexts: dict = field(default_factory=dict) - default_context: Optional[str] = None - - -def load_config(path: str = DEFAULT_CONFIG_PATH) -> Config: - """Load config from JSON file. Returns empty Config if file doesn't exist.""" - expanded = Path(path).expanduser() - if not expanded.exists(): - return Config() - - data = json.loads(expanded.read_text()) - - contexts = {} - for name, ctx_data in data.get("contexts", {}).items(): - credentials = ctx_data.get("credentials") - if credentials: - credentials = str(Path(credentials).expanduser()) - contexts[name] = Context( - name=name, - host=ctx_data["host"], - auth_server=ctx_data["auth_server"], - credentials=credentials, - ) - - return Config( - contexts=contexts, - default_context=data.get("default_context"), - ) - - -def resolve_context(config: Config, url: str, context_name: Optional[str] = None) -> Context: - """Resolve which context to use for a given URL. - - Resolution order: - 1. Explicit context_name (--context flag) - 2. Exact host match from URL - 3. Subdomain suffix match from URL - 4. default_context from config - 5. Fallback to default auth server with no credentials file - """ - # Explicit context name - if context_name: - if context_name not in config.contexts: - raise ValueError(f"Unknown context: {context_name}") - return config.contexts[context_name] - - # Domain matching - parsed = urlparse(url) - hostname = parsed.hostname or "" - - # Exact match - for ctx in config.contexts.values(): - if hostname == ctx.host: - return ctx - - # Subdomain suffix match - for ctx in config.contexts.values(): - if hostname.endswith("." + ctx.host): - return ctx - - # Default context fallback - if config.default_context and config.default_context in config.contexts: - return config.contexts[config.default_context] - - # No config at all — use default auth server with env var credentials - return Context( - name="default", - host="app.kognic.com", - auth_server=DEFAULT_HOST, - credentials=None, - ) diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py new file mode 100644 index 0000000..489c576 --- /dev/null +++ b/src/kognic/auth/env_config.py @@ -0,0 +1,92 @@ +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional +from urllib.parse import urlparse + +from kognic.auth import DEFAULT_HOST + +DEFAULT_CONFIG_PATH = str(Path("~") / ".config" / "kognic" / "config.json") + + +@dataclass +class Environment: + name: str + host: str + auth_server: str + credentials: Optional[str] = None + + +@dataclass +class KognicEnvConfig: + environments: dict = field(default_factory=dict) + default_environment: Optional[str] = None + + +def load_kognic_env_config(path: str = DEFAULT_CONFIG_PATH) -> KognicEnvConfig: + """Load config from JSON file. Returns empty Config if file doesn't exist.""" + expanded = Path(path).expanduser() + if not expanded.exists(): + return KognicEnvConfig() + + data = json.loads(expanded.read_text()) + + environments = {} + for name, env_data in data.get("contexts", {}).items(): + credentials = env_data.get("credentials") + if credentials: + credentials = str(Path(credentials).expanduser()) + environments[name] = Environment( + name=name, + host=env_data["host"], + auth_server=env_data["auth_server"], + credentials=credentials, + ) + + return KognicEnvConfig( + environments=environments, + default_environment=data.get("default_context"), + ) + + +def resolve_environment(config: KognicEnvConfig, url: str, env_name: Optional[str] = None) -> Environment: + """Resolve which environment to use for a given URL. + + Resolution order: + 1. Explicit env_name (--env flag) + 2. Exact host match from URL + 3. Subdomain suffix match from URL + 4. default_environment from config + 5. Fallback to default auth server with no credentials file + """ + # Explicit env name + if env_name: + if env_name not in config.environments: + raise ValueError(f"Unknown environment: {env_name}") + return config.environments[env_name] + + # Domain matching + parsed = urlparse(url) + hostname = parsed.hostname or "" + + # Exact match + for env in config.environments.values(): + if hostname == env.host: + return env + + # Subdomain suffix match + for env in config.environments.values(): + if hostname.endswith("." + env.host): + return env + + # Default environment fallback + if config.default_environment and config.default_environment in config.environments: + return config.environments[config.default_environment] + + # No config at all — use default auth server with env var credentials + return Environment( + name="default", + host="app.kognic.com", + auth_server=DEFAULT_HOST, + credentials=None, + ) diff --git a/src/kognic/auth/httpx/async_client.py b/src/kognic/auth/httpx/async_client.py index 98cba71..29ce9e7 100644 --- a/src/kognic/auth/httpx/async_client.py +++ b/src/kognic/auth/httpx/async_client.py @@ -1,6 +1,5 @@ import logging from asyncio import Lock -from typing import Optional import httpx from authlib.integrations.httpx_client import AsyncOAuth2Client @@ -29,25 +28,22 @@ def __init__( self, *, auth=None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, host: str = DEFAULT_HOST, token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, **kwargs, ): - """ - There is a variety of ways to set up the authentication. - :param auth: authentication credentials - :param client_id: client id for authentication - :param client_secret: client secret for authentication - :param host: base url for authentication server - :param token_endpoint: relative path to the token endpoint - :param kwargs: additional params to pass into Httpx Client Constructor + """Initialize the async auth client. + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + host: Base url for authentication server + token_endpoint: Relative path to the token endpoint + **kwargs: Additional params to pass into Httpx Client Constructor """ self.host = host self.token_url = f"{host}{token_endpoint}" - client_id, client_secret = resolve_credentials(auth, client_id, client_secret) + client_id, client_secret = resolve_credentials(auth) self._oauth_client = _AsyncFixedClient( client_id=client_id, @@ -66,7 +62,7 @@ def __init__( def token(self): return self._oauth_client.token - async def _update_token(self, token: OAuth2Token, refresh_token=None, access_token=None): + async def _update_token(self, token: OAuth2Token, access_token=None, refresh_token=None): self._log_new_token() @property diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index c95b1e4..ba30737 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -9,6 +9,7 @@ from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent +from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config from kognic.auth.httpx.async_client import HttpxAuthAsyncClient from kognic.auth.serde import serialize_body @@ -92,6 +93,9 @@ def __init__( client_request = self._oauth_client.request async def request(method, url, **kwargs): + if isinstance(url, str) and url.startswith("/"): + raise ValueError(f"Path must not start with /, got {url}") + # Accept anything jsonable as json, serialize it json = kwargs.pop("json", None) if json is not None: @@ -116,6 +120,33 @@ async def call_with_simple_retry(attempts): self._oauth_client.request = request + @classmethod + def from_env( + cls, + env: str, + *, + env_config_path: str = "", + **kwargs, + ) -> "BaseAsyncApiClient": + """Create a client from a named environment in the config file. + + Args: + env: Environment name to look up in the config file. + env_config_path: Path to config file. Defaults to ~/.config/kognic/config.json. + **kwargs: Additional arguments passed to the constructor (e.g. client_name, json_serializer). + + Returns: + Configured BaseAsyncApiClient instance. + """ + config_file_path = env_config_path or DEFAULT_CONFIG_PATH + cfg = load_kognic_env_config(config_file_path) + if env not in cfg.environments: + raise ValueError(f"Unknown environment: {env} not found in config at {config_file_path}") + resolved = cfg.environments[env] + kwargs.setdefault("auth", resolved.credentials) + kwargs["auth_host"] = resolved.auth_server + return cls(**kwargs) + async def __aenter__(self) -> "BaseAsyncApiClient": """Async context manager entry.""" return self diff --git a/src/kognic/auth/requests/auth_session.py b/src/kognic/auth/requests/auth_session.py index 6bf22a1..99e1843 100644 --- a/src/kognic/auth/requests/auth_session.py +++ b/src/kognic/auth/requests/auth_session.py @@ -46,14 +46,15 @@ def __init__( token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, **kwargs, ): - """ - There is a variety of ways to set up the authentication. - :param auth: authentication credentials - :param client_id: client id for authentication - :param client_secret: client secret for authentication - :param host: base url for authentication server - :param token_endpoint: relative path to the token endpoint - :param kwargs: additional params to pass into Client Constructor + """Initialize the auth session. + + Args: + auth: Authentication credentials - path to credentials file or (client_id, client_secret) tuple + client_id: Client id for authentication + client_secret: Client secret for authentication + host: Base url for authentication server + token_endpoint: Relative path to the token endpoint + **kwargs: Additional params to pass into Client Constructor """ self.host = host self.token_url = f"{host}{token_endpoint}" diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index f94fd41..a008abc 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -13,6 +13,7 @@ from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent from kognic.auth.credentials_parser import resolve_credentials +from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession from kognic.auth.serde import serialize_body @@ -29,19 +30,11 @@ def _create_cached_oauth_session( Caching avoids creating multiple sessions for the same credentials. """ - if auth_tuple: - client_id, client_secret = auth_tuple - return RequestsAuthSession( - client_id=client_id, - client_secret=client_secret, - host=auth_host, - token_endpoint=auth_token_endpoint, - ).session - else: - return RequestsAuthSession( - host=auth_host, - token_endpoint=auth_token_endpoint, - ).session + return RequestsAuthSession( + auth=auth_tuple, + host=auth_host, + token_endpoint=auth_token_endpoint, + ).session DEFAULT_RETRY = Retry(total=3, connect=3, read=3, backoff_factor=0.5, status_forcelist=[502, 503, 504]) @@ -90,7 +83,6 @@ def create_session( """Create a requests session with enhancements. - OAuth2 authentication with automatic token refresh - - Accept-Encoding: gzip header - Automatic JSON serialization for jsonable objects - Default retry logic for transient errors - Sunset header handling @@ -112,7 +104,6 @@ def create_session( session.headers["User-Agent"] = get_user_agent(f"requests/{requests.__version__}", client_name) - session.headers["Accept-Encoding"] = "gzip" session.mount("http://", HTTPAdapter(max_retries=DEFAULT_RETRY)) session.mount("https://", HTTPAdapter(max_retries=DEFAULT_RETRY)) @@ -208,3 +199,31 @@ def session(self) -> Session: json_serializer=self._json_serializer, ) return self._session + + @classmethod + def from_env( + cls, + env: str, + *, + env_config_path: str = "", + **kwargs, + ) -> "BaseApiClient": + """Create a client from a named environment in the config file. + + Args: + env: Environment name to look up in the config file. + env_config_path: Path to config file. Defaults to ~/.config/kognic/config.json. + **kwargs: Additional arguments passed to the constructor (e.g. client_name, json_serializer). + + Returns: + Configured BaseApiClient instance. + """ + + config_file_path = env_config_path or DEFAULT_CONFIG_PATH + cfg = load_kognic_env_config(config_file_path) + if env not in cfg.environments: + raise ValueError(f"Unknown environment: {env} not found in config at {config_file_path}") + resolved = cfg.environments[env] + kwargs.setdefault("auth", resolved.credentials) + kwargs["auth_host"] = resolved.auth_server + return cls(**kwargs) diff --git a/tests/test_base_client_async.py b/tests/test_base_client_async.py index 22f2b0f..5bbd4c4 100644 --- a/tests/test_base_client_async.py +++ b/tests/test_base_client_async.py @@ -1,6 +1,9 @@ """Unit tests for BaseAsyncApiClient (async client).""" +import json +import tempfile import unittest +from pathlib import Path from unittest.mock import patch @@ -32,5 +35,50 @@ def test_has_context_manager_methods(self): self.assertTrue(hasattr(BaseAsyncApiClient, "close")) +class TestBaseAsyncApiClientFromEnv(unittest.TestCase): + def _write_config(self, data): + f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump(data, f) + f.flush() + f.close() + return f.name + + @patch("kognic.auth.httpx.base_client.HttpxAuthAsyncClient.__init__", return_value=None) + def test_from_env_passes_resolved_values(self, mock_init): + from kognic.auth.httpx.base_client import BaseAsyncApiClient + + config_path = self._write_config( + { + "contexts": { + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "/tmp/demo-creds.json", + } + } + } + ) + try: + with patch.object(BaseAsyncApiClient, "_oauth_client", create=True): + BaseAsyncApiClient.from_env("demo", env_config_path=config_path) + mock_init.assert_called_once() + call_kwargs = mock_init.call_args[1] + self.assertEqual(call_kwargs["auth"], "/tmp/demo-creds.json") + self.assertEqual(call_kwargs["host"], "https://auth.demo.kognic.com") + finally: + Path(config_path).unlink() + + def test_unknown_env_raises(self): + from kognic.auth.httpx.base_client import BaseAsyncApiClient + + config_path = self._write_config({"contexts": {}}) + try: + with self.assertRaises(ValueError) as cm: + BaseAsyncApiClient.from_env("nonexistent", env_config_path=config_path) + self.assertIn("Unknown environment", str(cm.exception)) + finally: + Path(config_path).unlink() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py index 7300f21..e30c706 100644 --- a/tests/test_base_client_sync.py +++ b/tests/test_base_client_sync.py @@ -1,6 +1,9 @@ """Unit tests for BaseApiClient (sync client).""" +import json +import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from kognic.auth._sunset import DATETIME_FMT, handle_sunset @@ -88,5 +91,106 @@ class MyCustomClient(BaseApiClient): self.assertEqual(client._client_name, "MyCustomClient") +class TestBaseApiClientFromEnv(unittest.TestCase): + def _write_config(self, data): + f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) + json.dump(data, f) + f.flush() + f.close() + return f.name + + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + def test_from_env_sets_auth_and_host(self, mock_session_class): + from kognic.auth.requests.base_client import BaseApiClient + + mock_instance = MagicMock() + mock_instance.session = MagicMock() + mock_session_class.return_value = mock_instance + + config_path = self._write_config( + { + "contexts": { + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "/tmp/demo-creds.json", + } + } + } + ) + try: + client = BaseApiClient.from_env("demo", env_config_path=config_path) + self.assertEqual(client._auth, "/tmp/demo-creds.json") + self.assertEqual(client._auth_host, "https://auth.demo.kognic.com") + finally: + Path(config_path).unlink() + + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + def test_explicit_auth_overrides_env_credentials(self, mock_session_class): + from kognic.auth.requests.base_client import BaseApiClient + + mock_instance = MagicMock() + mock_instance.session = MagicMock() + mock_session_class.return_value = mock_instance + + config_path = self._write_config( + { + "contexts": { + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "/tmp/demo-creds.json", + } + } + } + ) + try: + client = BaseApiClient.from_env("demo", env_config_path=config_path, auth=("my-id", "my-secret")) + self.assertEqual(client._auth, ("my-id", "my-secret")) + self.assertEqual(client._auth_host, "https://auth.demo.kognic.com") + finally: + Path(config_path).unlink() + + def test_unknown_env_raises(self): + from kognic.auth.requests.base_client import BaseApiClient + + config_path = self._write_config({"contexts": {}}) + try: + with self.assertRaises(ValueError) as cm: + BaseApiClient.from_env("nonexistent", env_config_path=config_path) + self.assertIn("Unknown environment", str(cm.exception)) + finally: + Path(config_path).unlink() + + @patch("kognic.auth.requests.base_client.RequestsAuthSession") + def test_from_env_works_on_subclass(self, mock_session_class): + from kognic.auth.requests.base_client import BaseApiClient + + mock_instance = MagicMock() + mock_instance.session = MagicMock() + mock_session_class.return_value = mock_instance + + class MyClient(BaseApiClient): + pass + + config_path = self._write_config( + { + "contexts": { + "demo": { + "host": "demo.kognic.com", + "auth_server": "https://auth.demo.kognic.com", + "credentials": "/tmp/demo-creds.json", + } + } + } + ) + try: + client = MyClient.from_env("demo", env_config_path=config_path) + self.assertIsInstance(client, MyClient) + self.assertEqual(client._client_name, "MyClient") + finally: + Path(config_path).unlink() + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 6e09ef3..a9fbe8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ from kognic.auth import DEFAULT_HOST from kognic.auth.cli import create_parser, main from kognic.auth.cli.call import run as call_run -from kognic.auth.config import Context +from kognic.auth.env_config import Environment class CliParserTest(unittest.TestCase): @@ -14,7 +14,7 @@ def test_default_server(self): args = parser.parse_args(["get-access-token"]) self.assertIsNone(args.server) self.assertIsNone(args.credentials) - self.assertIsNone(args.context_name) + self.assertIsNone(args.env_name) def test_custom_server(self): parser = create_parser() @@ -36,8 +36,8 @@ def test_all_options(self): def test_get_access_token_with_context(self): parser = create_parser() - args = parser.parse_args(["get-access-token", "--context", "demo"]) - self.assertEqual(args.context_name, "demo") + args = parser.parse_args(["get-access-token", "--env", "demo"]) + self.assertEqual(args.env_name, "demo") def test_no_command_shows_help(self): with mock.patch("builtins.print"): @@ -131,13 +131,13 @@ def test_main_generic_exception(self, mock_session_class): self.assertIn("Error fetching token:", mock_print.call_args[0][0]) @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - @mock.patch("kognic.auth.cli.get_access_token.load_config") + @mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") def test_main_with_context(self, mock_load_config, mock_session_class): - from kognic.auth.config import Config + from kognic.auth.env_config import KognicEnvConfig - mock_load_config.return_value = Config( - contexts={ - "demo": Context( + mock_load_config.return_value = KognicEnvConfig( + environments={ + "demo": Environment( name="demo", host="demo.kognic.com", auth_server="https://auth.demo.kognic.com", @@ -150,7 +150,7 @@ def test_main_with_context(self, mock_load_config, mock_session_class): mock_session_class.return_value = mock_session with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--context", "demo"]) + result = main(["get-access-token", "--env", "demo"]) self.assertEqual(result, 0) mock_print.assert_called_once_with("demo-token") @@ -160,13 +160,13 @@ def test_main_with_context(self, mock_load_config, mock_session_class): ) @mock.patch("kognic.auth.cli.get_access_token.RequestsAuthSession") - @mock.patch("kognic.auth.cli.get_access_token.load_config") + @mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") def test_main_with_context_server_override(self, mock_load_config, mock_session_class): - from kognic.auth.config import Config + from kognic.auth.env_config import KognicEnvConfig - mock_load_config.return_value = Config( - contexts={ - "demo": Context( + mock_load_config.return_value = KognicEnvConfig( + environments={ + "demo": Environment( name="demo", host="demo.kognic.com", auth_server="https://auth.demo.kognic.com", @@ -179,7 +179,7 @@ def test_main_with_context_server_override(self, mock_load_config, mock_session_ mock_session_class.return_value = mock_session with mock.patch("builtins.print"): - result = main(["get-access-token", "--context", "demo", "--server", "https://custom.server"]) + result = main(["get-access-token", "--env", "demo", "--server", "https://custom.server"]) self.assertEqual(result, 0) mock_session_class.assert_called_once_with( @@ -188,17 +188,17 @@ def test_main_with_context_server_override(self, mock_load_config, mock_session_ ) def test_main_with_unknown_context(self): - with mock.patch("kognic.auth.cli.get_access_token.load_config") as mock_load_config: - from kognic.auth.config import Config + with mock.patch("kognic.auth.cli.get_access_token.load_kognic_env_config") as mock_load_config: + from kognic.auth.env_config import KognicEnvConfig - mock_load_config.return_value = Config() + mock_load_config.return_value = KognicEnvConfig() with mock.patch("builtins.print") as mock_print: - result = main(["get-access-token", "--context", "nonexistent"]) + result = main(["get-access-token", "--env", "nonexistent"]) self.assertEqual(result, 1) mock_print.assert_called_once() - self.assertIn("Unknown context", mock_print.call_args[0][0]) + self.assertIn("Unknown environment", mock_print.call_args[0][0]) class CallParserTest(unittest.TestCase): @@ -210,7 +210,7 @@ def test_call_basic(self): self.assertEqual(args.url, "https://app.kognic.com/v1/projects") self.assertIsNone(args.data) self.assertIsNone(args.headers) - self.assertIsNone(args.context_name) + self.assertIsNone(args.env_name) def test_call_with_method(self): parser = create_parser() @@ -239,8 +239,8 @@ def test_call_with_headers(self): def test_call_with_context(self): parser = create_parser() - args = parser.parse_args(["call", "https://demo.kognic.com/v1/projects", "--context", "demo"]) - self.assertEqual(args.context_name, "demo") + args = parser.parse_args(["call", "https://demo.kognic.com/v1/projects", "--env", "demo"]) + self.assertEqual(args.env_name, "demo") def test_call_with_config(self): parser = create_parser() @@ -256,7 +256,7 @@ def _make_parsed( data=None, headers=None, config="/nonexistent/config.json", - context_name=None, + env_name=None, ): parser = create_parser() args = ["call", url] @@ -268,16 +268,16 @@ def _make_parsed( for h in headers: args.extend(["-H", h]) args.extend(["--config", config]) - if context_name: - args.extend(["--context", context_name]) + if env_name: + args.extend(["--env", env_name]) return parser.parse_args(args) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -304,12 +304,12 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r ) mock_print.assert_called_once_with(json.dumps({"projects": []}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_post_with_data(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_post_with_data(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -335,12 +335,12 @@ def test_call_api_post_with_data(self, mock_session_class, mock_load_config, moc headers={"Content-Type": "application/json"}, ) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -366,12 +366,12 @@ def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config headers={"Accept": "text/plain", "X-Custom": "value"}, ) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -409,12 +409,12 @@ def test_call_api_invalid_header_format(self): error_output = mock_print.call_args[0][0] self.assertIn("Invalid header format", error_output) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_plain_text_response(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_plain_text_response(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -435,12 +435,12 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with("Hello World") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -464,13 +464,13 @@ def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, m mock_print.assert_any_call(json.dumps({"id": 1, "name": "a"})) mock_print.assert_any_call(json.dumps({"id": 2, "name": "b"})) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response has a single key holding a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -494,13 +494,13 @@ def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_ mock_print.assert_any_call(json.dumps({"id": 1})) mock_print.assert_any_call(json.dumps({"id": 2})) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used but response has multiple keys, pretty-print as usual.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -522,13 +522,13 @@ def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with(json.dumps({"data": [{"id": 1}], "total": 1}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response body is a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -553,13 +553,13 @@ def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_confi mock_print.assert_any_call(json.dumps({"id": 2})) mock_print.assert_any_call(json.dumps({"id": 3})) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and data is an empty list, nothing is printed.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -581,12 +581,12 @@ def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, m self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -612,12 +612,12 @@ def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, moc self.assertEqual(lines[1], "1,a") self.assertEqual(lines[2], "2,b") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -643,12 +643,12 @@ def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, moc self.assertEqual(lines[1], "1\ta") self.assertEqual(lines[2], "2\tb") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_table_data_array(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_table_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -674,13 +674,13 @@ def test_call_api_table_data_array(self, mock_session_class, mock_load_config, m self.assertEqual(lines[2], "| 1 | alice |") self.assertEqual(lines[3], "| 2 | b |") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """Table with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -702,13 +702,13 @@ def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, m self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in CSV output.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -733,13 +733,13 @@ def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, self.assertEqual(lines[0], "id,tags,meta") self.assertEqual(lines[1], '1,"[""a"", ""b""]","{""key"": ""val""}"') - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_table_nested_values(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_table_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in table output.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -764,12 +764,12 @@ def test_call_api_table_nested_values(self, mock_session_class, mock_load_config self.assertEqual(lines[1], "|----|------------|") self.assertEqual(lines[2], '| 1 | ["a", "b"] |') - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -795,13 +795,13 @@ def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, self.assertEqual(lines[1], "10,20") self.assertEqual(lines[2], "30,40") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV output includes all keys across all rows, with blanks for missing values.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -827,13 +827,13 @@ def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mo self.assertEqual(lines[1], "1,a,") self.assertEqual(lines[2], "2,,z") - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -855,13 +855,13 @@ def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, moc self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") @mock.patch("kognic.auth.cli.call.RequestsAuthSession") - def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config, mock_resolve_context): + def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV with non-flattenable response falls back to pretty JSON.""" mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="default", host="app.kognic.com", auth_server="https://auth.app.kognic.com", @@ -883,11 +883,11 @@ def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with(json.dumps({"a": 1, "b": 2}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_context") - @mock.patch("kognic.auth.cli.call.load_config") - def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_context): + @mock.patch("kognic.auth.cli.call.resolve_environment") + @mock.patch("kognic.auth.cli.call.load_kognic_env_config") + def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() - mock_resolve_context.return_value = Context( + mock_resolve_environment.return_value = Environment( name="demo", host="demo.kognic.com", auth_server="https://auth.demo.kognic.com", diff --git a/tests/test_config.py b/tests/test_config.py index b988586..03428b5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,14 +4,14 @@ from pathlib import Path from kognic.auth import DEFAULT_HOST -from kognic.auth.config import Config, Context, load_config, resolve_context +from kognic.auth.env_config import Environment, KognicEnvConfig, load_kognic_env_config, resolve_environment class LoadConfigTest(unittest.TestCase): def test_missing_file_returns_empty_config(self): - config = load_config("/nonexistent/path/config.json") - self.assertEqual(config.contexts, {}) - self.assertIsNone(config.default_context) + config = load_kognic_env_config("/nonexistent/path/config.json") + self.assertEqual(config.environments, {}) + self.assertIsNone(config.default_environment) def test_valid_config(self): data = { @@ -31,21 +31,21 @@ def test_valid_config(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(data, f) f.flush() - config = load_config(f.name) + config = load_kognic_env_config(f.name) Path(f.name).unlink() - self.assertEqual(len(config.contexts), 2) - self.assertEqual(config.default_context, "production") + self.assertEqual(len(config.environments), 2) + self.assertEqual(config.default_environment, "production") - prod = config.contexts["production"] + prod = config.environments["production"] self.assertEqual(prod.name, "production") self.assertEqual(prod.host, "app.kognic.com") self.assertEqual(prod.auth_server, "https://auth.app.kognic.com") self.assertTrue(prod.credentials.endswith("creds.json")) self.assertNotIn("~", prod.credentials) - demo = config.contexts["demo"] + demo = config.environments["demo"] self.assertIsNone(demo.credentials) def test_invalid_json_raises(self): @@ -53,7 +53,7 @@ def test_invalid_json_raises(self): f.write("not valid json{") f.flush() with self.assertRaises(json.JSONDecodeError): - load_config(f.name) + load_kognic_env_config(f.name) Path(f.name).unlink() def test_empty_contexts(self): @@ -61,83 +61,83 @@ def test_empty_contexts(self): with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(data, f) f.flush() - config = load_config(f.name) + config = load_kognic_env_config(f.name) Path(f.name).unlink() - self.assertEqual(config.contexts, {}) - self.assertIsNone(config.default_context) + self.assertEqual(config.environments, {}) + self.assertIsNone(config.default_environment) -class ResolveContextTest(unittest.TestCase): +class ResolveEnvironmentTest(unittest.TestCase): def setUp(self): - self.config = Config( - contexts={ - "production": Context( + self.config = KognicEnvConfig( + environments={ + "production": Environment( name="production", host="app.kognic.com", auth_server="https://auth.app.kognic.com", credentials="/path/to/prod-creds.json", ), - "demo": Context( + "demo": Environment( name="demo", host="demo.kognic.com", auth_server="https://auth.demo.kognic.com", credentials="/path/to/demo-creds.json", ), }, - default_context="production", + default_environment="production", ) - def test_explicit_context(self): - ctx = resolve_context(self.config, "https://anything.com/v1/foo", "demo") - self.assertEqual(ctx.name, "demo") + def test_explicit_env(self): + env = resolve_environment(self.config, "https://anything.com/v1/foo", "demo") + self.assertEqual(env.name, "demo") - def test_explicit_context_unknown_raises(self): + def test_explicit_env_unknown_raises(self): with self.assertRaises(ValueError) as cm: - resolve_context(self.config, "https://anything.com", "nonexistent") - self.assertIn("Unknown context", str(cm.exception)) + resolve_environment(self.config, "https://anything.com", "nonexistent") + self.assertIn("Unknown environment", str(cm.exception)) def test_exact_host_match(self): - ctx = resolve_context(self.config, "https://app.kognic.com/v1/projects") - self.assertEqual(ctx.name, "production") + env = resolve_environment(self.config, "https://app.kognic.com/v1/projects") + self.assertEqual(env.name, "production") def test_subdomain_match(self): - ctx = resolve_context(self.config, "https://api.app.kognic.com/v1/projects") - self.assertEqual(ctx.name, "production") + env = resolve_environment(self.config, "https://api.app.kognic.com/v1/projects") + self.assertEqual(env.name, "production") def test_demo_exact_match(self): - ctx = resolve_context(self.config, "https://demo.kognic.com/v1/projects") - self.assertEqual(ctx.name, "demo") + env = resolve_environment(self.config, "https://demo.kognic.com/v1/projects") + self.assertEqual(env.name, "demo") def test_demo_subdomain_match(self): - ctx = resolve_context(self.config, "https://api.demo.kognic.com/v1/projects") - self.assertEqual(ctx.name, "demo") + env = resolve_environment(self.config, "https://api.demo.kognic.com/v1/projects") + self.assertEqual(env.name, "demo") - def test_default_context_fallback(self): - ctx = resolve_context(self.config, "https://unknown.example.com/v1/foo") - self.assertEqual(ctx.name, "production") + def test_default_environment_fallback(self): + env = resolve_environment(self.config, "https://unknown.example.com/v1/foo") + self.assertEqual(env.name, "production") def test_no_config_fallback(self): - empty_config = Config() - ctx = resolve_context(empty_config, "https://app.kognic.com/v1/projects") - self.assertEqual(ctx.name, "default") - self.assertEqual(ctx.auth_server, DEFAULT_HOST) - self.assertIsNone(ctx.credentials) + empty_config = KognicEnvConfig() + env = resolve_environment(empty_config, "https://app.kognic.com/v1/projects") + self.assertEqual(env.name, "default") + self.assertEqual(env.auth_server, DEFAULT_HOST) + self.assertIsNone(env.credentials) def test_no_default_no_match_falls_back_to_default_auth(self): - config = Config( - contexts={ - "demo": Context( + config = KognicEnvConfig( + environments={ + "demo": Environment( name="demo", host="demo.kognic.com", auth_server="https://auth.demo.kognic.com", ), }, - default_context=None, + default_environment=None, ) - ctx = resolve_context(config, "https://unknown.example.com/v1/foo") - self.assertEqual(ctx.name, "default") - self.assertEqual(ctx.auth_server, DEFAULT_HOST) + env = resolve_environment(config, "https://unknown.example.com/v1/foo") + self.assertEqual(env.name, "default") + self.assertEqual(env.auth_server, DEFAULT_HOST) if __name__ == "__main__": From 78c652127554f6af9a7ad8b36531b8b4cfe3630d Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:34:09 +0100 Subject: [PATCH 11/21] Replace os.unlink with Path.unlink in credential parser tests Co-Authored-By: Claude Opus 4.6 --- tests/test_credentials_parser.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_credentials_parser.py b/tests/test_credentials_parser.py index 63bfdad..9cb2c5a 100644 --- a/tests/test_credentials_parser.py +++ b/tests/test_credentials_parser.py @@ -3,6 +3,7 @@ import json import os import unittest +from pathlib import Path from unittest.mock import patch from kognic.auth.credentials_parser import ( @@ -44,7 +45,7 @@ def test_parse_from_file(self, tmp_path=None): self.assertEqual(creds.client_id, "test_id") self.assertEqual(creds.client_secret, "test_secret") finally: - os.unlink(path) + Path(path).unlink() def test_parse_file_not_found(self): with self.assertRaises(FileNotFoundError): @@ -69,7 +70,7 @@ def test_json_file_path(self): creds = get_credentials(path) self.assertEqual(creds.client_id, "test_id") finally: - os.unlink(path) + Path(path).unlink() def test_non_json_file_raises(self): with self.assertRaises(ValueError) as ctx: @@ -124,7 +125,7 @@ def test_kognic_credentials_file(self): self.assertEqual(client_id, "test_id") self.assertEqual(client_secret, "test_secret") finally: - os.unlink(path) + Path(path).unlink() @patch.dict( os.environ, @@ -169,7 +170,7 @@ def test_auth_file_path(self): self.assertEqual(client_id, "test_id") self.assertEqual(client_secret, "test_secret") finally: - os.unlink(path) + Path(path).unlink() @patch.dict(os.environ, {"KOGNIC_CLIENT_ID": "env_id", "KOGNIC_CLIENT_SECRET": "env_secret"}, clear=True) def test_falls_back_to_env(self): From e28bde6d5a27130040624b03d2fcd2635ba3be5f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:41:58 +0100 Subject: [PATCH 12/21] Rename CLI --config to --env-config-file-path Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++++---- src/kognic/auth/cli/call.py | 6 +++--- src/kognic/auth/cli/get_access_token.py | 6 +++--- tests/test_cli.py | 10 ++++++---- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 731b24c..b1f37f0 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,14 @@ Each environment has the following fields: Generate an access token for Kognic API authentication. ```bash -kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--config FILE] +kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] [--env-config-file-path FILE] ``` **Options:** - `--server` - Authentication server URL (default: `https://auth.app.kognic.com`) - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. - `--env` - Use a named environment from the config file. -- `--config` - Config file path (default: `~/.config/kognic/config.json`) +- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/config.json`) When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values. @@ -103,7 +103,7 @@ kognic-auth get-access-token --env demo --server https://custom.server Make an authenticated HTTP request to a Kognic API. ```bash -kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env NAME] [--config FILE] +kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env NAME] [--env-config-file-path FILE] ``` **Options:** @@ -113,7 +113,7 @@ kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env - `-H`, `--header` - Header in `Key: Value` format (repeatable) - `--format` - Output format (default: `json`). See [Output formats](#output-formats) below. - `--env` - Force a specific environment (skip URL-based matching) -- `--config` - Config file path (default: `~/.config/kognic/config.json`) +- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/config.json`) When `--env` is not provided, the environment is automatically resolved by matching the request URL's hostname against the `host` field of each environment in the config file. diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/call.py index f78205b..7fdf552 100644 --- a/src/kognic/auth/cli/call.py +++ b/src/kognic/auth/cli/call.py @@ -37,9 +37,9 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: help="Header in 'Key: Value' format (repeatable)", ) call_parser.add_argument( - "--config", + "--env-config-file-path", default=DEFAULT_CONFIG_PATH, - help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", + help=f"Environment config file path (default: {DEFAULT_CONFIG_PATH})", ) call_parser.add_argument("--env", dest="env_name", help="Force a specific environment (skip URL-based matching)") call_parser.add_argument( @@ -162,7 +162,7 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: def run(parsed: argparse.Namespace) -> int: try: - config = load_kognic_env_config(parsed.config) + config = load_kognic_env_config(parsed.env_config_file_path) env = resolve_environment(config, parsed.url, parsed.env_name) session = RequestsAuthSession( diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index eb4bfa0..2a46391 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -26,9 +26,9 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: help="Path to JSON credentials file. If not provided, credentials are read from environment variables.", ) token_parser.add_argument( - "--config", + "--env-config-file-path", default=DEFAULT_CONFIG_PATH, - help=f"Config file path (default: {DEFAULT_CONFIG_PATH})", + help=f"Environment config file path (default: {DEFAULT_CONFIG_PATH})", ) token_parser.add_argument( "--env", @@ -43,7 +43,7 @@ def run(parsed: argparse.Namespace) -> int: credentials = parsed.credentials if parsed.env_name: - config = load_kognic_env_config(parsed.config) + config = load_kognic_env_config(parsed.env_config_file_path) if parsed.env_name not in config.environments: print(f"Error: Unknown environment: {parsed.env_name}", file=sys.stderr) return 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index a9fbe8a..1bd31e6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -244,8 +244,10 @@ def test_call_with_context(self): def test_call_with_config(self): parser = create_parser() - args = parser.parse_args(["call", "https://app.kognic.com/v1/projects", "--config", "/custom/config.json"]) - self.assertEqual(args.config, "/custom/config.json") + args = parser.parse_args( + ["call", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/custom/config.json"] + ) + self.assertEqual(args.env_config_file_path, "/custom/config.json") class CallApiTest(unittest.TestCase): @@ -255,7 +257,7 @@ def _make_parsed( url="https://app.kognic.com/v1/projects", data=None, headers=None, - config="/nonexistent/config.json", + env_config_file_path="/nonexistent/config.json", env_name=None, ): parser = create_parser() @@ -267,7 +269,7 @@ def _make_parsed( if headers: for h in headers: args.extend(["-H", h]) - args.extend(["--config", config]) + args.extend(["--env-config-file-path", env_config_file_path]) if env_name: args.extend(["--env", env_name]) return parser.parse_args(args) From eea9fa280891b1c0b6e33fa9da6f3251f6a60388 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:42:44 +0100 Subject: [PATCH 13/21] Rename DEFAULT_CONFIG_PATH to DEFAULT_ENV_CONFIG_FILE_PATH Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/cli/call.py | 6 +++--- src/kognic/auth/cli/get_access_token.py | 6 +++--- src/kognic/auth/env_config.py | 4 ++-- src/kognic/auth/httpx/base_client.py | 4 ++-- src/kognic/auth/requests/base_client.py | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/call.py index 7fdf552..516b22d 100644 --- a/src/kognic/auth/cli/call.py +++ b/src/kognic/auth/cli/call.py @@ -7,7 +7,7 @@ import sys from typing import Any -from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config, resolve_environment +from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment from kognic.auth.requests.auth_session import RequestsAuthSession COMMAND = "call" @@ -38,8 +38,8 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: ) call_parser.add_argument( "--env-config-file-path", - default=DEFAULT_CONFIG_PATH, - help=f"Environment config file path (default: {DEFAULT_CONFIG_PATH})", + default=DEFAULT_ENV_CONFIG_FILE_PATH, + help=f"Environment config file path (default: {DEFAULT_ENV_CONFIG_FILE_PATH})", ) call_parser.add_argument("--env", dest="env_name", help="Force a specific environment (skip URL-based matching)") call_parser.add_argument( diff --git a/src/kognic/auth/cli/get_access_token.py b/src/kognic/auth/cli/get_access_token.py index 2a46391..46f9007 100644 --- a/src/kognic/auth/cli/get_access_token.py +++ b/src/kognic/auth/cli/get_access_token.py @@ -4,7 +4,7 @@ import sys from kognic.auth import DEFAULT_HOST -from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config +from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession COMMAND = "get-access-token" @@ -27,8 +27,8 @@ def register_parser(subparsers: argparse._SubParsersAction) -> None: ) token_parser.add_argument( "--env-config-file-path", - default=DEFAULT_CONFIG_PATH, - help=f"Environment config file path (default: {DEFAULT_CONFIG_PATH})", + default=DEFAULT_ENV_CONFIG_FILE_PATH, + help=f"Environment config file path (default: {DEFAULT_ENV_CONFIG_FILE_PATH})", ) token_parser.add_argument( "--env", diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 489c576..94ef530 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -6,7 +6,7 @@ from kognic.auth import DEFAULT_HOST -DEFAULT_CONFIG_PATH = str(Path("~") / ".config" / "kognic" / "config.json") +DEFAULT_ENV_CONFIG_FILE_PATH = str(Path("~") / ".config" / "kognic" / "config.json") @dataclass @@ -23,7 +23,7 @@ class KognicEnvConfig: default_environment: Optional[str] = None -def load_kognic_env_config(path: str = DEFAULT_CONFIG_PATH) -> KognicEnvConfig: +def load_kognic_env_config(path: str = DEFAULT_ENV_CONFIG_FILE_PATH) -> KognicEnvConfig: """Load config from JSON file. Returns empty Config if file doesn't exist.""" expanded = Path(path).expanduser() if not expanded.exists(): diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index ba30737..de2739d 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -9,7 +9,7 @@ from kognic.auth import DEFAULT_HOST, DEFAULT_TOKEN_ENDPOINT_RELPATH from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent -from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config +from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.httpx.async_client import HttpxAuthAsyncClient from kognic.auth.serde import serialize_body @@ -138,7 +138,7 @@ def from_env( Returns: Configured BaseAsyncApiClient instance. """ - config_file_path = env_config_path or DEFAULT_CONFIG_PATH + config_file_path = env_config_path or DEFAULT_ENV_CONFIG_FILE_PATH cfg = load_kognic_env_config(config_file_path) if env not in cfg.environments: raise ValueError(f"Unknown environment: {env} not found in config at {config_file_path}") diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index a008abc..b220005 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -13,7 +13,7 @@ from kognic.auth._sunset import handle_sunset from kognic.auth._user_agent import get_user_agent from kognic.auth.credentials_parser import resolve_credentials -from kognic.auth.env_config import DEFAULT_CONFIG_PATH, load_kognic_env_config +from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config from kognic.auth.requests.auth_session import RequestsAuthSession from kognic.auth.serde import serialize_body @@ -219,7 +219,7 @@ def from_env( Configured BaseApiClient instance. """ - config_file_path = env_config_path or DEFAULT_CONFIG_PATH + config_file_path = env_config_path or DEFAULT_ENV_CONFIG_FILE_PATH cfg = load_kognic_env_config(config_file_path) if env not in cfg.environments: raise ValueError(f"Unknown environment: {env} not found in config at {config_file_path}") From 4716fbd285c482fdc3f9b9e3c9ead0f000e8854b Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:43:10 +0100 Subject: [PATCH 14/21] Fix test_valid_config to use correct config keys (contexts/default_context) Co-Authored-By: Claude Opus 4.6 --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 03428b5..bbdb916 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -57,7 +57,7 @@ def test_invalid_json_raises(self): Path(f.name).unlink() def test_empty_contexts(self): - data = {"contexts": {}} + data = {"environments": {}} with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(data, f) f.flush() From 99a53cfe21e361fe414c7c5ced4f874a5e93e2c6 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:44:04 +0100 Subject: [PATCH 15/21] Rename config file keys from contexts/default_context to environments/default_environment Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +++--- src/kognic/auth/env_config.py | 4 ++-- tests/test_base_client_async.py | 4 ++-- tests/test_base_client_sync.py | 8 ++++---- tests/test_config.py | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index b1f37f0..f175d08 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. Th ```json { - "default_context": "production", - "contexts": { + "default_environment": "production", + "environments": { "production": { "host": "app.kognic.com", "auth_server": "https://auth.app.kognic.com", @@ -65,7 +65,7 @@ Each environment has the following fields: - `auth_server` - The OAuth server URL used to fetch tokens. - `credentials` *(optional)* - Path to a JSON credentials file. Tilde (`~`) is expanded. If omitted, credentials are read from environment variables. -`default_context` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found. +`default_environment` specifies which environment to use as a fallback when no `--env` flag is given and no URL match is found. ### get-access-token diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 94ef530..4de2e93 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -32,7 +32,7 @@ def load_kognic_env_config(path: str = DEFAULT_ENV_CONFIG_FILE_PATH) -> KognicEn data = json.loads(expanded.read_text()) environments = {} - for name, env_data in data.get("contexts", {}).items(): + for name, env_data in data.get("environments", {}).items(): credentials = env_data.get("credentials") if credentials: credentials = str(Path(credentials).expanduser()) @@ -45,7 +45,7 @@ def load_kognic_env_config(path: str = DEFAULT_ENV_CONFIG_FILE_PATH) -> KognicEn return KognicEnvConfig( environments=environments, - default_environment=data.get("default_context"), + default_environment=data.get("default_environment"), ) diff --git a/tests/test_base_client_async.py b/tests/test_base_client_async.py index 5bbd4c4..4b23fc4 100644 --- a/tests/test_base_client_async.py +++ b/tests/test_base_client_async.py @@ -49,7 +49,7 @@ def test_from_env_passes_resolved_values(self, mock_init): config_path = self._write_config( { - "contexts": { + "environments": { "demo": { "host": "demo.kognic.com", "auth_server": "https://auth.demo.kognic.com", @@ -71,7 +71,7 @@ def test_from_env_passes_resolved_values(self, mock_init): def test_unknown_env_raises(self): from kognic.auth.httpx.base_client import BaseAsyncApiClient - config_path = self._write_config({"contexts": {}}) + config_path = self._write_config({"environments": {}}) try: with self.assertRaises(ValueError) as cm: BaseAsyncApiClient.from_env("nonexistent", env_config_path=config_path) diff --git a/tests/test_base_client_sync.py b/tests/test_base_client_sync.py index e30c706..b6a497d 100644 --- a/tests/test_base_client_sync.py +++ b/tests/test_base_client_sync.py @@ -109,7 +109,7 @@ def test_from_env_sets_auth_and_host(self, mock_session_class): config_path = self._write_config( { - "contexts": { + "environments": { "demo": { "host": "demo.kognic.com", "auth_server": "https://auth.demo.kognic.com", @@ -135,7 +135,7 @@ def test_explicit_auth_overrides_env_credentials(self, mock_session_class): config_path = self._write_config( { - "contexts": { + "environments": { "demo": { "host": "demo.kognic.com", "auth_server": "https://auth.demo.kognic.com", @@ -154,7 +154,7 @@ def test_explicit_auth_overrides_env_credentials(self, mock_session_class): def test_unknown_env_raises(self): from kognic.auth.requests.base_client import BaseApiClient - config_path = self._write_config({"contexts": {}}) + config_path = self._write_config({"environments": {}}) try: with self.assertRaises(ValueError) as cm: BaseApiClient.from_env("nonexistent", env_config_path=config_path) @@ -175,7 +175,7 @@ class MyClient(BaseApiClient): config_path = self._write_config( { - "contexts": { + "environments": { "demo": { "host": "demo.kognic.com", "auth_server": "https://auth.demo.kognic.com", diff --git a/tests/test_config.py b/tests/test_config.py index bbdb916..658f0e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,7 +15,7 @@ def test_missing_file_returns_empty_config(self): def test_valid_config(self): data = { - "contexts": { + "environments": { "production": { "host": "app.kognic.com", "auth_server": "https://auth.app.kognic.com", @@ -26,7 +26,7 @@ def test_valid_config(self): "auth_server": "https://auth.demo.kognic.com", }, }, - "default_context": "production", + "default_environment": "production", } with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: json.dump(data, f) From 9b36b63794a608acd40c35cec178ba704245a333 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:52:41 +0100 Subject: [PATCH 16/21] Accept PathLike in all path-accepting interfaces Adds os.PathLike to type hints for auth, env_config_path, and path parameters across credentials_parser, env_config, and both base clients. Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/credentials_parser.py | 4 +++- src/kognic/auth/env_config.py | 5 +++-- src/kognic/auth/httpx/base_client.py | 5 +++-- src/kognic/auth/requests/base_client.py | 9 +++++---- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 8866aa0..4f28540 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -22,7 +22,7 @@ class ApiCredentials: issuer: str -def parse_credentials(path: Union[str, dict]): +def parse_credentials(path: Union[str, os.PathLike, dict]): if isinstance(path, dict): credentials = path else: @@ -48,6 +48,8 @@ def parse_credentials(path: Union[str, dict]): def get_credentials(auth): + if isinstance(auth, os.PathLike): + return parse_credentials(auth) if isinstance(auth, str): if auth.endswith(".json"): return parse_credentials(auth) diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 4de2e93..3acddfc 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -1,7 +1,8 @@ import json +import os from dataclasses import dataclass, field from pathlib import Path -from typing import Optional +from typing import Optional, Union from urllib.parse import urlparse from kognic.auth import DEFAULT_HOST @@ -23,7 +24,7 @@ class KognicEnvConfig: default_environment: Optional[str] = None -def load_kognic_env_config(path: str = DEFAULT_ENV_CONFIG_FILE_PATH) -> KognicEnvConfig: +def load_kognic_env_config(path: Union[str, os.PathLike] = DEFAULT_ENV_CONFIG_FILE_PATH) -> KognicEnvConfig: """Load config from JSON file. Returns empty Config if file doesn't exist.""" expanded = Path(path).expanduser() if not expanded.exists(): diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index de2739d..32e64b8 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -2,6 +2,7 @@ import asyncio import logging +import os from typing import Any, Callable, Optional, Union import httpx @@ -55,7 +56,7 @@ class BaseAsyncApiClient(HttpxAuthAsyncClient): def __init__( self, *, - auth: Optional[Union[str, tuple]] = None, + auth: Optional[Union[str, os.PathLike, tuple]] = None, auth_host: str = DEFAULT_HOST, auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", @@ -125,7 +126,7 @@ def from_env( cls, env: str, *, - env_config_path: str = "", + env_config_path: Union[str, os.PathLike] = "", **kwargs, ) -> "BaseAsyncApiClient": """Create a client from a named environment in the config file. diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index b220005..11d5358 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -1,6 +1,7 @@ """Base API client V2 using requests/OAuth2 session.""" import logging +import os from functools import lru_cache from threading import Lock from typing import Any, Callable, Optional, Tuple, Union @@ -60,7 +61,7 @@ def _check_response(resp: requests.Response): def _resolve_auth_tuple( - auth: Optional[Union[str, tuple]], + auth: Optional[Union[str, os.PathLike, tuple]], client_id: Optional[str], client_secret: Optional[str], ) -> Optional[Tuple[str, str]]: @@ -74,7 +75,7 @@ def _resolve_auth_tuple( def create_session( *, - auth: Optional[Union[str, tuple]] = None, + auth: Optional[Union[str, os.PathLike, tuple]] = None, auth_host: str = DEFAULT_HOST, auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = None, @@ -156,7 +157,7 @@ class BaseApiClient: def __init__( self, *, - auth: Optional[Union[str, tuple]] = None, + auth: Optional[Union[str, os.PathLike, tuple]] = None, auth_host: str = DEFAULT_HOST, auth_token_endpoint: str = DEFAULT_TOKEN_ENDPOINT_RELPATH, client_name: Optional[str] = "auto", @@ -205,7 +206,7 @@ def from_env( cls, env: str, *, - env_config_path: str = "", + env_config_path: Union[str, os.PathLike] = "", **kwargs, ) -> "BaseApiClient": """Create a client from a named environment in the config file. From a37c170177d622bbcdcd0725ee0c8a3890a9761f Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:56:00 +0100 Subject: [PATCH 17/21] Add .json suffix check for PathLike auth credentials Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/credentials_parser.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index 4f28540..ff29dfd 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -48,12 +48,11 @@ def parse_credentials(path: Union[str, os.PathLike, dict]): def get_credentials(auth): - if isinstance(auth, os.PathLike): + if isinstance(auth, (str, os.PathLike)): + path = str(auth) + if not path.endswith(".json"): + raise ValueError("Bad auth credentials file, must be json") return parse_credentials(auth) - if isinstance(auth, str): - if auth.endswith(".json"): - return parse_credentials(auth) - raise ValueError("Bad auth credentials file, must be json") elif isinstance(auth, ApiCredentials): return auth else: From 779e79126b099592c6c61e461a372cac2dcfe856 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 16:56:41 +0100 Subject: [PATCH 18/21] Include path in credentials file validation error message Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/credentials_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kognic/auth/credentials_parser.py b/src/kognic/auth/credentials_parser.py index ff29dfd..ed504e5 100644 --- a/src/kognic/auth/credentials_parser.py +++ b/src/kognic/auth/credentials_parser.py @@ -51,7 +51,7 @@ def get_credentials(auth): if isinstance(auth, (str, os.PathLike)): path = str(auth) if not path.endswith(".json"): - raise ValueError("Bad auth credentials file, must be json") + raise ValueError(f"Bad auth credentials file, must be json: {path}") return parse_credentials(auth) elif isinstance(auth, ApiCredentials): return auth From da8fd6b88f5d4c22cbe088c35c491058cd697b8a Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Thu, 12 Feb 2026 19:51:05 +0100 Subject: [PATCH 19/21] Configure CLI logging to write warnings to stderr Adds a WARNING-level handler on kognic.auth logger so sunset deprecation warnings and rate limit errors are visible in CLI output. Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/cli/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py index 2941bcc..dd25abb 100644 --- a/src/kognic/auth/cli/__init__.py +++ b/src/kognic/auth/cli/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import logging import sys from types import ModuleType @@ -22,7 +23,15 @@ def create_parser() -> argparse.ArgumentParser: return parser +def _configure_logging() -> None: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) + logging.getLogger("kognic.auth").addHandler(handler) + logging.getLogger("kognic.auth").setLevel(logging.WARNING) + + def main(args: list[str] | None = None) -> int: + _configure_logging() parser = create_parser() parsed = parser.parse_args(args) From 6978ba2b30f1955873fb4831d804dc0330061478 Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 13 Feb 2026 10:23:33 +0100 Subject: [PATCH 20/21] Rename default config file from config.json to environments.json Co-Authored-By: Claude Opus 4.6 --- README.md | 6 +- pyproject.toml | 1 + src/kognic/auth/cli/__init__.py | 4 +- .../auth/cli/{call.py => api_request.py} | 51 ++-- src/kognic/auth/env_config.py | 2 +- src/kognic/auth/httpx/base_client.py | 2 +- src/kognic/auth/requests/base_client.py | 2 +- tests/test_cli.py | 238 +++++++++--------- 8 files changed, 154 insertions(+), 152 deletions(-) rename src/kognic/auth/cli/{call.py => api_request.py} (82%) diff --git a/README.md b/README.md index f175d08..96464c9 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The interface is currently marked experimental, and breaking changes may be made ### Configuration file -The CLI can be configured with a JSON file at `~/.config/kognic/config.json`. This lets you define named environments, each with its own host, auth server, and credentials. +The CLI can be configured with a JSON file at `~/.config/kognic/environments.json`. This lets you define named environments, each with its own host, auth server, and credentials. ```json { @@ -79,7 +79,7 @@ kognic-auth get-access-token [--server SERVER] [--credentials FILE] [--env NAME] - `--server` - Authentication server URL (default: `https://auth.app.kognic.com`) - `--credentials` - Path to JSON credentials file. If not provided, credentials are read from environment variables. - `--env` - Use a named environment from the config file. -- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/config.json`) +- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`) When `--env` is provided, the auth server and credentials are resolved from the config file. Explicit `--server` or `--credentials` flags override the environment values. @@ -113,7 +113,7 @@ kognic-auth call URL [-X METHOD] [-d DATA] [-H HEADER] [--format FORMAT] [--env - `-H`, `--header` - Header in `Key: Value` format (repeatable) - `--format` - Output format (default: `json`). See [Output formats](#output-formats) below. - `--env` - Force a specific environment (skip URL-based matching) -- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/config.json`) +- `--env-config-file-path` - Environment config file path (default: `~/.config/kognic/environments.json`) When `--env` is not provided, the environment is automatically resolved by matching the request URL's hostname against the `host` field of each environment in the config file. diff --git a/pyproject.toml b/pyproject.toml index dbf9562..914c0e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ homepage = "https://github.com/annotell/kognic-auth-python" [project.scripts] kognic-auth = "kognic.auth.cli:main" +kog = "kognic.auth.cli.api_request:main" [tool.setuptools_scm] write_to = "src/kognic/auth/_version.py" diff --git a/src/kognic/auth/cli/__init__.py b/src/kognic/auth/cli/__init__.py index dd25abb..d595de3 100644 --- a/src/kognic/auth/cli/__init__.py +++ b/src/kognic/auth/cli/__init__.py @@ -5,9 +5,9 @@ import sys from types import ModuleType -from kognic.auth.cli import call, get_access_token +from kognic.auth.cli import get_access_token -_SUBCOMMANDS: list[ModuleType] = [get_access_token, call] +_SUBCOMMANDS: list[ModuleType] = [get_access_token] def create_parser() -> argparse.ArgumentParser: diff --git a/src/kognic/auth/cli/call.py b/src/kognic/auth/cli/api_request.py similarity index 82% rename from src/kognic/auth/cli/call.py rename to src/kognic/auth/cli/api_request.py index 516b22d..883a7bc 100644 --- a/src/kognic/auth/cli/call.py +++ b/src/kognic/auth/cli/api_request.py @@ -8,47 +8,41 @@ from typing import Any from kognic.auth.env_config import DEFAULT_ENV_CONFIG_FILE_PATH, load_kognic_env_config, resolve_environment -from kognic.auth.requests.auth_session import RequestsAuthSession +from kognic.auth.requests.base_client import create_session -COMMAND = "call" +METHODS = ["get", "post", "put", "patch", "delete", "head", "options"] -def register_parser(subparsers: argparse._SubParsersAction) -> None: - call_parser = subparsers.add_parser( - COMMAND, - help="Make an authenticated HTTP request to a Kognic API", +def _create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="kog", + description="Make authenticated HTTP requests to Kognic APIs", ) - call_parser.add_argument("url", metavar="URL", help="Full URL to call") - call_parser.add_argument( - "-X", - "--request", - dest="method", - default="GET", - metavar="METHOD", - help="HTTP method (default: GET)", - ) - call_parser.add_argument("-d", "--data", help="Request body (JSON string)") - call_parser.add_argument( + parser.add_argument("method", metavar="METHOD", choices=METHODS, help=f"HTTP method ({', '.join(METHODS)})") + parser.add_argument("url", metavar="URL", help="Full URL to call") + parser.add_argument("-d", "--data", help="Request body (JSON string)") + parser.add_argument( "-H", "--header", action="append", dest="headers", - metavar="HDR", + metavar="HEADER", help="Header in 'Key: Value' format (repeatable)", ) - call_parser.add_argument( + parser.add_argument( "--env-config-file-path", default=DEFAULT_ENV_CONFIG_FILE_PATH, help=f"Environment config file path (default: {DEFAULT_ENV_CONFIG_FILE_PATH})", ) - call_parser.add_argument("--env", dest="env_name", help="Force a specific environment (skip URL-based matching)") - call_parser.add_argument( + parser.add_argument("--env", dest="env_name", help="Force a specific environment (skip URL-based matching)") + parser.add_argument( "--format", dest="output_format", choices=["json", "jsonl", "csv", "tsv", "table"], default="json", help="Output format: json (default), jsonl (one JSON object per line), csv, tsv, table (markdown)", ) + return parser def _parse_headers(raw: list[str] | None) -> dict[str, str] | None: @@ -165,15 +159,15 @@ def run(parsed: argparse.Namespace) -> int: config = load_kognic_env_config(parsed.env_config_file_path) env = resolve_environment(config, parsed.url, parsed.env_name) - session = RequestsAuthSession( + session = create_session( auth=env.credentials, - host=env.auth_server, + auth_host=env.auth_server, ) headers = _parse_headers(parsed.headers) or {} data = _parse_body(parsed.data, headers) - response = session.session.request( + response = session.request( method=parsed.method.upper(), url=parsed.url, json=data if data is not None else None, @@ -189,3 +183,12 @@ def run(parsed: argparse.Namespace) -> int: except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 + + +def main(args: list[str] | None = None) -> None: + from kognic.auth.cli import _configure_logging + + _configure_logging() + parser = _create_parser() + parsed = parser.parse_args(args) + sys.exit(run(parsed)) diff --git a/src/kognic/auth/env_config.py b/src/kognic/auth/env_config.py index 3acddfc..8bf6b33 100644 --- a/src/kognic/auth/env_config.py +++ b/src/kognic/auth/env_config.py @@ -7,7 +7,7 @@ from kognic.auth import DEFAULT_HOST -DEFAULT_ENV_CONFIG_FILE_PATH = str(Path("~") / ".config" / "kognic" / "config.json") +DEFAULT_ENV_CONFIG_FILE_PATH = str(Path("~") / ".config" / "kognic" / "environments.json") @dataclass diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index 32e64b8..e4e1f57 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -133,7 +133,7 @@ def from_env( Args: env: Environment name to look up in the config file. - env_config_path: Path to config file. Defaults to ~/.config/kognic/config.json. + env_config_path: Path to config file. Defaults to ~/.config/kognic/environments.json. **kwargs: Additional arguments passed to the constructor (e.g. client_name, json_serializer). Returns: diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 11d5358..2ab92fe 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -213,7 +213,7 @@ def from_env( Args: env: Environment name to look up in the config file. - env_config_path: Path to config file. Defaults to ~/.config/kognic/config.json. + env_config_path: Path to config file. Defaults to ~/.config/kognic/environments.json. **kwargs: Additional arguments passed to the constructor (e.g. client_name, json_serializer). Returns: diff --git a/tests/test_cli.py b/tests/test_cli.py index 1bd31e6..340ae3a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,8 @@ from kognic.auth import DEFAULT_HOST from kognic.auth.cli import create_parser, main -from kognic.auth.cli.call import run as call_run +from kognic.auth.cli.api_request import _create_parser as create_kog_parser +from kognic.auth.cli.api_request import run as call_run from kognic.auth.env_config import Environment @@ -201,33 +202,32 @@ def test_main_with_unknown_context(self): self.assertIn("Unknown environment", mock_print.call_args[0][0]) -class CallParserTest(unittest.TestCase): - def test_call_basic(self): - parser = create_parser() - args = parser.parse_args(["call", "https://app.kognic.com/v1/projects"]) - self.assertEqual(args.command, "call") - self.assertEqual(args.method, "GET") +class KogParserTest(unittest.TestCase): + def test_kog_basic(self): + parser = create_kog_parser() + args = parser.parse_args(["get", "https://app.kognic.com/v1/projects"]) + self.assertEqual(args.method, "get") self.assertEqual(args.url, "https://app.kognic.com/v1/projects") self.assertIsNone(args.data) self.assertIsNone(args.headers) self.assertIsNone(args.env_name) - def test_call_with_method(self): - parser = create_parser() - args = parser.parse_args(["call", "-X", "POST", "https://app.kognic.com/v1/projects"]) - self.assertEqual(args.method, "POST") + def test_kog_with_method(self): + parser = create_kog_parser() + args = parser.parse_args(["post", "https://app.kognic.com/v1/projects"]) + self.assertEqual(args.method, "post") - def test_call_with_data(self): - parser = create_parser() - args = parser.parse_args(["call", "https://app.kognic.com/v1/projects", "-X", "POST", "-d", '{"name": "test"}']) - self.assertEqual(args.method, "POST") + def test_kog_with_data(self): + parser = create_kog_parser() + args = parser.parse_args(["post", "https://app.kognic.com/v1/projects", "-d", '{"name": "test"}']) + self.assertEqual(args.method, "post") self.assertEqual(args.data, '{"name": "test"}') - def test_call_with_headers(self): - parser = create_parser() + def test_kog_with_headers(self): + parser = create_kog_parser() args = parser.parse_args( [ - "call", + "get", "https://app.kognic.com/v1/projects", "-H", "Accept: application/json", @@ -237,15 +237,15 @@ def test_call_with_headers(self): ) self.assertEqual(args.headers, ["Accept: application/json", "X-Custom: value"]) - def test_call_with_context(self): - parser = create_parser() - args = parser.parse_args(["call", "https://demo.kognic.com/v1/projects", "--env", "demo"]) + def test_kog_with_env(self): + parser = create_kog_parser() + args = parser.parse_args(["get", "https://demo.kognic.com/v1/projects", "--env", "demo"]) self.assertEqual(args.env_name, "demo") - def test_call_with_config(self): - parser = create_parser() + def test_kog_with_config(self): + parser = create_kog_parser() args = parser.parse_args( - ["call", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/custom/config.json"] + ["get", "https://app.kognic.com/v1/projects", "--env-config-file-path", "/custom/config.json"] ) self.assertEqual(args.env_config_file_path, "/custom/config.json") @@ -253,17 +253,15 @@ def test_call_with_config(self): class CallApiTest(unittest.TestCase): def _make_parsed( self, - method="GET", + method="get", url="https://app.kognic.com/v1/projects", data=None, headers=None, env_config_file_path="/nonexistent/config.json", env_name=None, ): - parser = create_parser() - args = ["call", url] - if method != "GET": - args.extend(["-X", method]) + parser = create_kog_parser() + args = [method, url] if data: args.extend(["-d", data]) if headers: @@ -274,9 +272,9 @@ def _make_parsed( args.extend(["--env", env_name]) return parser.parse_args(args) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -290,7 +288,7 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"projects": []} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -298,7 +296,7 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r result = call_run(parsed) self.assertEqual(result, 0) - mock_session.session.request.assert_called_once_with( + mock_session.request.assert_called_once_with( method="GET", url="https://app.kognic.com/v1/projects", json=None, @@ -306,9 +304,9 @@ def test_call_api_get_success(self, mock_session_class, mock_load_config, mock_r ) mock_print.assert_called_once_with(json.dumps({"projects": []}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_post_with_data(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -322,24 +320,24 @@ def test_call_api_post_with_data(self, mock_session_class, mock_load_config, moc mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"id": 1} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session - parsed = self._make_parsed(method="POST", data='{"name": "test"}') + parsed = self._make_parsed(method="post", data='{"name": "test"}') with mock.patch("builtins.print"): result = call_run(parsed) self.assertEqual(result, 0) - mock_session.session.request.assert_called_once_with( + mock_session.request.assert_called_once_with( method="POST", url="https://app.kognic.com/v1/projects", json={"name": "test"}, headers={"Content-Type": "application/json"}, ) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -353,7 +351,7 @@ def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config mock_response.ok = True mock_response.headers = {"Content-Type": "text/plain"} mock_response.text = "OK" - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed(headers=["Accept: text/plain", "X-Custom: value"]) @@ -361,16 +359,16 @@ def test_call_api_with_custom_headers(self, mock_session_class, mock_load_config result = call_run(parsed) self.assertEqual(result, 0) - mock_session.session.request.assert_called_once_with( + mock_session.request.assert_called_once_with( method="GET", url="https://app.kognic.com/v1/projects", json=None, headers={"Accept": "text/plain", "X-Custom": "value"}, ) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -384,7 +382,7 @@ def test_call_api_error_status(self, mock_session_class, mock_load_config, mock_ mock_response.ok = False mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"error": "not found"} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -411,9 +409,9 @@ def test_call_api_invalid_header_format(self): error_output = mock_print.call_args[0][0] self.assertIn("Invalid header format", error_output) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_plain_text_response(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -427,7 +425,7 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config mock_response.ok = True mock_response.headers = {"Content-Type": "text/plain"} mock_response.text = "Hello World" - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -437,9 +435,9 @@ def test_call_api_plain_text_response(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with("Hello World") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -453,7 +451,7 @@ def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, m mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -466,9 +464,9 @@ def test_call_api_jsonl_data_array(self, mock_session_class, mock_load_config, m mock_print.assert_any_call(json.dumps({"id": 1, "name": "a"})) mock_print.assert_any_call(json.dumps({"id": 2, "name": "b"})) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response has a single key holding a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() @@ -483,7 +481,7 @@ def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_ mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"projects": [{"id": 1}, {"id": 2}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -496,9 +494,9 @@ def test_call_api_jsonl_single_key_non_data(self, mock_session_class, mock_load_ mock_print.assert_any_call(json.dumps({"id": 1})) mock_print.assert_any_call(json.dumps({"id": 2})) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used but response has multiple keys, pretty-print as usual.""" mock_load_config.return_value = mock.MagicMock() @@ -513,7 +511,7 @@ def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1}], "total": 1} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -524,9 +522,9 @@ def test_call_api_jsonl_multiple_keys(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with(json.dumps({"data": [{"id": 1}], "total": 1}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and response body is a list, flatten it.""" mock_load_config.return_value = mock.MagicMock() @@ -541,7 +539,7 @@ def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_confi mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = [{"id": 1}, {"id": 2}, {"id": 3}] - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -555,9 +553,9 @@ def test_call_api_jsonl_top_level_list(self, mock_session_class, mock_load_confi mock_print.assert_any_call(json.dumps({"id": 2})) mock_print.assert_any_call(json.dumps({"id": 3})) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """When --format=jsonl is used and data is an empty list, nothing is printed.""" mock_load_config.return_value = mock.MagicMock() @@ -572,7 +570,7 @@ def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, m mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -583,9 +581,9 @@ def test_call_api_jsonl_empty_data(self, mock_session_class, mock_load_config, m self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -599,7 +597,7 @@ def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, moc mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -614,9 +612,9 @@ def test_call_api_csv_data_array(self, mock_session_class, mock_load_config, moc self.assertEqual(lines[1], "1,a") self.assertEqual(lines[2], "2,b") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -630,7 +628,7 @@ def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, moc mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -645,9 +643,9 @@ def test_call_api_tsv_data_array(self, mock_session_class, mock_load_config, moc self.assertEqual(lines[1], "1\ta") self.assertEqual(lines[2], "2\tb") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_data_array(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -661,7 +659,7 @@ def test_call_api_table_data_array(self, mock_session_class, mock_load_config, m mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "alice"}, {"id": 2, "name": "b"}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -676,9 +674,9 @@ def test_call_api_table_data_array(self, mock_session_class, mock_load_config, m self.assertEqual(lines[2], "| 1 | alice |") self.assertEqual(lines[3], "| 2 | b |") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """Table with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() @@ -693,7 +691,7 @@ def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, m mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -704,9 +702,9 @@ def test_call_api_table_empty_data(self, mock_session_class, mock_load_config, m self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in CSV output.""" mock_load_config.return_value = mock.MagicMock() @@ -721,7 +719,7 @@ def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"], "meta": {"key": "val"}}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -735,9 +733,9 @@ def test_call_api_csv_nested_values(self, mock_session_class, mock_load_config, self.assertEqual(lines[0], "id,tags,meta") self.assertEqual(lines[1], '1,"[""a"", ""b""]","{""key"": ""val""}"') - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_table_nested_values(self, mock_session_class, mock_load_config, mock_resolve_environment): """Nested dicts and lists are JSON-serialized in table output.""" mock_load_config.return_value = mock.MagicMock() @@ -752,7 +750,7 @@ def test_call_api_table_nested_values(self, mock_session_class, mock_load_config mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "tags": ["a", "b"]}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -766,9 +764,9 @@ def test_call_api_table_nested_values(self, mock_session_class, mock_load_config self.assertEqual(lines[1], "|----|------------|") self.assertEqual(lines[2], '| 1 | ["a", "b"] |') - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -782,7 +780,7 @@ def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = [{"x": 10, "y": 20}, {"x": 30, "y": 40}] - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -797,9 +795,9 @@ def test_call_api_csv_top_level_list(self, mock_session_class, mock_load_config, self.assertEqual(lines[1], "10,20") self.assertEqual(lines[2], "30,40") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV output includes all keys across all rows, with blanks for missing values.""" mock_load_config.return_value = mock.MagicMock() @@ -814,7 +812,7 @@ def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mo mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": [{"id": 1, "name": "a"}, {"id": 2, "extra": "z"}]} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -829,9 +827,9 @@ def test_call_api_csv_sparse_keys(self, mock_session_class, mock_load_config, mo self.assertEqual(lines[1], "1,a,") self.assertEqual(lines[2], "2,,z") - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV with empty list prints nothing.""" mock_load_config.return_value = mock.MagicMock() @@ -846,7 +844,7 @@ def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, moc mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"data": []} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -857,9 +855,9 @@ def test_call_api_csv_empty_data(self, mock_session_class, mock_load_config, moc self.assertEqual(result, 0) mock_print.assert_not_called() - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") - @mock.patch("kognic.auth.cli.call.RequestsAuthSession") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.create_session") def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config, mock_resolve_environment): """CSV with non-flattenable response falls back to pretty JSON.""" mock_load_config.return_value = mock.MagicMock() @@ -874,7 +872,7 @@ def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config mock_response.ok = True mock_response.headers = {"Content-Type": "application/json"} mock_response.json.return_value = {"a": 1, "b": 2} - mock_session.session.request.return_value = mock_response + mock_session.request.return_value = mock_response mock_session_class.return_value = mock_session parsed = self._make_parsed() @@ -885,8 +883,8 @@ def test_call_api_csv_not_flattenable(self, mock_session_class, mock_load_config self.assertEqual(result, 0) mock_print.assert_called_once_with(json.dumps({"a": 1, "b": 2}, indent=2)) - @mock.patch("kognic.auth.cli.call.resolve_environment") - @mock.patch("kognic.auth.cli.call.load_kognic_env_config") + @mock.patch("kognic.auth.cli.api_request.resolve_environment") + @mock.patch("kognic.auth.cli.api_request.load_kognic_env_config") def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_environment): mock_load_config.return_value = mock.MagicMock() mock_resolve_environment.return_value = Environment( @@ -896,22 +894,22 @@ def test_call_api_uses_context_credentials(self, mock_load_config, mock_resolve_ credentials="/path/to/demo-creds.json", ) - with mock.patch("kognic.auth.cli.call.RequestsAuthSession") as mock_session_class: + with mock.patch("kognic.auth.cli.api_request.create_session") as mock_create_session: mock_session = mock.MagicMock() mock_response = mock.MagicMock() mock_response.ok = True mock_response.headers = {"Content-Type": "text/plain"} mock_response.text = "ok" - mock_session.session.request.return_value = mock_response - mock_session_class.return_value = mock_session + mock_session.request.return_value = mock_response + mock_create_session.return_value = mock_session parsed = self._make_parsed(url="https://demo.kognic.com/v1/projects") with mock.patch("builtins.print"): call_run(parsed) - mock_session_class.assert_called_once_with( + mock_create_session.assert_called_once_with( auth="/path/to/demo-creds.json", - host="https://auth.demo.kognic.com", + auth_host="https://auth.demo.kognic.com", ) From 4320f18ed401cc51725a133ae53d447d0c3ee7cb Mon Sep 17 00:00:00 2001 From: Michel Edkrantz Date: Fri, 13 Feb 2026 10:37:14 +0100 Subject: [PATCH 21/21] Use Self return type on base clients and validate CLI input before auth Co-Authored-By: Claude Opus 4.6 --- src/kognic/auth/cli/api_request.py | 6 +++--- src/kognic/auth/httpx/base_client.py | 25 +++++++++++++++---------- src/kognic/auth/requests/base_client.py | 9 +++++++-- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/kognic/auth/cli/api_request.py b/src/kognic/auth/cli/api_request.py index 883a7bc..38b2da4 100644 --- a/src/kognic/auth/cli/api_request.py +++ b/src/kognic/auth/cli/api_request.py @@ -156,6 +156,9 @@ def _print_response(response: Any, *, output_format: str = "json") -> None: def run(parsed: argparse.Namespace) -> int: try: + headers = _parse_headers(parsed.headers) or {} + data = _parse_body(parsed.data, headers) + config = load_kognic_env_config(parsed.env_config_file_path) env = resolve_environment(config, parsed.url, parsed.env_name) @@ -164,9 +167,6 @@ def run(parsed: argparse.Namespace) -> int: auth_host=env.auth_server, ) - headers = _parse_headers(parsed.headers) or {} - data = _parse_body(parsed.data, headers) - response = session.request( method=parsed.method.upper(), url=parsed.url, diff --git a/src/kognic/auth/httpx/base_client.py b/src/kognic/auth/httpx/base_client.py index e4e1f57..ec2a8d5 100644 --- a/src/kognic/auth/httpx/base_client.py +++ b/src/kognic/auth/httpx/base_client.py @@ -1,9 +1,14 @@ """Base async API client V2 using httpx/OAuth2 client.""" +from __future__ import annotations + import asyncio import logging import os -from typing import Any, Callable, Optional, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Union + +if TYPE_CHECKING: + from typing import Self import httpx @@ -121,6 +126,14 @@ async def call_with_simple_retry(attempts): self._oauth_client.request = request + async def __aenter__(self) -> Self: + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Async context manager exit.""" + await self.close() + @classmethod def from_env( cls, @@ -128,7 +141,7 @@ def from_env( *, env_config_path: Union[str, os.PathLike] = "", **kwargs, - ) -> "BaseAsyncApiClient": + ) -> Self: """Create a client from a named environment in the config file. Args: @@ -147,11 +160,3 @@ def from_env( kwargs.setdefault("auth", resolved.credentials) kwargs["auth_host"] = resolved.auth_server return cls(**kwargs) - - async def __aenter__(self) -> "BaseAsyncApiClient": - """Async context manager entry.""" - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - """Async context manager exit.""" - await self.close() diff --git a/src/kognic/auth/requests/base_client.py b/src/kognic/auth/requests/base_client.py index 2ab92fe..041147c 100644 --- a/src/kognic/auth/requests/base_client.py +++ b/src/kognic/auth/requests/base_client.py @@ -1,10 +1,15 @@ """Base API client V2 using requests/OAuth2 session.""" +from __future__ import annotations + import logging import os from functools import lru_cache from threading import Lock -from typing import Any, Callable, Optional, Tuple, Union +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple, Union + +if TYPE_CHECKING: + from typing import Self import requests from requests import Session @@ -208,7 +213,7 @@ def from_env( *, env_config_path: Union[str, os.PathLike] = "", **kwargs, - ) -> "BaseApiClient": + ) -> Self: """Create a client from a named environment in the config file. Args: