Skip to content

katana_public_api_client

katana_public_api_client

Katana Public API Client - Python client for Katana Manufacturing ERP.

Classes

APIError(message, status_code, error_response=None)

Bases: Exception

Base exception for API errors.

Parameters:

  • message (str) –

    Human-readable error message

  • status_code (int) –

    HTTP status code

  • error_response (ErrorResponse | DetailedErrorResponse | None, default: None ) –

    The error response object from the API

Source code in katana_public_api_client/utils.py
def __init__(
    self,
    message: str,
    status_code: int,
    error_response: ErrorResponse | DetailedErrorResponse | None = None,
):
    """Initialize API error.

    Args:
        message: Human-readable error message
        status_code: HTTP status code
        error_response: The error response object from the API
    """
    super().__init__(message)
    self.status_code = status_code
    self.error_response = error_response
Functions

AuthenticatedClient

A Client which has been authenticated for use on secured endpoints

The following are accepted as keyword arguments and will be used to construct httpx Clients internally:

``base_url``: The base URL for the API, all requests are made to a relative path to this URL

``cookies``: A dictionary of cookies to be sent with every request

``headers``: A dictionary of headers to be sent with every request

``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.

``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.

``follow_redirects``: Whether or not to follow redirects. Default value is False.

``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Functions
__aenter__() async

Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)

Source code in katana_public_api_client/client.py
async def __aenter__(self) -> "AuthenticatedClient":
    """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
    await self.get_async_httpx_client().__aenter__()
    return self
__aexit__(*args, **kwargs) async

Exit a context manager for underlying httpx.AsyncClient (see httpx docs)

Source code in katana_public_api_client/client.py
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
    """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
    await self.get_async_httpx_client().__aexit__(*args, **kwargs)
__enter__()

Enter a context manager for self.client—you cannot enter twice (see httpx docs)

Source code in katana_public_api_client/client.py
def __enter__(self) -> "AuthenticatedClient":
    """Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
    self.get_httpx_client().__enter__()
    return self
__exit__(*args, **kwargs)

Exit a context manager for internal httpx.Client (see httpx docs)

Source code in katana_public_api_client/client.py
def __exit__(self, *args: Any, **kwargs: Any) -> None:
    """Exit a context manager for internal httpx.Client (see httpx docs)"""
    self.get_httpx_client().__exit__(*args, **kwargs)
get_async_httpx_client()

Get the underlying httpx.AsyncClient, constructing a new one if not previously set

Source code in katana_public_api_client/client.py
def get_async_httpx_client(self) -> httpx.AsyncClient:
    """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
    if self._async_client is None:
        self._headers[self.auth_header_name] = (
            f"{self.prefix} {self.token}" if self.prefix else self.token
        )
        self._async_client = httpx.AsyncClient(
            base_url=self._base_url,
            cookies=self._cookies,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
            follow_redirects=self._follow_redirects,
            **self._httpx_args,
        )
    return self._async_client
get_httpx_client()

Get the underlying httpx.Client, constructing a new one if not previously set

Source code in katana_public_api_client/client.py
def get_httpx_client(self) -> httpx.Client:
    """Get the underlying httpx.Client, constructing a new one if not previously set"""
    if self._client is None:
        self._headers[self.auth_header_name] = (
            f"{self.prefix} {self.token}" if self.prefix else self.token
        )
        self._client = httpx.Client(
            base_url=self._base_url,
            cookies=self._cookies,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
            follow_redirects=self._follow_redirects,
            **self._httpx_args,
        )
    return self._client
set_async_httpx_client(async_client)

Manually the underlying httpx.AsyncClient

NOTE: This will override any other settings on the client, including cookies, headers, and timeout.

Source code in katana_public_api_client/client.py
def set_async_httpx_client(
    self, async_client: httpx.AsyncClient
) -> "AuthenticatedClient":
    """Manually the underlying httpx.AsyncClient

    **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
    """
    self._async_client = async_client
    return self
set_httpx_client(client)

Manually set the underlying httpx.Client

NOTE: This will override any other settings on the client, including cookies, headers, and timeout.

Source code in katana_public_api_client/client.py
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
    """Manually set the underlying httpx.Client

    **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
    """
    self._client = client
    return self
with_cookies(cookies)

Get a new client matching this one with additional cookies

Source code in katana_public_api_client/client.py
def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient":
    """Get a new client matching this one with additional cookies"""
    if self._client is not None:
        self._client.cookies.update(cookies)
    if self._async_client is not None:
        self._async_client.cookies.update(cookies)
    return evolve(self, cookies={**self._cookies, **cookies})
with_headers(headers)

Get a new client matching this one with additional headers

Source code in katana_public_api_client/client.py
def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient":
    """Get a new client matching this one with additional headers"""
    if self._client is not None:
        self._client.headers.update(headers)
    if self._async_client is not None:
        self._async_client.headers.update(headers)
    return evolve(self, headers={**self._headers, **headers})
with_timeout(timeout)

Get a new client matching this one with a new timeout (in seconds)

Source code in katana_public_api_client/client.py
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
    """Get a new client matching this one with a new timeout (in seconds)"""
    if self._client is not None:
        self._client.timeout = timeout
    if self._async_client is not None:
        self._async_client.timeout = timeout
    return evolve(self, timeout=timeout)

AuthenticationError(message, status_code, error_response=None)

Bases: APIError

Raised when authentication fails (401).

Source code in katana_public_api_client/utils.py
def __init__(
    self,
    message: str,
    status_code: int,
    error_response: ErrorResponse | DetailedErrorResponse | None = None,
):
    """Initialize API error.

    Args:
        message: Human-readable error message
        status_code: HTTP status code
        error_response: The error response object from the API
    """
    super().__init__(message)
    self.status_code = status_code
    self.error_response = error_response

Client

A class for keeping track of data related to the API

The following are accepted as keyword arguments and will be used to construct httpx Clients internally:

``base_url``: The base URL for the API, all requests are made to a relative path to this URL

``cookies``: A dictionary of cookies to be sent with every request

``headers``: A dictionary of headers to be sent with every request

``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.

``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.

``follow_redirects``: Whether or not to follow redirects. Default value is False.

``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Functions
__aenter__() async

Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)

Source code in katana_public_api_client/client.py
async def __aenter__(self) -> "Client":
    """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
    await self.get_async_httpx_client().__aenter__()
    return self
__aexit__(*args, **kwargs) async

Exit a context manager for underlying httpx.AsyncClient (see httpx docs)

Source code in katana_public_api_client/client.py
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
    """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
    await self.get_async_httpx_client().__aexit__(*args, **kwargs)
__enter__()

Enter a context manager for self.client—you cannot enter twice (see httpx docs)

Source code in katana_public_api_client/client.py
def __enter__(self) -> "Client":
    """Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
    self.get_httpx_client().__enter__()
    return self
__exit__(*args, **kwargs)

Exit a context manager for internal httpx.Client (see httpx docs)

Source code in katana_public_api_client/client.py
def __exit__(self, *args: Any, **kwargs: Any) -> None:
    """Exit a context manager for internal httpx.Client (see httpx docs)"""
    self.get_httpx_client().__exit__(*args, **kwargs)
get_async_httpx_client()

Get the underlying httpx.AsyncClient, constructing a new one if not previously set

Source code in katana_public_api_client/client.py
def get_async_httpx_client(self) -> httpx.AsyncClient:
    """Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
    if self._async_client is None:
        self._async_client = httpx.AsyncClient(
            base_url=self._base_url,
            cookies=self._cookies,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
            follow_redirects=self._follow_redirects,
            **self._httpx_args,
        )
    return self._async_client
get_httpx_client()

Get the underlying httpx.Client, constructing a new one if not previously set

Source code in katana_public_api_client/client.py
def get_httpx_client(self) -> httpx.Client:
    """Get the underlying httpx.Client, constructing a new one if not previously set"""
    if self._client is None:
        self._client = httpx.Client(
            base_url=self._base_url,
            cookies=self._cookies,
            headers=self._headers,
            timeout=self._timeout,
            verify=self._verify_ssl,
            follow_redirects=self._follow_redirects,
            **self._httpx_args,
        )
    return self._client
set_async_httpx_client(async_client)

Manually the underlying httpx.AsyncClient

NOTE: This will override any other settings on the client, including cookies, headers, and timeout.

Source code in katana_public_api_client/client.py
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
    """Manually the underlying httpx.AsyncClient

    **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
    """
    self._async_client = async_client
    return self
set_httpx_client(client)

Manually set the underlying httpx.Client

NOTE: This will override any other settings on the client, including cookies, headers, and timeout.

Source code in katana_public_api_client/client.py
def set_httpx_client(self, client: httpx.Client) -> "Client":
    """Manually set the underlying httpx.Client

    **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
    """
    self._client = client
    return self
with_cookies(cookies)

Get a new client matching this one with additional cookies

Source code in katana_public_api_client/client.py
def with_cookies(self, cookies: dict[str, str]) -> "Client":
    """Get a new client matching this one with additional cookies"""
    if self._client is not None:
        self._client.cookies.update(cookies)
    if self._async_client is not None:
        self._async_client.cookies.update(cookies)
    return evolve(self, cookies={**self._cookies, **cookies})
with_headers(headers)

Get a new client matching this one with additional headers

Source code in katana_public_api_client/client.py
def with_headers(self, headers: dict[str, str]) -> "Client":
    """Get a new client matching this one with additional headers"""
    if self._client is not None:
        self._client.headers.update(headers)
    if self._async_client is not None:
        self._async_client.headers.update(headers)
    return evolve(self, headers={**self._headers, **headers})
with_timeout(timeout)

Get a new client matching this one with a new timeout (in seconds)

Source code in katana_public_api_client/client.py
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
    """Get a new client matching this one with a new timeout (in seconds)"""
    if self._client is not None:
        self._client.timeout = timeout
    if self._async_client is not None:
        self._async_client.timeout = timeout
    return evolve(self, timeout=timeout)

KatanaClient(api_key=None, base_url=None, timeout=30.0, max_retries=5, max_pages=100, logger=None, **httpx_kwargs)

Bases: AuthenticatedClient

The pythonic Katana API client with automatic resilience and pagination.

This client inherits from AuthenticatedClient and can be passed directly to generated API methods without needing the .client property.

Features: - Automatic retries on network errors and server errors (5xx) - Automatic rate limit handling with Retry-After header support - Smart auto-pagination that detects and handles paginated responses automatically - Rich logging and observability - Minimal configuration - just works out of the box

Usage
Auto-pagination happens automatically - just call the API

async with KatanaClient() as client: from katana_public_api_client.api.product import get_all_products

# This automatically collects all pages if pagination is detected
response = await get_all_products.asyncio_detailed(
    client=client,  # Pass client directly - no .client needed!
    limit=50  # All pages collected automatically
)

# Get specific page only (add page=X to disable auto-pagination)
response = await get_all_products.asyncio_detailed(
    client=client,
    page=1,      # Get specific page
    limit=100    # Set page size
)

# Control max pages globally
client_limited = KatanaClient(max_pages=5)  # Limit to 5 pages max

Parameters:

  • api_key (str | None, default: None ) –

    Katana API key. If None, will try to load from KATANA_API_KEY env var, .env file, or ~/.netrc file (in that order).

  • base_url (str | None, default: None ) –

    Base URL for the Katana API. Defaults to https://api.katanamrp.com/v1

  • timeout (float, default: 30.0 ) –

    Request timeout in seconds. Defaults to 30.0.

  • max_retries (int, default: 5 ) –

    Maximum number of retry attempts for failed requests. Defaults to 5.

  • max_pages (int, default: 100 ) –

    Maximum number of pages to collect during auto-pagination. Defaults to 100.

  • logger (Logger | None, default: None ) –

    Logger instance for capturing client operations. If None, creates a default logger.

  • **httpx_kwargs (Any, default: {} ) –

    Additional arguments passed to the base AsyncHTTPTransport. Common parameters include: - http2 (bool): Enable HTTP/2 support - limits (httpx.Limits): Connection pool limits - verify (bool | str | ssl.SSLContext): SSL certificate verification - cert (str | tuple): Client-side certificates - trust_env (bool): Trust environment variables for proxy configuration - event_hooks (dict): Custom event hooks (will be merged with built-in hooks)

Raises:

  • ValueError

    If no API key is provided via api_key param, KATANA_API_KEY env var, .env file, or ~/.netrc file.

Note

Transport-related parameters (http2, limits, verify, etc.) are correctly passed to the innermost AsyncHTTPTransport layer, ensuring they take effect even with the layered transport architecture.

Example

async with KatanaClient() as client: ... # All API calls through client get automatic resilience ... response = await some_api_method.asyncio_detailed(client=client)

Source code in katana_public_api_client/katana_client.py
def __init__(
    self,
    api_key: str | None = None,
    base_url: str | None = None,
    timeout: float = 30.0,
    max_retries: int = 5,
    max_pages: int = 100,
    logger: logging.Logger | None = None,
    **httpx_kwargs: Any,
):
    """
    Initialize the Katana API client with automatic resilience features.

    Args:
        api_key: Katana API key. If None, will try to load from KATANA_API_KEY env var,
            .env file, or ~/.netrc file (in that order).
        base_url: Base URL for the Katana API. Defaults to https://api.katanamrp.com/v1
        timeout: Request timeout in seconds. Defaults to 30.0.
        max_retries: Maximum number of retry attempts for failed requests. Defaults to 5.
        max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
        logger: Logger instance for capturing client operations. If None, creates a default logger.
        **httpx_kwargs: Additional arguments passed to the base AsyncHTTPTransport.
            Common parameters include:
            - http2 (bool): Enable HTTP/2 support
            - limits (httpx.Limits): Connection pool limits
            - verify (bool | str | ssl.SSLContext): SSL certificate verification
            - cert (str | tuple): Client-side certificates
            - trust_env (bool): Trust environment variables for proxy configuration
            - event_hooks (dict): Custom event hooks (will be merged with built-in hooks)

    Raises:
        ValueError: If no API key is provided via api_key param, KATANA_API_KEY env var,
            .env file, or ~/.netrc file.

    Note:
        Transport-related parameters (http2, limits, verify, etc.) are correctly
        passed to the innermost AsyncHTTPTransport layer, ensuring they take effect
        even with the layered transport architecture.

    Example:
        >>> async with KatanaClient() as client:
        ...     # All API calls through client get automatic resilience
        ...     response = await some_api_method.asyncio_detailed(client=client)
    """
    load_dotenv()

    # Handle backwards compatibility: accept 'token' kwarg as alias for 'api_key'
    if "token" in httpx_kwargs:
        if api_key is not None:
            raise ValueError("Cannot specify both 'api_key' and 'token' parameters")
        api_key = httpx_kwargs.pop("token")

    # Determine base_url early so we can use it for netrc lookup
    base_url = (
        base_url or os.getenv("KATANA_BASE_URL") or "https://api.katanamrp.com/v1"
    )

    # Setup credentials with priority: param > env (including .env) > netrc
    api_key = (
        api_key or os.getenv("KATANA_API_KEY") or self._read_from_netrc(base_url)
    )

    if not api_key:
        raise ValueError(
            "API key required via: api_key param, KATANA_API_KEY env var, "
            ".env file, or ~/.netrc"
        )

    self.logger = logger or logging.getLogger(__name__)
    self.max_pages = max_pages

    # Domain class instances (lazy-loaded)
    self._products: Products | None = None
    self._materials: Materials | None = None
    self._variants: Variants | None = None
    self._services: Services | None = None
    self._inventory: Inventory | None = None

    # Extract client-level parameters that shouldn't go to the transport
    # Event hooks for observability - start with our defaults
    event_hooks: dict[str, list[Callable[[httpx.Response], Awaitable[None]]]] = {
        "response": [
            self._capture_pagination_metadata,
            self._log_response_metrics,
        ]
    }

    # Extract and merge user hooks
    user_hooks = httpx_kwargs.pop("event_hooks", {})
    for event, hooks in user_hooks.items():
        # Normalize to list and add to existing or create new event
        hook_list = cast(
            list[Callable[[httpx.Response], Awaitable[None]]],
            hooks if isinstance(hooks, list) else [hooks],
        )
        if event in event_hooks:
            event_hooks[event].extend(hook_list)
        else:
            event_hooks[event] = hook_list

    # Check if user wants to override the transport entirely
    custom_transport = httpx_kwargs.pop("transport", None) or httpx_kwargs.pop(
        "async_transport", None
    )

    if custom_transport:
        # User provided a custom transport, use it as-is
        transport = custom_transport
    else:
        # Separate transport-specific kwargs from client-specific kwargs
        # Client-specific params that should NOT go to the transport
        client_only_params = ["headers", "cookies", "params", "auth"]
        client_kwargs = {
            k: httpx_kwargs.pop(k)
            for k in list(httpx_kwargs.keys())
            if k in client_only_params
        }

        # Create resilient transport with remaining transport-specific httpx_kwargs
        # These will be passed to the base AsyncHTTPTransport (http2, limits, verify, etc.)
        transport = ResilientAsyncTransport(
            max_retries=max_retries,
            max_pages=max_pages,
            logger=self.logger,
            **httpx_kwargs,  # Pass through http2, limits, verify, cert, trust_env, etc.
        )

        # Put client-specific params back into httpx_kwargs for the parent class
        httpx_kwargs.update(client_kwargs)

    # Initialize the parent AuthenticatedClient
    super().__init__(
        base_url=base_url,
        token=api_key,
        timeout=httpx.Timeout(timeout),
        httpx_args={
            "transport": transport,
            "event_hooks": event_hooks,
            **httpx_kwargs,  # Include any remaining client-level kwargs
        },
    )
Attributes
inventory property

Access inventory and stock operations.

Returns:

  • Inventory

    Inventory instance for stock levels, movements, and adjustments.

Example

async with KatanaClient() as client: ... # Check stock levels ... stock = await client.inventory.check_stock("WIDGET-001") ... low_stock = await client.inventory.list_low_stock(threshold=10)

materials property

Access material catalog operations.

Returns:

  • Materials

    Materials instance for material CRUD operations.

Example

async with KatanaClient() as client: ... materials = await client.materials.list() ... material = await client.materials.get(123)

products property

Access product catalog operations.

Returns:

  • Products

    Products instance for product CRUD and search operations.

Example

async with KatanaClient() as client: ... # Product CRUD ... products = await client.products.list(is_sellable=True) ... product = await client.products.get(123) ... results = await client.products.search("widget")

services property

Access service catalog operations.

Returns:

  • Services

    Services instance for service CRUD operations.

Example

async with KatanaClient() as client: ... services = await client.services.list() ... service = await client.services.get(123)

variants property

Access variant catalog operations.

Returns:

  • Variants

    Variants instance for variant CRUD operations.

Example

async with KatanaClient() as client: ... variants = await client.variants.list() ... variant = await client.variants.get(123)

Functions

RateLimitError(message, status_code, error_response=None)

Bases: APIError

Raised when rate limit is exceeded (429).

Source code in katana_public_api_client/utils.py
def __init__(
    self,
    message: str,
    status_code: int,
    error_response: ErrorResponse | DetailedErrorResponse | None = None,
):
    """Initialize API error.

    Args:
        message: Human-readable error message
        status_code: HTTP status code
        error_response: The error response object from the API
    """
    super().__init__(message)
    self.status_code = status_code
    self.error_response = error_response

ServerError(message, status_code, error_response=None)

Bases: APIError

Raised when server error occurs (5xx).

Source code in katana_public_api_client/utils.py
def __init__(
    self,
    message: str,
    status_code: int,
    error_response: ErrorResponse | DetailedErrorResponse | None = None,
):
    """Initialize API error.

    Args:
        message: Human-readable error message
        status_code: HTTP status code
        error_response: The error response object from the API
    """
    super().__init__(message)
    self.status_code = status_code
    self.error_response = error_response

ValidationError(message, status_code, error_response=None)

Bases: APIError

Raised when request validation fails (422).

Parameters:

  • message (str) –

    Human-readable error message

  • status_code (int) –

    HTTP status code (should be 422)

  • error_response (DetailedErrorResponse | None, default: None ) –

    The detailed error response with validation details

Source code in katana_public_api_client/utils.py
def __init__(
    self,
    message: str,
    status_code: int,
    error_response: DetailedErrorResponse | None = None,
):
    """Initialize validation error.

    Args:
        message: Human-readable error message
        status_code: HTTP status code (should be 422)
        error_response: The detailed error response with validation details
    """
    super().__init__(message, status_code, error_response)
    self.validation_errors = (
        error_response.details if error_response and error_response.details else []
    )
Functions
__str__()

Format validation error with code-specific details.

Source code in katana_public_api_client/utils.py
def __str__(self) -> str:
    """Format validation error with code-specific details."""
    msg = super().__str__()

    # Add code-specific details if present using type-safe isinstance checks
    if self.validation_errors:
        error_details = []
        for detail in self.validation_errors:
            field = detail.path.lstrip("/")

            # Use isinstance for type-safe error handling
            if isinstance(detail, EnumValidationError):
                error_details.append(
                    f"  Field '{field}' must be one of: {detail.allowed_values}"
                )

            elif isinstance(detail, MinValidationError):
                error_details.append(
                    f"  Field '{field}' must be >= {detail.minimum}"
                )

            elif isinstance(detail, MaxValidationError):
                error_details.append(
                    f"  Field '{field}' must be <= {detail.maximum}"
                )

            elif isinstance(detail, InvalidTypeValidationError):
                error_details.append(
                    f"  Field '{field}' must be of type: {detail.expected_type}"
                )

            elif isinstance(detail, TooSmallValidationError):
                if not isinstance(detail.min_length, Unset):
                    error_details.append(
                        f"  Field '{field}' must have minimum length: {detail.min_length}"
                    )
                elif not isinstance(detail.min_items, Unset):
                    error_details.append(
                        f"  Field '{field}' must have minimum items: {detail.min_items}"
                    )

            elif isinstance(detail, TooBigValidationError):
                if not isinstance(detail.max_length, Unset):
                    error_details.append(
                        f"  Field '{field}' must have maximum length: {detail.max_length}"
                    )
                elif not isinstance(detail.max_items, Unset):
                    error_details.append(
                        f"  Field '{field}' must have maximum items: {detail.max_items}"
                    )

            elif isinstance(detail, RequiredValidationError):
                error_details.append(
                    f"  Missing required field: '{detail.missing_property}'"
                )

            elif isinstance(detail, PatternValidationError):
                error_details.append(
                    f"  Field '{field}' must match pattern: {detail.pattern}"
                )

            elif isinstance(detail, UnrecognizedKeysValidationError):
                error_details.append(f"  Unrecognized fields: {detail.keys}")
                if not isinstance(detail.valid_keys, Unset):
                    error_details.append(f"  Valid fields: {detail.valid_keys}")

        if error_details:
            msg += "\n" + "\n".join(error_details)

    return msg

Functions

get_error_message(response)

Extract error message from an error response.

Parameters:

  • response (Response[T]) –

    The Response object (typically an error response)

Returns:

  • str | None

    Error message string, or None if no error message found

Example
response = await some_api_call.asyncio_detailed(client=client)
if is_error(response):
    error_msg = get_error_message(response)
    print(f"API Error: {error_msg}")
Source code in katana_public_api_client/utils.py
def get_error_message[T](response: Response[T]) -> str | None:
    """Extract error message from an error response.

    Args:
        response: The Response object (typically an error response)

    Returns:
        Error message string, or None if no error message found

    Example:
        ```python
        response = await some_api_call.asyncio_detailed(client=client)
        if is_error(response):
            error_msg = get_error_message(response)
            print(f"API Error: {error_msg}")
        ```
    """
    if response.parsed is None:
        return None

    if not isinstance(response.parsed, ErrorResponse | DetailedErrorResponse):
        return None

    # Type narrowing: at this point parsed is ErrorResponse | DetailedErrorResponse
    parsed_error = response.parsed

    error_message = (
        parsed_error.message  # type: ignore[attr-defined]
        if not isinstance(parsed_error.message, Unset)  # type: ignore[attr-defined]
        else None
    )

    # Check nested error format
    if hasattr(parsed_error, "additional_properties"):
        nested = parsed_error.additional_properties  # type: ignore[attr-defined]
        if isinstance(nested, dict) and "error" in nested:
            nested_error = nested["error"]  # type: ignore[index]
            if isinstance(nested_error, dict):
                error_message = str(nested_error.get("message", error_message))  # type: ignore[arg-type]

    return error_message

get_variant_display_name(variant)

Build the full variant display name matching Katana UI format.

Format: "{Product/Material Name} / {Config Value 1} / {Config Value 2} / ..."

Example: "Mayhem 140 / Liquid Black / Large / 5 Star"

When the variant has been fetched with extend=product_or_material, the API returns variants with a nested product_or_material object (Product or Material). This function extracts the base product/material name and appends config attribute values separated by " / ".

Parameters:

  • variant (Variant) –

    Variant object (ideally with product_or_material populated)

Returns:

  • str

    Formatted variant name with config values, or empty string if no name available

Example
from katana_public_api_client import KatanaClient
from katana_public_api_client.api.variant import get_variant
from katana_public_api_client.utils import get_variant_display_name

async with KatanaClient() as client:
    response = await get_variant.asyncio_detailed(client=client, id=123)
    variant = unwrap(response)
    display_name = get_variant_display_name(variant)
    print(display_name)  # "Mayhem 140 / Liquid Black / Large / 5 Star"
Source code in katana_public_api_client/utils.py
def get_variant_display_name(variant: "Variant") -> str:
    """Build the full variant display name matching Katana UI format.

    Format: "{Product/Material Name} / {Config Value 1} / {Config Value 2} / ..."

    Example: "Mayhem 140 / Liquid Black / Large / 5 Star"

    When the variant has been fetched with extend=product_or_material, the API
    returns variants with a nested product_or_material object (Product or Material).
    This function extracts the base product/material name and appends config attribute
    values separated by " / ".

    Args:
        variant: Variant object (ideally with product_or_material populated)

    Returns:
        Formatted variant name with config values, or empty string if no name available

    Example:
        ```python
        from katana_public_api_client import KatanaClient
        from katana_public_api_client.api.variant import get_variant
        from katana_public_api_client.utils import get_variant_display_name

        async with KatanaClient() as client:
            response = await get_variant.asyncio_detailed(client=client, id=123)
            variant = unwrap(response)
            display_name = get_variant_display_name(variant)
            print(display_name)  # "Mayhem 140 / Liquid Black / Large / 5 Star"
        ```
    """
    # Get base product/material name
    base_name = ""
    if hasattr(variant, "product_or_material") and variant.product_or_material:
        product_or_material = variant.product_or_material
        if hasattr(product_or_material, "name"):
            base_name = product_or_material.name or ""

    if not base_name:
        return ""

    # Append config attribute values (just values, not "name: value")
    parts = [base_name]
    if hasattr(variant, "config_attributes") and variant.config_attributes:
        for attr in variant.config_attributes:
            if hasattr(attr, "config_value") and attr.config_value:
                parts.append(str(attr.config_value))  # type: ignore[arg-type]

    # Join with forward slashes (Katana UI format)
    return " / ".join(parts)  # type: ignore[arg-type]

handle_response(response, *, on_success=None, on_error=None, raise_on_error=False)

Handle a response with custom success and error handlers.

This function provides a convenient way to handle both success and error cases with custom callbacks.

Parameters:

  • response (Response[T]) –

    The Response object from an API call

  • on_success (Callable[[T], Any] | None, default: None ) –

    Callback function to call with parsed data on success

  • on_error (Callable[[APIError], Any] | None, default: None ) –

    Callback function to call with APIError on error

  • raise_on_error (bool, default: False ) –

    If True, raise the error even if on_error is provided

Returns:

  • Any

    Result of on_success callback, result of on_error callback, or None

Example
def handle_products(product_list):
    print(f"Got {len(product_list.data)} products")
    return product_list.data


def handle_error(error):
    print(f"Error: {error}")
    return []


response = await get_all_products.asyncio_detailed(client=client)
products = handle_response(
    response, on_success=handle_products, on_error=handle_error
)
Source code in katana_public_api_client/utils.py
def handle_response(
    response: Response[T],
    *,
    on_success: Callable[[T], Any] | None = None,
    on_error: Callable[[APIError], Any] | None = None,
    raise_on_error: bool = False,
) -> Any:
    """Handle a response with custom success and error handlers.

    This function provides a convenient way to handle both success and error
    cases with custom callbacks.

    Args:
        response: The Response object from an API call
        on_success: Callback function to call with parsed data on success
        on_error: Callback function to call with APIError on error
        raise_on_error: If True, raise the error even if on_error is provided

    Returns:
        Result of on_success callback, result of on_error callback, or None

    Example:
        ```python
        def handle_products(product_list):
            print(f"Got {len(product_list.data)} products")
            return product_list.data


        def handle_error(error):
            print(f"Error: {error}")
            return []


        response = await get_all_products.asyncio_detailed(client=client)
        products = handle_response(
            response, on_success=handle_products, on_error=handle_error
        )
        ```
    """
    try:
        data = unwrap(response, raise_on_error=True)
        if on_success:
            return on_success(data)
        return data
    except APIError as e:
        if raise_on_error:
            raise
        if on_error:
            return on_error(e)
        return None

is_error(response)

Check if a response was an error (4xx or 5xx status code).

Parameters:

  • response (Response[Any]) –

    The Response object to check

Returns:

  • bool

    True if status code is 4xx or 5xx, False otherwise

Source code in katana_public_api_client/utils.py
def is_error(response: Response[Any]) -> bool:
    """Check if a response was an error (4xx or 5xx status code).

    Args:
        response: The Response object to check

    Returns:
        True if status code is 4xx or 5xx, False otherwise
    """
    return response.status_code >= 400

is_success(response)

Check if a response was successful (2xx status code).

Parameters:

  • response (Response[Any]) –

    The Response object to check

Returns:

  • bool

    True if status code is 2xx, False otherwise

Example
response = await some_api_call.asyncio_detailed(client=client)
if is_success(response):
    data = unwrap_data(response)
else:
    print(f"Error: {response.status_code}")
Source code in katana_public_api_client/utils.py
def is_success(response: Response[Any]) -> bool:
    """Check if a response was successful (2xx status code).

    Args:
        response: The Response object to check

    Returns:
        True if status code is 2xx, False otherwise

    Example:
        ```python
        response = await some_api_call.asyncio_detailed(client=client)
        if is_success(response):
            data = unwrap_data(response)
        else:
            print(f"Error: {response.status_code}")
        ```
    """
    return 200 <= response.status_code < 300

unwrap(response, *, raise_on_error=True)

unwrap(
    response: Response[T], *, raise_on_error: bool = True
) -> T
unwrap(
    response: Response[T], *, raise_on_error: bool = False
) -> T | None

Unwrap a Response object and return the parsed data or raise an error.

This is the main utility function for handling API responses. It automatically raises appropriate exceptions for error responses and returns the parsed data for successful responses.

Parameters:

  • response (Response[T]) –

    The Response object from an API call

  • raise_on_error (bool, default: True ) –

    If True, raise exceptions on error status codes. If False, return None on errors.

Returns:

  • T | None

    The parsed response data

Raises:

Example
from katana_public_api_client import KatanaClient
from katana_public_api_client.api.product import get_all_products
from katana_public_api_client.utils import unwrap

async with KatanaClient() as client:
    response = await get_all_products.asyncio_detailed(client=client)
    product_list = unwrap(
        response
    )  # Raises on error, returns parsed data
    products = product_list.data  # List of Product objects
Source code in katana_public_api_client/utils.py
def unwrap[T](
    response: Response[T],
    *,
    raise_on_error: bool = True,
) -> T | None:
    """Unwrap a Response object and return the parsed data or raise an error.

    This is the main utility function for handling API responses. It automatically
    raises appropriate exceptions for error responses and returns the parsed data
    for successful responses.

    Args:
        response: The Response object from an API call
        raise_on_error: If True, raise exceptions on error status codes.
                        If False, return None on errors.

    Returns:
        The parsed response data

    Raises:
        AuthenticationError: When status is 401
        ValidationError: When status is 422
        RateLimitError: When status is 429
        ServerError: When status is 5xx
        APIError: For other error status codes

    Example:
        ```python
        from katana_public_api_client import KatanaClient
        from katana_public_api_client.api.product import get_all_products
        from katana_public_api_client.utils import unwrap

        async with KatanaClient() as client:
            response = await get_all_products.asyncio_detailed(client=client)
            product_list = unwrap(
                response
            )  # Raises on error, returns parsed data
            products = product_list.data  # List of Product objects
        ```
    """
    if response.parsed is None:
        if raise_on_error:
            raise APIError(
                f"No parsed response data for status {response.status_code}",
                response.status_code,
            )
        return None

    # Check if it's an error response
    if isinstance(response.parsed, ErrorResponse | DetailedErrorResponse):
        if not raise_on_error:
            return None

        # Type narrowing: at this point parsed is ErrorResponse | DetailedErrorResponse
        parsed_error = response.parsed

        error_name = (
            parsed_error.name  # type: ignore[attr-defined]
            if not isinstance(parsed_error.name, Unset)  # type: ignore[attr-defined]
            else "Unknown"
        )
        error_message = (
            parsed_error.message  # type: ignore[attr-defined]
            if not isinstance(parsed_error.message, Unset)  # type: ignore[attr-defined]
            else "No error message provided"
        )

        # Handle nested error format
        if hasattr(parsed_error, "additional_properties"):
            nested = parsed_error.additional_properties  # type: ignore[attr-defined]
            if isinstance(nested, dict) and "error" in nested:
                nested_error = nested["error"]  # type: ignore[index]
                if isinstance(nested_error, dict):
                    error_name = str(nested_error.get("name", error_name))  # type: ignore[arg-type]
                    error_message = str(nested_error.get("message", error_message))  # type: ignore[arg-type]

        message = f"{error_name}: {error_message}"

        if response.status_code == HTTPStatus.UNAUTHORIZED:
            raise AuthenticationError(message, response.status_code, parsed_error)  # type: ignore[arg-type]
        elif response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY:
            # ValidationError expects DetailedErrorResponse, but parsed_error could be ErrorResponse
            detailed_error = (
                parsed_error
                if isinstance(parsed_error, DetailedErrorResponse)
                else None
            )
            raise ValidationError(
                message,
                response.status_code,
                detailed_error,
            )
        elif response.status_code == HTTPStatus.TOO_MANY_REQUESTS:
            raise RateLimitError(message, response.status_code, parsed_error)  # type: ignore[arg-type]
        elif 500 <= response.status_code < 600:
            raise ServerError(message, response.status_code, parsed_error)  # type: ignore[arg-type]
        else:
            raise APIError(message, response.status_code, parsed_error)  # type: ignore[arg-type]

    return response.parsed

unwrap_data(response, *, raise_on_error=True, default=None)

unwrap_data(
    response: Response[T],
    *,
    raise_on_error: bool = True,
    default: None = None,
) -> Any
unwrap_data(
    response: Response[T],
    *,
    raise_on_error: bool = False,
    default: None = None,
) -> Any | None
unwrap_data(
    response: Response[T],
    *,
    raise_on_error: bool = False,
    default: list[DataT],
) -> Any

Unwrap a Response and extract the data list from list responses.

This is a convenience function that unwraps the response and extracts the .data field from list response objects (like ProductListResponse, WebhookListResponse, etc.).

Parameters:

  • response (Response[T]) –

    The Response object from an API call

  • raise_on_error (bool, default: True ) –

    If True, raise exceptions on error status codes. If False, return default on errors.

  • default (list[DataT] | None, default: None ) –

    Default value to return if data is not available

Returns:

  • Any | None

    List of data objects, or default if not available

Example
from katana_public_api_client import KatanaClient
from katana_public_api_client.api.product import get_all_products
from katana_public_api_client.utils import unwrap_data

async with KatanaClient() as client:
    response = await get_all_products.asyncio_detailed(client=client)
    products = unwrap_data(response)  # Directly get list of Products
    for product in products:
        print(product.name)
Source code in katana_public_api_client/utils.py
def unwrap_data[T, DataT](
    response: Response[T],
    *,
    raise_on_error: bool = True,
    default: list[DataT] | None = None,
) -> Any | None:
    """Unwrap a Response and extract the data list from list responses.

    This is a convenience function that unwraps the response and extracts
    the `.data` field from list response objects (like ProductListResponse,
    WebhookListResponse, etc.).

    Args:
        response: The Response object from an API call
        raise_on_error: If True, raise exceptions on error status codes.
                        If False, return default on errors.
        default: Default value to return if data is not available

    Returns:
        List of data objects, or default if not available

    Raises:
        Same exceptions as unwrap()

    Example:
        ```python
        from katana_public_api_client import KatanaClient
        from katana_public_api_client.api.product import get_all_products
        from katana_public_api_client.utils import unwrap_data

        async with KatanaClient() as client:
            response = await get_all_products.asyncio_detailed(client=client)
            products = unwrap_data(response)  # Directly get list of Products
            for product in products:
                print(product.name)
        ```
    """
    try:
        parsed = unwrap(response, raise_on_error=raise_on_error)
    except APIError:
        if raise_on_error:
            raise
        return default

    if parsed is None:
        return default

    # Extract data field if it exists
    data = getattr(parsed, "data", None)
    if isinstance(data, Unset):
        return default if default is not None else []
    if data is not None:
        return data

    # If there's no data field and no default, wrap the object in a list
    if default is not None:
        return default

    # If it's not a list response, return it as a single-item list
    return [parsed]