diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c25245b..f3ab5ac4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,24 +35,40 @@ jobs: - name: Run lints run: ./scripts/lint - upload: - if: github.repository == 'stainless-sdks/riza-api-python' && (github.event_name == 'push' || github.event.pull_request.head.repo.fork) + build: + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork timeout-minutes: 10 - name: upload + name: build permissions: contents: read id-token: write - runs-on: depot-ubuntu-24.04 + runs-on: ${{ github.repository == 'stainless-sdks/riza-api-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 + - name: Install Rye + run: | + curl -sSf https://rye.astral.sh/get | bash + echo "$HOME/.rye/shims" >> $GITHUB_PATH + env: + RYE_VERSION: '0.44.0' + RYE_INSTALL_OPTION: '--yes' + + - name: Install dependencies + run: rye sync --all-features + + - name: Run build + run: rye build + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/riza-api-python' id: github-oidc uses: actions/github-script@v6 with: script: core.setOutput('github_token', await core.getIDToken()); - name: Upload tarball + if: github.repository == 'stainless-sdks/riza-api-python' env: URL: https://pkg.stainless.com/s AUTH: ${{ steps.github-oidc.outputs.github_token }} diff --git a/.gitignore b/.gitignore index 87797408..95ceb189 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ .prism.log -.vscode _dev __pycache__ diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a7130553..d52d2b97 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.12.0" + ".": "0.13.0" } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..5b010307 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.importFormat": "relative", +} diff --git a/CHANGELOG.md b/CHANGELOG.md index ec895513..5a125fb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.13.0 (2025-08-27) + +Full Changelog: [v0.12.0...v0.13.0](https://github.com/riza-io/riza-api-python/compare/v0.12.0...v0.13.0) + +### Features + +* **api:** api update ([382486c](https://github.com/riza-io/riza-api-python/commit/382486c74c65eea1194720b45ece9591d4d41af3)) +* clean up environment call outs ([8769bb2](https://github.com/riza-io/riza-api-python/commit/8769bb2c3c2c1f27f9b3376784e6aea508605f06)) +* **client:** support file upload requests ([1087c71](https://github.com/riza-io/riza-api-python/commit/1087c71fb0bf395b432d4fc431ee6603692e4136)) + + +### Bug Fixes + +* avoid newer type syntax ([9a18d40](https://github.com/riza-io/riza-api-python/commit/9a18d4006f1bf12a65155058c38a350d50f54c4d)) +* **client:** don't send Content-Type header on GET requests ([bd53e69](https://github.com/riza-io/riza-api-python/commit/bd53e69b195f87bde9d7fb9e5d0b53e1c6fe8115)) +* **parsing:** correctly handle nested discriminated unions ([8d47022](https://github.com/riza-io/riza-api-python/commit/8d47022a13eee3e79257a1b5f05fb5f1c011356d)) +* **parsing:** ignore empty metadata ([13c6372](https://github.com/riza-io/riza-api-python/commit/13c637238f21a0e1d107ef93ff68aa64544d80b4)) +* **parsing:** parse extra field types ([7d02baf](https://github.com/riza-io/riza-api-python/commit/7d02baf3b6f9b21004709680918f988e39b1c67e)) + + +### Chores + +* **ci:** change upload type ([d9528d6](https://github.com/riza-io/riza-api-python/commit/d9528d63ccb73418a25fd4faf7780503e59effb9)) +* **internal:** bump pinned h11 dep ([90fc2e5](https://github.com/riza-io/riza-api-python/commit/90fc2e5f508cfafe32e4c01aeb47ff48d2cf97a5)) +* **internal:** change ci workflow machines ([0d557ec](https://github.com/riza-io/riza-api-python/commit/0d557ec5e70c449738f311582b5816fc356baa47)) +* **internal:** fix ruff target version ([aa45f88](https://github.com/riza-io/riza-api-python/commit/aa45f883a7987c82ac55f32c0cd574df06e51a30)) +* **internal:** update comment in script ([0035775](https://github.com/riza-io/riza-api-python/commit/003577545ff551df4172905dba95d7375ef91416)) +* **internal:** update pyright exclude list ([e031abb](https://github.com/riza-io/riza-api-python/commit/e031abb28b1171dac9169607d11b7153a326fd95)) +* **package:** mark python 3.13 as supported ([05a0bcf](https://github.com/riza-io/riza-api-python/commit/05a0bcf5f142708df85b1cb43629440f17425286)) +* **project:** add settings file for vscode ([0166e57](https://github.com/riza-io/riza-api-python/commit/0166e5774e72bcea4a532baafecb1ad513883616)) +* **readme:** fix version rendering on pypi ([d5a15da](https://github.com/riza-io/riza-api-python/commit/d5a15daab691ce39320b92570cb03e05500f9553)) +* update @stainless-api/prism-cli to v5.15.0 ([bba0d03](https://github.com/riza-io/riza-api-python/commit/bba0d03cb1b6bdd875c1683073f1bd63211b7067)) +* update github action ([343e3ec](https://github.com/riza-io/riza-api-python/commit/343e3ec7a6bf8cb4bc9dccdae89a5e41d0fe178c)) + ## 0.12.0 (2025-06-29) Full Changelog: [v0.11.0...v0.12.0](https://github.com/riza-io/riza-api-python/compare/v0.11.0...v0.12.0) diff --git a/README.md b/README.md index da31a2a6..6423d936 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Riza Python API library -[![PyPI version]()](https://pypi.org/project/rizaio/) + +[![PyPI version](https://img.shields.io/pypi/v/rizaio.svg?label=pypi%20(stable))](https://pypi.org/project/rizaio/) The Riza Python library provides convenient access to the Riza REST API from any Python 3.8+ application. The library includes type definitions for all request params and response fields, @@ -84,7 +85,6 @@ pip install rizaio[aiohttp] Then you can enable it by instantiating the client with `http_client=DefaultAioHttpClient()`: ```python -import os import asyncio from rizaio import DefaultAioHttpClient from rizaio import AsyncRiza @@ -92,7 +92,7 @@ from rizaio import AsyncRiza async def main() -> None: async with AsyncRiza( - api_key=os.environ.get("RIZA_API_KEY"), # This is the default and can be omitted + api_key="My API Key", http_client=DefaultAioHttpClient(), ) as client: response = await client.command.exec( diff --git a/pyproject.toml b/pyproject.toml index 529e45ae..c0ef3df1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rizaio" -version = "0.12.0" +version = "0.13.0" description = "The official Python library for the riza API" dynamic = ["readme"] license = "Apache-2.0" @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Operating System :: OS Independent", "Operating System :: POSIX", "Operating System :: MacOS", @@ -38,7 +39,7 @@ Homepage = "https://github.com/riza-io/riza-api-python" Repository = "https://github.com/riza-io/riza-api-python" [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.6"] +aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.8"] [tool.rye] managed = true @@ -147,6 +148,7 @@ exclude = [ "_dev", ".venv", ".nox", + ".git", ] reportImplicitOverride = true @@ -158,7 +160,7 @@ reportPrivateUsage = false [tool.ruff] line-length = 120 output-format = "grouped" -target-version = "py37" +target-version = "py38" [tool.ruff.format] docstring-code-format = true diff --git a/requirements-dev.lock b/requirements-dev.lock index 9b2900ca..87254a38 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -48,15 +48,15 @@ filelock==3.12.4 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via httpx-aiohttp # via respx # via rizaio -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via rizaio idna==3.4 # via anyio diff --git a/requirements.lock b/requirements.lock index 77a9e6ad..3abce5f7 100644 --- a/requirements.lock +++ b/requirements.lock @@ -36,14 +36,14 @@ exceptiongroup==1.2.2 frozenlist==1.6.2 # via aiohttp # via aiosignal -h11==0.14.0 +h11==0.16.0 # via httpcore -httpcore==1.0.2 +httpcore==1.0.9 # via httpx httpx==0.28.1 # via httpx-aiohttp # via rizaio -httpx-aiohttp==0.1.6 +httpx-aiohttp==0.1.8 # via rizaio idna==3.4 # via anyio diff --git a/scripts/mock b/scripts/mock index d2814ae6..0b28f6ea 100755 --- a/scripts/mock +++ b/scripts/mock @@ -21,7 +21,7 @@ echo "==> Starting mock server with URL ${URL}" # Run prism mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" &> .prism.log & + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & # Wait for server to come online echo -n "Waiting for server" @@ -37,5 +37,5 @@ if [ "$1" == "--daemon" ]; then echo else - npm exec --package=@stainless-api/prism-cli@5.8.5 -- prism mock "$URL" + npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" fi diff --git a/scripts/test b/scripts/test index 2b878456..dbeda2d2 100755 --- a/scripts/test +++ b/scripts/test @@ -43,7 +43,7 @@ elif ! prism_is_running ; then echo -e "To run the server, pass in the path or url of your OpenAPI" echo -e "spec to the prism command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stoplight/prism-cli@~5.3.2 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" echo exit 1 diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh index ee0ba42e..8b5236df 100755 --- a/scripts/utils/upload-artifact.sh +++ b/scripts/utils/upload-artifact.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash set -exuo pipefail -RESPONSE=$(curl -X POST "$URL" \ +FILENAME=$(basename dist/*.whl) + +RESPONSE=$(curl -X POST "$URL?filename=$FILENAME" \ -H "Authorization: Bearer $AUTH" \ -H "Content-Type: application/json") @@ -12,13 +14,13 @@ if [[ "$SIGNED_URL" == "null" ]]; then exit 1 fi -UPLOAD_RESPONSE=$(tar -cz . | curl -v -X PUT \ - -H "Content-Type: application/gzip" \ - --data-binary @- "$SIGNED_URL" 2>&1) +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: binary/octet-stream" \ + --data-binary "@dist/$FILENAME" "$SIGNED_URL" 2>&1) if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then echo -e "\033[32mUploaded build to Stainless storage.\033[0m" - echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/riza-api-python/$SHA'\033[0m" + echo -e "\033[32mInstallation: pip install 'https://pkg.stainless.com/s/riza-api-python/$SHA/$FILENAME'\033[0m" else echo -e "\033[31mFailed to upload artifact.\033[0m" exit 1 diff --git a/src/rizaio/_base_client.py b/src/rizaio/_base_client.py index 8ac84196..e44d54b5 100644 --- a/src/rizaio/_base_client.py +++ b/src/rizaio/_base_client.py @@ -529,6 +529,18 @@ def _build_request( # work around https://github.com/encode/httpx/discussions/2880 kwargs["extensions"] = {"sni_hostname": prepared_url.host.replace("_", "-")} + is_body_allowed = options.method.lower() != "get" + + if is_body_allowed: + if isinstance(json_data, bytes): + kwargs["content"] = json_data + else: + kwargs["json"] = json_data if is_given(json_data) else None + kwargs["files"] = files + else: + headers.pop("Content-Type", None) + kwargs.pop("data", None) + # TODO: report this error to httpx return self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, @@ -540,8 +552,6 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data if is_given(json_data) else None, - files=files, **kwargs, ) diff --git a/src/rizaio/_files.py b/src/rizaio/_files.py index 715cc207..cc14c14f 100644 --- a/src/rizaio/_files.py +++ b/src/rizaio/_files.py @@ -69,12 +69,12 @@ def _transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], _read_file_content(file[1]), *file[2:]) + return (file[0], read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -def _read_file_content(file: FileContent) -> HttpxFileContent: +def read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return pathlib.Path(file).read_bytes() return file @@ -111,12 +111,12 @@ async def _async_transform_file(file: FileTypes) -> HttpxFileTypes: return file if is_tuple_t(file): - return (file[0], await _async_read_file_content(file[1]), *file[2:]) + return (file[0], await async_read_file_content(file[1]), *file[2:]) raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple") -async def _async_read_file_content(file: FileContent) -> HttpxFileContent: +async def async_read_file_content(file: FileContent) -> HttpxFileContent: if isinstance(file, os.PathLike): return await anyio.Path(file).read_bytes() diff --git a/src/rizaio/_models.py b/src/rizaio/_models.py index 4b399147..c9f0528d 100644 --- a/src/rizaio/_models.py +++ b/src/rizaio/_models.py @@ -2,9 +2,10 @@ import os import inspect -from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, cast +from typing import TYPE_CHECKING, Any, Type, Union, Generic, TypeVar, Callable, Optional, cast from datetime import date, datetime from typing_extensions import ( + List, Unpack, Literal, ClassVar, @@ -207,14 +208,18 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] else: fields_values[name] = field_get_default(field) + extra_field_type = _get_extra_fields_type(__cls) + _extra = {} for key, value in values.items(): if key not in model_fields: + parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value + if PYDANTIC_V2: - _extra[key] = value + _extra[key] = parsed else: _fields_set.add(key) - fields_values[key] = value + fields_values[key] = parsed object.__setattr__(m, "__dict__", fields_values) @@ -299,7 +304,7 @@ def model_dump( exclude_none=exclude_none, ) - return cast(dict[str, Any], json_safe(dumped)) if mode == "json" else dumped + return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped @override def model_dump_json( @@ -366,7 +371,24 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: if type_ is None: raise RuntimeError(f"Unexpected field type is None for {key}") - return construct_type(value=value, type_=type_) + return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None)) + + +def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None: + if not PYDANTIC_V2: + # TODO + return None + + schema = cls.__pydantic_core_schema__ + if schema["type"] == "model": + fields = schema["schema"] + if fields["type"] == "model-fields": + extras = fields.get("extras_schema") + if extras and "cls" in extras: + # mypy can't narrow the type + return extras["cls"] # type: ignore[no-any-return] + + return None def is_basemodel(type_: type) -> bool: @@ -420,7 +442,7 @@ def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T: return cast(_T, construct_type(value=value, type_=type_)) -def construct_type(*, value: object, type_: object) -> object: +def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object: """Loose coercion to the expected type with construction of nested values. If the given value does not match the expected type then it is returned as-is. @@ -438,8 +460,10 @@ def construct_type(*, value: object, type_: object) -> object: type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` - if is_annotated_type(type_): - meta: tuple[Any, ...] = get_args(type_)[1:] + if metadata is not None and len(metadata) > 0: + meta: tuple[Any, ...] = tuple(metadata) + elif is_annotated_type(type_): + meta = get_args(type_)[1:] type_ = extract_type_arg(type_, 0) else: meta = tuple() diff --git a/src/rizaio/_version.py b/src/rizaio/_version.py index 02bdd107..64128762 100644 --- a/src/rizaio/_version.py +++ b/src/rizaio/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "rizaio" -__version__ = "0.12.0" # x-release-please-version +__version__ = "0.13.0" # x-release-please-version diff --git a/tests/test_client.py b/tests/test_client.py index 32de542b..fa6366f5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -456,7 +456,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, client: Riza) -> None: request = client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, @@ -1259,7 +1259,7 @@ def test_request_extra_query(self) -> None: def test_multipart_repeating_array(self, async_client: AsyncRiza) -> None: request = async_client._build_request( FinalRequestOptions.construct( - method="get", + method="post", url="/foo", headers={"Content-Type": "multipart/form-data; boundary=6b7ba517decee4a450543ea6ae821c82"}, json_data={"array": ["foo", "bar"]}, diff --git a/tests/test_models.py b/tests/test_models.py index 70a35682..94710dd4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Union, Optional, cast +from typing import TYPE_CHECKING, Any, Dict, List, Union, Optional, cast from datetime import datetime, timezone from typing_extensions import Literal, Annotated, TypeAliasType @@ -889,3 +889,75 @@ class ModelB(BaseModel): ) assert isinstance(m, ModelB) + + +def test_nested_discriminated_union() -> None: + class InnerType1(BaseModel): + type: Literal["type_1"] + + class InnerModel(BaseModel): + inner_value: str + + class InnerType2(BaseModel): + type: Literal["type_2"] + some_inner_model: InnerModel + + class Type1(BaseModel): + base_type: Literal["base_type_1"] + value: Annotated[ + Union[ + InnerType1, + InnerType2, + ], + PropertyInfo(discriminator="type"), + ] + + class Type2(BaseModel): + base_type: Literal["base_type_2"] + + T = Annotated[ + Union[ + Type1, + Type2, + ], + PropertyInfo(discriminator="base_type"), + ] + + model = construct_type( + type_=T, + value={ + "base_type": "base_type_1", + "value": { + "type": "type_2", + }, + }, + ) + assert isinstance(model, Type1) + assert isinstance(model.value, InnerType2) + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="this is only supported in pydantic v2 for now") +def test_extra_properties() -> None: + class Item(BaseModel): + prop: int + + class Model(BaseModel): + __pydantic_extra__: Dict[str, Item] = Field(init=False) # pyright: ignore[reportIncompatibleVariableOverride] + + other: str + + if TYPE_CHECKING: + + def __getattr__(self, attr: str) -> Item: ... + + model = construct_type( + type_=Model, + value={ + "a": {"prop": 1}, + "other": "foo", + }, + ) + assert isinstance(model, Model) + assert model.a.prop == 1 + assert isinstance(model.a, Item) + assert model.other == "foo"