Skip to content
Open
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: (github.event_name == 'push' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata')
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install Rye
run: |
Expand All @@ -46,7 +46,7 @@ jobs:
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install Rye
run: |
Expand All @@ -67,7 +67,7 @@ jobs:
github.repository == 'stainless-sdks/ade-python' &&
!startsWith(github.ref, 'refs/heads/stl/')
id: github-oidc
uses: actions/github-script@v8
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: core.setOutput('github_token', await core.getIDToken());

Expand All @@ -87,7 +87,7 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/ade-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
if: github.event_name == 'push' || github.event.pull_request.head.repo.fork
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install Rye
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install Rye
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release-doctor.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
if: github.repository == 'landing-ai/ade-python' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next')

steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Check release environment
run: |
Expand Down
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "1.12.0"
".": "1.13.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 9
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/landingai%2Fade-027fb87ba705ac701f9632cddf16c55874cc5fa02d6cc0759a6a8f39583ff1b7.yml
openapi_spec_hash: 1b0a0f468129939b1f96fa130c1c60e2
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/landingai/ade-d733e95dce6606899f1a6fd5a50d29e69e09460c6175551f2426119c9c1b6e39.yml
openapi_spec_hash: 9ac652238fd78f84d5515665eea04fe1
config_hash: d3c62c183c1d0f73f5d04504a415859b
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Changelog

## 1.13.0 (2026-05-29)

Full Changelog: [v1.12.0...v1.13.0](https://github.com/landing-ai/ade-python/compare/v1.12.0...v1.13.0)

### Features

* **api:** api update ([3216770](https://github.com/landing-ai/ade-python/commit/3216770f746525a87780e26d228a4f1f8e0c9403))
* **internal/types:** support eagerly validating pydantic iterators ([92a4a3a](https://github.com/landing-ai/ade-python/commit/92a4a3af2789b0189ad1e3f5efaf6ba83c7e0a36))
* smart save_to with full path support and bug fix ([#85](https://github.com/landing-ai/ade-python/issues/85)) ([7576d1f](https://github.com/landing-ai/ade-python/commit/7576d1f80c20744c0dbbb81ebf1b5f72323c3ec2))
* support setting headers via env ([a3a857d](https://github.com/landing-ai/ade-python/commit/a3a857d74244911f8f70937f1159d232b488b2f8))


### Bug Fixes

* **client:** add missing f-string prefix in file type error message ([3dde871](https://github.com/landing-ai/ade-python/commit/3dde871a50f9b541f2b12ef37b7c97ee1a9b23e3))
* use correct field name format for multipart file arrays ([8344ac4](https://github.com/landing-ai/ade-python/commit/8344ac463dd76eb2d36d7da43fc6e22d95886fdc))


### Chores

* **internal:** reformat pyproject.toml ([ecfd5e2](https://github.com/landing-ai/ade-python/commit/ecfd5e233952511a64c8277168682f7e4ed3dbd4))


### Documentation

* use theme-aware logo in README ([#89](https://github.com/landing-ai/ade-python/issues/89)) ([2f49d2a](https://github.com/landing-ai/ade-python/commit/2f49d2ab0395fabdfdb6e8b25f3fceb270efb933))

## 1.12.0 (2026-04-23)

Full Changelog: [v1.11.1...v1.12.0](https://github.com/landing-ai/ade-python/compare/v1.11.1...v1.12.0)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "landingai-ade"
version = "1.12.0"
version = "1.13.0"
description = "The official Python library for the landingai-ade API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down Expand Up @@ -168,7 +168,7 @@ show_error_codes = true
#
# We also exclude our `tests` as mypy doesn't always infer
# types correctly and Pyright will still catch any type errors.
exclude = ['src/landingai_ade/_files.py', '_dev/.*.py', 'tests/.*']
exclude = ["src/landingai_ade/_files.py", "_dev/.*.py", "tests/.*"]

strict_equality = true
implicit_reexport = true
Expand Down
19 changes: 19 additions & 0 deletions src/landingai_ade/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
)
from ._utils import (
is_given,
is_mapping_t,
extract_files,
maybe_transform,
get_async_library,
Expand Down Expand Up @@ -219,6 +220,15 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc

custom_headers_env = os.environ.get("LANDINGAI_ADE_CUSTOM_HEADERS")
if custom_headers_env is not None:
parsed: dict[str, str] = {}
for line in custom_headers_env.split("\n"):
colon = line.find(":")
if colon >= 0:
parsed[line[:colon].strip()] = line[colon + 1 :].strip()
default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}

super().__init__(
version=__version__,
base_url=base_url,
Expand Down Expand Up @@ -909,6 +919,15 @@ def __init__(
except KeyError as exc:
raise ValueError(f"Unknown environment: {environment}") from exc

custom_headers_env = os.environ.get("LANDINGAI_ADE_CUSTOM_HEADERS")
if custom_headers_env is not None:
parsed: dict[str, str] = {}
for line in custom_headers_env.split("\n"):
colon = line.find(":")
if colon >= 0:
parsed[line[:colon].strip()] = line[colon + 1 :].strip()
default_headers = {**parsed, **(default_headers if is_mapping_t(default_headers) else {})}

super().__init__(
version=__version__,
base_url=base_url,
Expand Down
2 changes: 1 addition & 1 deletion src/landingai_ade/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles
elif is_sequence_t(files):
files = [(key, await _async_transform_file(file)) for key, file in files]
else:
raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence")
raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence")

return files

Expand Down
80 changes: 80 additions & 0 deletions src/landingai_ade/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
ClassVar,
Protocol,
Required,
Annotated,
ParamSpec,
TypeAlias,
TypedDict,
TypeGuard,
final,
Expand Down Expand Up @@ -79,7 +81,15 @@
from ._constants import RAW_RESPONSE_HEADER

if TYPE_CHECKING:
from pydantic import GetCoreSchemaHandler, ValidatorFunctionWrapHandler
from pydantic_core import CoreSchema, core_schema
from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema
else:
try:
from pydantic_core import CoreSchema, core_schema
except ImportError:
CoreSchema = None
core_schema = None

__all__ = ["BaseModel", "GenericModel"]

Expand Down Expand Up @@ -396,6 +406,76 @@ def model_dump_json(
)


class _EagerIterable(list[_T], Generic[_T]):
"""
Accepts any Iterable[T] input (including generators), consumes it
eagerly, and validates all items upfront.

Validation preserves the original container type where possible
(e.g. a set[T] stays a set[T]). Serialization (model_dump / JSON)
always emits a list — round-tripping through model_dump() will not
restore the original container type.
"""

@classmethod
def __get_pydantic_core_schema__(
cls,
source_type: Any,
handler: GetCoreSchemaHandler,
) -> CoreSchema:
(item_type,) = get_args(source_type) or (Any,)
item_schema: CoreSchema = handler.generate_schema(item_type)
list_of_items_schema: CoreSchema = core_schema.list_schema(item_schema)

return core_schema.no_info_wrap_validator_function(
cls._validate,
list_of_items_schema,
serialization=core_schema.plain_serializer_function_ser_schema(
cls._serialize,
info_arg=False,
),
)

@staticmethod
def _validate(v: Iterable[_T], handler: "ValidatorFunctionWrapHandler") -> Any:
original_type: type[Any] = type(v)

# Normalize to list so list_schema can validate each item
if isinstance(v, list):
items: list[_T] = v
else:
try:
items = list(v)
except TypeError as e:
raise TypeError("Value is not iterable") from e

# Validate items against the inner schema
validated: list[_T] = handler(items)

# Reconstruct original container type
if original_type is list:
return validated
# str(list) produces the list's repr, not a string built from items,
# so skip reconstruction for str and its subclasses.
if issubclass(original_type, str):
return validated
try:
return original_type(validated)
except (TypeError, ValueError):
# If the type cannot be reconstructed, just return the validated list
return validated

@staticmethod
def _serialize(v: Iterable[_T]) -> list[_T]:
"""Always serialize as a list so Pydantic's JSON encoder is happy."""
if isinstance(v, list):
return v
return list(v)


EagerIterable: TypeAlias = Annotated[Iterable[_T], _EagerIterable]


def _construct_field(value: object, field: FieldInfo, key: str) -> object:
if value is None:
return field_get_default(field)
Expand Down
8 changes: 2 additions & 6 deletions src/landingai_ade/_qs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,13 @@

from typing import Any, List, Tuple, Union, Mapping, TypeVar
from urllib.parse import parse_qs, urlencode
from typing_extensions import Literal, get_args
from typing_extensions import get_args

from ._types import NotGiven, not_given
from ._types import NotGiven, ArrayFormat, NestedFormat, not_given
from ._utils import flatten

_T = TypeVar("_T")


ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
NestedFormat = Literal["dots", "brackets"]

PrimitiveData = Union[str, int, float, bool, None]
# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"]
# https://github.com/microsoft/pyright/issues/3555
Expand Down
3 changes: 3 additions & 0 deletions src/landingai_ade/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
ModelT = TypeVar("ModelT", bound=pydantic.BaseModel)
_T = TypeVar("_T")

ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
NestedFormat = Literal["dots", "brackets"]


# Approximates httpx internal ProxiesTypes and RequestFiles types
# while adding support for `PathLike` instances
Expand Down
42 changes: 34 additions & 8 deletions src/landingai_ade/_utils/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@
)
from pathlib import Path
from datetime import date, datetime
from typing_extensions import TypeGuard
from typing_extensions import TypeGuard, get_args

import sniffio

from .._types import Omit, NotGiven, FileTypes, HeadersLike
from .._types import Omit, NotGiven, FileTypes, ArrayFormat, HeadersLike

_T = TypeVar("_T")
_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...])
Expand All @@ -40,25 +40,45 @@ def extract_files(
query: Mapping[str, object],
*,
paths: Sequence[Sequence[str]],
array_format: ArrayFormat = "brackets",
) -> list[tuple[str, FileTypes]]:
"""Recursively extract files from the given dictionary based on specified paths.

A path may look like this ['foo', 'files', '<array>', 'data'].

``array_format`` controls how ``<array>`` segments contribute to the emitted
field name. Supported values: ``"brackets"`` (``foo[]``), ``"repeat"`` and
``"comma"`` (``foo``), ``"indices"`` (``foo[0]``, ``foo[1]``).

Note: this mutates the given dictionary.
"""
files: list[tuple[str, FileTypes]] = []
for path in paths:
files.extend(_extract_items(query, path, index=0, flattened_key=None))
files.extend(_extract_items(query, path, index=0, flattened_key=None, array_format=array_format))
return files


def _array_suffix(array_format: ArrayFormat, array_index: int) -> str:
if array_format == "brackets":
return "[]"
if array_format == "indices":
return f"[{array_index}]"
if array_format == "repeat" or array_format == "comma":
# Both repeat the bare field name for each file part; there is no
# meaningful way to comma-join binary parts.
return ""
raise NotImplementedError(
f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}"
)


def _extract_items(
obj: object,
path: Sequence[str],
*,
index: int,
flattened_key: str | None,
array_format: ArrayFormat,
) -> list[tuple[str, FileTypes]]:
try:
key = path[index]
Expand All @@ -75,9 +95,11 @@ def _extract_items(

if is_list(obj):
files: list[tuple[str, FileTypes]] = []
for entry in obj:
assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "")
files.append((flattened_key + "[]", cast(FileTypes, entry)))
for array_index, entry in enumerate(obj):
suffix = _array_suffix(array_format, array_index)
emitted_key = (flattened_key + suffix) if flattened_key else suffix
assert_is_file_content(entry, key=emitted_key)
files.append((emitted_key, cast(FileTypes, entry)))
return files

assert_is_file_content(obj, key=flattened_key)
Expand Down Expand Up @@ -106,6 +128,7 @@ def _extract_items(
path,
index=index,
flattened_key=flattened_key,
array_format=array_format,
)
elif is_list(obj):
if key != "<array>":
Expand All @@ -117,9 +140,12 @@ def _extract_items(
item,
path,
index=index,
flattened_key=flattened_key + "[]" if flattened_key is not None else "[]",
flattened_key=(
(flattened_key if flattened_key is not None else "") + _array_suffix(array_format, array_index)
),
array_format=array_format,
)
for item in obj
for array_index, item in enumerate(obj)
]
)

Expand Down
Loading