Skip to content
Open
14 changes: 7 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/unify-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 uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
version: '0.10.2'

Expand All @@ -43,10 +43,10 @@ jobs:
id-token: write
runs-on: ${{ github.repository == 'stainless-sdks/unify-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
version: '0.10.2'

Expand All @@ -61,7 +61,7 @@ jobs:
github.repository == 'stainless-sdks/unify-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 @@ -81,10 +81,10 @@ jobs:
runs-on: ${{ github.repository == 'stainless-sdks/unify-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 uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
version: '0.10.2'

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ jobs:
id-token: write

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

- name: Install uv
uses: astral-sh/setup-uv@v5
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
version: '0.9.13'

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 == 'unifygtm/sdk-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 @@
{
".": "0.1.3"
".": "0.2.0"
}
2 changes: 1 addition & 1 deletion .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 21
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/unify%2Funify-832b8061b7a43ca20bfbb6cd29ea596457cf9e5471be42ab9c03ce0de23bb8a0.yml
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/unify/unify-d48d47232bd5377c79273d945b6aabefa4dad32e643a8ad6fc22c880f562fea5.yml
openapi_spec_hash: c36702c14fc409d4bbbe4d7e3e185149
config_hash: 733f711df6a401fc87fd4c8a0957d89f
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Changelog

## 0.2.0 (2026-06-18)

Full Changelog: [v0.1.3...v0.2.0](https://github.com/unifygtm/sdk-python/compare/v0.1.3...v0.2.0)

### Features

* **internal/types:** support eagerly validating pydantic iterators ([0207aab](https://github.com/unifygtm/sdk-python/commit/0207aabcc10aeafaace76cbf06db2c839b6ce39a))
* support setting headers via env ([182af02](https://github.com/unifygtm/sdk-python/commit/182af022f0bffec9a9862ce7a8f02d9a46f94458))


### Bug Fixes

* **auth:** prioritize first auth header ([160a1d4](https://github.com/unifygtm/sdk-python/commit/160a1d4e92846bcd347c512fa65bcd0c22e64577))
* **client:** add missing f-string prefix in file type error message ([6c71e19](https://github.com/unifygtm/sdk-python/commit/6c71e19a379f23b7861257be0e21230d665f87ab))
* use correct field name format for multipart file arrays ([2c9207c](https://github.com/unifygtm/sdk-python/commit/2c9207cd0a8f794532e0a13ba1603d1dbf7af6b4))


### Chores

* **internal:** reformat pyproject.toml ([063208b](https://github.com/unifygtm/sdk-python/commit/063208b4ffec5d9fce42cafc94bf39488db61ca1))

## 0.1.3 (2026-04-23)

Full Changelog: [v0.1.2...v0.1.3](https://github.com/unifygtm/sdk-python/compare/v0.1.2...v0.1.3)
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 = "unifygtm-sdk"
version = "0.1.3"
version = "0.2.0"
description = "The official Python library for the Unify API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down Expand Up @@ -154,7 +154,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/unify/_files.py', '_dev/.*.py', 'tests/.*']
exclude = ["src/unify/_files.py", "_dev/.*.py", "tests/.*"]

strict_equality = true
implicit_reexport = true
Expand Down
40 changes: 33 additions & 7 deletions src/unify/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
RequestOptions,
not_given,
)
from ._utils import is_given, get_async_library
from ._utils import (
is_given,
is_mapping_t,
get_async_library,
)
from ._compat import cached_property
from ._models import SecurityOptions
from ._version import __version__
Expand Down Expand Up @@ -82,6 +86,15 @@ def __init__(
if base_url is None:
base_url = f"https://api.unifygtm.com"

custom_headers_env = os.environ.get("UNIFY_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 @@ -114,9 +127,11 @@ def qs(self) -> Querystring:

@override
def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
return {
**(self._api_key_auth if security.get("api_key_auth", False) else {}),
}
headers: dict[str, str] = {}
if security.get("api_key_auth", False):
for key, value in self._api_key_auth.items():
headers.setdefault(key, value)
return headers

@property
def _api_key_auth(self) -> dict[str, str]:
Expand Down Expand Up @@ -261,6 +276,15 @@ def __init__(
if base_url is None:
base_url = f"https://api.unifygtm.com"

custom_headers_env = os.environ.get("UNIFY_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 @@ -293,9 +317,11 @@ def qs(self) -> Querystring:

@override
def _auth_headers(self, security: SecurityOptions) -> dict[str, str]:
return {
**(self._api_key_auth if security.get("api_key_auth", False) else {}),
}
headers: dict[str, str] = {}
if security.get("api_key_auth", False):
for key, value in self._api_key_auth.items():
headers.setdefault(key, value)
return headers

@property
def _api_key_auth(self) -> dict[str, str]:
Expand Down
2 changes: 1 addition & 1 deletion src/unify/_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,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/unify/_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/unify/_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/unify/_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
Loading
Loading