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
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ jobs:
lint:
name: lint
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

Expand All @@ -33,7 +32,6 @@ jobs:
test:
name: test
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

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 @@
{
".": "3.19.0"
".": "3.19.1"
}
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## 3.19.1 (2025-04-16)

Full Changelog: [v3.19.0...v3.19.1](https://github.com/orbcorp/orb-python/compare/v3.19.0...v3.19.1)

### Bug Fixes

* **client:** correctly reuse idempotency key ([0cdec4a](https://github.com/orbcorp/orb-python/commit/0cdec4a9188032b28280671e1a07839207f32265))
* **perf:** optimize some hot paths ([a5f8e2b](https://github.com/orbcorp/orb-python/commit/a5f8e2b0e5a1069c23864821e6d07c3b314a89bd))
* **perf:** skip traversing types for NotGiven values ([1416a8e](https://github.com/orbcorp/orb-python/commit/1416a8e2345aed3f03d5e2e0f144a3f100e0383b))


### Chores

* **internal:** base client updates ([d2a8b98](https://github.com/orbcorp/orb-python/commit/d2a8b98bfe31a91f12f8ef7a5e6d648755967d1b))
* **internal:** bump pyright version ([35933d9](https://github.com/orbcorp/orb-python/commit/35933d9adaacef7d1603c294ff334433919506c7))
* **internal:** expand CI branch coverage ([#619](https://github.com/orbcorp/orb-python/issues/619)) ([c1d283c](https://github.com/orbcorp/orb-python/commit/c1d283c07516a7df63dbde00d762afac5877f50c))
* **internal:** reduce CI branch coverage ([45f5613](https://github.com/orbcorp/orb-python/commit/45f56132576369cc18c94075b3b963ce080ce1f3))
* **internal:** update pyright settings ([e528807](https://github.com/orbcorp/orb-python/commit/e52880726bdbb63354c0163871756b3a809fef7f))

## 3.19.0 (2025-04-08)

Full Changelog: [v3.18.2...v3.19.0](https://github.com/orbcorp/orb-python/compare/v3.18.2...v3.19.0)
Expand Down
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "orb-billing"
version = "3.19.0"
version = "3.19.1"
description = "The official Python library for the orb API"
dynamic = ["readme"]
license = "Apache-2.0"
Expand Down Expand Up @@ -42,7 +42,7 @@ Repository = "https://github.com/orbcorp/orb-python"
managed = true
# version pins are in requirements-dev.lock
dev-dependencies = [
"pyright>=1.1.359",
"pyright==1.1.399",
"mypy",
"respx",
"pytest",
Expand Down Expand Up @@ -147,6 +147,7 @@ exclude = [
]

reportImplicitOverride = true
reportOverlappingOverload = false

reportImportCycles = false
reportPrivateUsage = false
Expand Down
2 changes: 1 addition & 1 deletion requirements-dev.lock
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ pydantic-core==2.27.1
# via pydantic
pygments==2.18.0
# via rich
pyright==1.1.392.post0
pyright==1.1.399
pytest==8.3.3
# via pytest-asyncio
pytest-asyncio==0.24.0
Expand Down
42 changes: 40 additions & 2 deletions src/orb/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@
_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any])

if TYPE_CHECKING:
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
from httpx._config import (
DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage]
)

HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG
else:
try:
from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT
Expand All @@ -116,6 +120,7 @@ class PageInfo:

url: URL | NotGiven
params: Query | NotGiven
json: Body | NotGiven

@overload
def __init__(
Expand All @@ -131,19 +136,30 @@ def __init__(
params: Query,
) -> None: ...

@overload
def __init__(
self,
*,
json: Body,
) -> None: ...

def __init__(
self,
*,
url: URL | NotGiven = NOT_GIVEN,
json: Body | NotGiven = NOT_GIVEN,
params: Query | NotGiven = NOT_GIVEN,
) -> None:
self.url = url
self.json = json
self.params = params

@override
def __repr__(self) -> str:
if self.url:
return f"{self.__class__.__name__}(url={self.url})"
if self.json:
return f"{self.__class__.__name__}(json={self.json})"
return f"{self.__class__.__name__}(params={self.params})"


Expand Down Expand Up @@ -192,6 +208,19 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions:
options.url = str(url)
return options

if not isinstance(info.json, NotGiven):
if not is_mapping(info.json):
raise TypeError("Pagination is only supported with mappings")

if not options.json_data:
options.json_data = {**info.json}
else:
if not is_mapping(options.json_data):
raise TypeError("Pagination is only supported with mappings")

options.json_data = {**options.json_data, **info.json}
return options

raise ValueError("Unexpected PageInfo state")


Expand Down Expand Up @@ -410,7 +439,8 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0

idempotency_header = self._idempotency_header
if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers:
headers[idempotency_header] = options.idempotency_key or self._idempotency_key()
options.idempotency_key = options.idempotency_key or self._idempotency_key()
headers[idempotency_header] = options.idempotency_key

# Don't set these headers if they were already set or removed by the caller. We check
# `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case.
Expand Down Expand Up @@ -944,6 +974,10 @@ def _request(
request = self._build_request(options, retries_taken=retries_taken)
self._prepare_request(request)

if options.idempotency_key:
# ensure the idempotency key is reused between requests
input_options.idempotency_key = options.idempotency_key

kwargs: HttpxSendArgs = {}
if self.custom_auth is not None:
kwargs["auth"] = self.custom_auth
Expand Down Expand Up @@ -1490,6 +1524,10 @@ async def _request(
request = self._build_request(options, retries_taken=retries_taken)
await self._prepare_request(request)

if options.idempotency_key:
# ensure the idempotency key is reused between requests
input_options.idempotency_key = options.idempotency_key

kwargs: HttpxSendArgs = {}
if self.custom_auth is not None:
kwargs["auth"] = self.custom_auth
Expand Down
1 change: 0 additions & 1 deletion src/orb/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
)

import pydantic
import pydantic.generics
from pydantic.fields import FieldInfo

from ._types import (
Expand Down
25 changes: 24 additions & 1 deletion src/orb/_utils/_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
import pathlib
from typing import Any, Mapping, TypeVar, cast
from datetime import date, datetime
from typing_extensions import Literal, get_args, override, get_type_hints
from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints

import anyio
import pydantic

from ._utils import (
is_list,
is_given,
lru_cache,
is_mapping,
is_iterable,
)
Expand Down Expand Up @@ -108,6 +110,7 @@ class Params(TypedDict, total=False):
return cast(_T, transformed)


@lru_cache(maxsize=8096)
def _get_annotated_type(type_: type) -> type | None:
"""If the given type is an `Annotated` type then it is returned, if not `None` is returned.

Expand Down Expand Up @@ -258,6 +261,11 @@ def _transform_typeddict(
result: dict[str, object] = {}
annotations = get_type_hints(expected_type, include_extras=True)
for key, value in data.items():
if not is_given(value):
# we don't need to include `NotGiven` values here as they'll
# be stripped out before the request is sent anyway
continue

type_ = annotations.get(key)
if type_ is None:
# we do not have a type annotation for this field, leave it as is
Expand Down Expand Up @@ -415,10 +423,25 @@ async def _async_transform_typeddict(
result: dict[str, object] = {}
annotations = get_type_hints(expected_type, include_extras=True)
for key, value in data.items():
if not is_given(value):
# we don't need to include `NotGiven` values here as they'll
# be stripped out before the request is sent anyway
continue

type_ = annotations.get(key)
if type_ is None:
# we do not have a type annotation for this field, leave it as is
result[key] = value
else:
result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_)
return result


@lru_cache(maxsize=8096)
def get_type_hints(
obj: Any,
globalns: dict[str, Any] | None = None,
localns: Mapping[str, Any] | None = None,
include_extras: bool = False,
) -> dict[str, Any]:
return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras)
4 changes: 3 additions & 1 deletion src/orb/_utils/_typing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
get_origin,
)

from ._utils import lru_cache
from .._types import InheritsGeneric
from .._compat import is_union as _is_union

Expand Down Expand Up @@ -66,6 +67,7 @@ def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]:


# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]]
@lru_cache(maxsize=8096)
def strip_annotated_type(typ: type) -> type:
if is_required_type(typ) or is_annotated_type(typ):
return strip_annotated_type(cast(type, get_args(typ)[0]))
Expand Down Expand Up @@ -108,7 +110,7 @@ class MyResponse(Foo[_T]):
```
"""
cls = cast(object, get_origin(typ) or typ)
if cls in generic_bases:
if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains]
# we're given the class directly
return extract_type_arg(typ, index)

Expand Down
2 changes: 1 addition & 1 deletion src/orb/_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

__title__ = "orb"
__version__ = "3.19.0" # x-release-please-version
__version__ = "3.19.1" # x-release-please-version
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from orb import Orb, AsyncOrb

if TYPE_CHECKING:
from _pytest.fixtures import FixtureRequest
from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage]

pytest.register_assert_rewrite("tests.utils")

Expand Down
2 changes: 1 addition & 1 deletion tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ class B(BaseModel):

@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1")
def test_type_alias_type() -> None:
Alias = TypeAliasType("Alias", str)
Alias = TypeAliasType("Alias", str) # pyright: ignore

class Model(BaseModel):
alias: Alias
Expand Down
9 changes: 8 additions & 1 deletion tests/test_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest

from orb._types import Base64FileInput
from orb._types import NOT_GIVEN, Base64FileInput
from orb._utils import (
PropertyInfo,
transform as _transform,
Expand Down Expand Up @@ -444,3 +444,10 @@ async def test_transform_skipping(use_async: bool) -> None:
# iterables of ints are converted to a list
data = iter([1, 2, 3])
assert await transform(data, Iterable[int], use_async) == [1, 2, 3]


@parametrize
@pytest.mark.asyncio
async def test_strips_notgiven(use_async: bool) -> None:
assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"}
assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {}