Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion codegen/layouts/partials/resource-dataclass.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
{{../memberIndent}}{{pythonIdentifier name}}: {{type}}
{{/each}}

{{memberIndent}}# The payload is decoded JSON, so every value read out of it is untyped.
{{memberIndent}}# Typing d as Any keeps that at this boundary instead of casting each
{{memberIndent}}# read, and the dataclass fields carry the real types.
{{memberIndent}}@classmethod
{{memberIndent}}def from_dict(cls, d: Dict[str, Any]):
{{memberIndent}}def from_dict(cls, d: Any):
{{#unless properties}}
{{memberIndent}} # This shape documents no properties, so there is nothing to read.
{{memberIndent}} # pylint: disable=unused-argument
Expand Down
2 changes: 1 addition & 1 deletion codegen/layouts/partials/route-method.hbs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
def {{> method-signature}}:
"""{{> method-docstring}}"""
json_payload = {}
json_payload: Dict[str, Any] = {}

{{#each params}}
if {{name}} is not None:
Expand Down
32 changes: 27 additions & 5 deletions codegen/lib/layouts/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import type { Blueprint, Property } from '@seamapi/blueprint'
import { pascalCase, snakeCase } from 'change-case'

import { convertCustomResourceName } from '../custom-resource-name-conversions.js'
import { mapPropertyToPythonType } from '../python-type.js'
import {
mapPropertyToPythonType,
mapRequiredPropertyToPythonType,
} from '../python-type.js'

export interface ResourceLayoutContext extends ResourceClassLayoutContext {
moduleName: string
Expand Down Expand Up @@ -144,6 +147,9 @@ const mergeOccurrences = (occurrences: Property[], path: string): Property => {
return { ...first, ...docs }
}

const withOptionality = (property: Property, isOptional: boolean): Property =>
isOptional ? { ...property, isOptional: true } : property

const mergePropertyLists = (
propertyLists: Property[][],
path = '',
Expand All @@ -161,7 +167,13 @@ const mergePropertyLists = (
}

return [...occurrences.entries()].map(([name, group]) =>
mergeOccurrences(group, path === '' ? name : `${path}.${name}`),
// A property only some variants carry is absent whenever the merged
// dataclass holds one of the variants that omits it, so it is optional on
// the merged shape no matter how each variant declares it.
withOptionality(
mergeOccurrences(group, path === '' ? name : `${path}.${name}`),
group.length < propertyLists.length,
),
)
}

Expand Down Expand Up @@ -249,7 +261,17 @@ const buildClass = (
)
}

const type = mapPropertyToPythonType(property, nestedClassName)
const isObject = nestedClassName != null && property.format === 'object'
// A nested object is read as None whenever the payload omits it, and the
// schema is not a reliable guide to when that happens: an action attempt
// documents both error and result as required, yet a pending one carries
// neither. Constructing them unconditionally would fail on those payloads,
// so from_dict keeps its None fallback and the field stays Optional.
const type = mapPropertyToPythonType(property, nestedClassName, isObject)
const requiredType = mapRequiredPropertyToPythonType(
property,
nestedClassName,
)
return {
name: property.name,
description: property.description,
Expand All @@ -259,8 +281,8 @@ const buildClass = (
// Nested classes are attributes of the class that owns them, so
// from_dict reaches them through cls rather than a qualified path.
nestedClassName: nestedClassName ?? '',
isDictParam: type.startsWith('Dict'),
isObject: nestedClassName != null && property.format === 'object',
isDictParam: requiredType.startsWith('Dict'),
isObject,
isObjectList: nestedClassName != null && property.format === 'list',
}
})
Expand Down
16 changes: 16 additions & 0 deletions codegen/lib/python-type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,25 @@ export const mapParameterToPythonType = (parameter: Parameter): string => {
return mapScalarFormatToPythonType(parameter.format)
}

// from_dict reads every property with dict.get, so a property the API may omit
// or send as null arrives as None. Declaring those fields Optional keeps the
// dataclass honest about what a caller can actually find on it.
export const mapPropertyToPythonType = (
property: Property,
nestedClassName?: string,
isOptional = false,
): string => {
const type = mapRequiredPropertyToPythonType(property, nestedClassName)
return isOptional || property.isOptional || property.isNullable
? `Optional[${type}]`
: type
}

// The type a property has before optionality is taken into account. Callers
// that match on the shape of the type, rather than render it, want this one.
export const mapRequiredPropertyToPythonType = (
property: Property,
nestedClassName?: string,
): string => {
if (property.format === 'list') {
return `List[${
Expand Down
2 changes: 1 addition & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ default: build
uv run pylint ./seam ./test
uv run black --check .
uv run rstcheck README.rst
uv run mypy seam/resources --disable-error-code=arg-type --disable-error-code=import-not-found
uv run mypy seam test

@test:
uv run pytest --cov=./seam
Expand Down
1 change: 0 additions & 1 deletion seam/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# flake8: noqa
# type: ignore

from .seam import Seam
from .seam_without_workspace import SeamWithoutWorkspace
Expand Down
7 changes: 5 additions & 2 deletions seam/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,18 @@ def get_auth_headers(
api_key=api_key,
personal_access_token=personal_access_token,
):
return get_auth_headers_for_api_key(api_key)
# The guard returns True only for a non-None api_key, which is not
# something the type checker can see through the call.
return get_auth_headers_for_api_key(api_key) # type: ignore[arg-type]

if is_seam_options_with_personal_access_token(
personal_access_token=personal_access_token,
api_key=api_key,
workspace_id=workspace_id,
):
# Likewise, the guard raises unless both of these are set.
return get_auth_headers_for_personal_access_token(
personal_access_token, workspace_id
personal_access_token, workspace_id # type: ignore[arg-type]
)

raise SeamInvalidOptionsError(
Expand Down
30 changes: 24 additions & 6 deletions seam/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _handle_response(self, response: requests.Response):
raise NotImplementedError

@abc.abstractmethod
def _handle_error_response(self, response: requests.Response):
def _handle_error_response(self, response: requests.Response, status_code: int):
raise NotImplementedError


Expand Down Expand Up @@ -74,7 +74,19 @@ def __init__(
headers = {**auth_headers, **custom_headers, **SDK_HEADERS}
self.headers.update(headers)

def request(self, method, url, *args, **kwargs):
# request returns the decoded body rather than the Response that
# niquests.Session promises, so the verb helpers routed through it have to
# say so too. Without these overrides callers see the inherited Response
# type and indexing the returned payload does not type check.
def get(self, url, **kwargs) -> Any:
return self.request("GET", url, **kwargs)

# data and json are named rather than collected into *args because
# Session.request takes params in the position Session.post gives data.
def post(self, url, data=None, json=None, **kwargs) -> Any:
return self.request("POST", url, data=data, json=json, **kwargs)

def request(self, method, url, *args, **kwargs) -> Any:
url = urljoin(self.base_url, url)

if kwargs.get("timeout", NIQUESTS_TIMEOUT_DEFAULT) == NIQUESTS_TIMEOUT_DEFAULT:
Expand All @@ -85,16 +97,22 @@ def request(self, method, url, *args, **kwargs):
return self._handle_response(response)

def _handle_response(self, response: requests.Response):
if not 200 <= response.status_code < 300:
self._handle_error_response(response)
# niquests types status_code as optional because a Response exists
# before it has one. Anything reaching here has been received, so a
# missing status is an error the SDK cannot classify itself.
status_code = response.status_code

if status_code is None:
response.raise_for_status()
elif not 200 <= status_code < 300:
self._handle_error_response(response, status_code)

if "application/json" in response.headers.get("content-type", ""):
return response.json()

return response.text

def _handle_error_response(self, response: requests.Response):
status_code = response.status_code
def _handle_error_response(self, response: requests.Response, status_code: int):
request_id = response.headers.get("seam-request-id")

if status_code == 401:
Expand Down
48 changes: 32 additions & 16 deletions seam/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Any, Dict
from typing import Any, Dict, Optional
from .resources import ActionAttempt


Expand All @@ -15,20 +15,24 @@ class SeamHttpApiError(Exception):
:vartype code: str
:ivar status_code: The HTTP status code of the error response
:vartype status_code: int
:ivar request_id: The unique identifier for the API request
:vartype request_id: str
:ivar request_id: The unique identifier for the API request, when the
response carried one
:vartype request_id: Optional[str]
:ivar data: Additional error data, if provided by the API
:vartype data: Dict[str, Any]
"""

def __init__(self, error: Dict[str, Any], status_code: int, request_id: str):
def __init__(
self, error: Dict[str, Any], status_code: int, request_id: Optional[str]
):
"""
:param error: Dictionary containing error details from the API response
:type error: Dict[str, Any]
:param status_code: HTTP status code of the error response
:type status_code: int
:param request_id: Unique identifier for the API request
:type request_id: str
:param request_id: Unique identifier for the API request, when the
response carried one
:type request_id: Optional[str]
"""

super().__init__(error.get("message"))
Expand All @@ -45,10 +49,11 @@ class SeamHttpUnauthorizedError(SeamHttpApiError):
This exception is a specific type of SeamHttpApiError for 401 Unauthorized errors.
"""

def __init__(self, request_id: str):
def __init__(self, request_id: Optional[str]):
"""
:param request_id: Unique identifier for the API request
:type request_id: str
:param request_id: Unique identifier for the API request, when the
response carried one
:type request_id: Optional[str]
"""

super().__init__(
Expand All @@ -66,14 +71,17 @@ class SeamHttpInvalidInputError(SeamHttpApiError):
:vartype code: str
"""

def __init__(self, error: Dict[str, Any], status_code: int, request_id: str):
def __init__(
self, error: Dict[str, Any], status_code: int, request_id: Optional[str]
):
"""
:param error: Dictionary containing error details from the API response
:type error: Dict[str, Any]
:param status_code: HTTP status code of the error response
:type status_code: int
:param request_id: Unique identifier for the API request
:type request_id: str
:param request_id: Unique identifier for the API request, when the
response carried one
:type request_id: Optional[str]
"""

super().__init__(error, status_code, request_id)
Expand Down Expand Up @@ -120,9 +128,17 @@ def __init__(self, action_attempt: ActionAttempt):
:type action_attempt: ActionAttempt
"""

super().__init__(action_attempt.error.message, action_attempt)
# A failed action attempt carries an error, but reading through it
# unguarded would raise AttributeError over the actual failure if one
# ever arrives without it.
error = action_attempt.error

super().__init__(
error.message if error is not None else "Action attempt failed",
action_attempt,
)
self.name = self.__class__.__name__
self.code = action_attempt.error.type
self.code = error.type if error is not None else "unknown_error"


class SeamActionAttemptTimeoutError(SeamActionAttemptError):
Expand All @@ -136,12 +152,12 @@ class SeamActionAttemptTimeoutError(SeamActionAttemptError):
:vartype name: str
"""

def __init__(self, action_attempt: ActionAttempt, timeout: str):
def __init__(self, action_attempt: ActionAttempt, timeout: float):
"""
:param action_attempt: The ActionAttempt object associated with this error
:type action_attempt: ActionAttempt
:param timeout: The timeout duration in seconds
:type timeout: str
:type timeout: float
"""

message = f"Timed out waiting for action attempt after {timeout}s"
Expand Down
4 changes: 2 additions & 2 deletions seam/modules/action_attempts.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ def poll_until_ready(
client: SeamHttpClient,
*,
action_attempt_id: str,
timeout: Optional[float] = TIMEOUT,
polling_interval: Optional[float] = POLLING_INTERVAL,
timeout: float = TIMEOUT,
polling_interval: float = POLLING_INTERVAL,
) -> ActionAttempt:
time_waiting = 0.0

Expand Down
15 changes: 10 additions & 5 deletions seam/paginator.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Callable, Dict, Any, Tuple, Generator, List
from typing import Callable, Dict, Any, Optional, Tuple, Generator, List, Union
from .client import SeamHttpClient
from niquests import Response, JSONDecodeError
from niquests import PreparedRequest, Response, JSONDecodeError
from .pagination import Pagination


Expand All @@ -17,7 +17,7 @@ def __init__(
self,
client: SeamHttpClient,
request: Callable,
params: Dict[str, Any] = None,
params: Optional[Dict[str, Any]] = None,
):
"""
Initializes the Paginator.
Expand Down Expand Up @@ -74,7 +74,7 @@ def flatten_to_list(self) -> List[Any]:
if current_items:
all_items.extend(current_items)

while pagination.has_next_page:
while pagination and pagination.has_next_page and pagination.next_page_cursor:
current_items, pagination = self.next_page(pagination.next_page_cursor)
if current_items:
all_items.extend(current_items)
Expand All @@ -92,8 +92,13 @@ def flatten(self) -> Generator[Any, None, None]:
if current_items:
yield from current_items

def _cache_pagination(self, response: Response, page_key: str) -> None:
def _cache_pagination(
self, response: Union[PreparedRequest, Response], page_key: str
) -> None:
"""Extracts pagination dict from response, creates Pagination object, and caches it."""
if not isinstance(response, Response):
return

try:
response_json = response.json()
pagination = response_json.get("pagination", {})
Expand Down
Loading
Loading