diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1bc57136..6538ca91 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.7.1" + ".": "0.8.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 81559bb8..657fb751 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.8.0 (2023-11-21) + +Full Changelog: [v0.7.1...v0.8.0](https://github.com/Finch-API/finch-api-python/compare/v0.7.1...v0.8.0) + +### Features + +* **api:** updates ([#192](https://github.com/Finch-API/finch-api-python/issues/192)) ([8f03e74](https://github.com/Finch-API/finch-api-python/commit/8f03e74f6dbc3e9c2e9543e74b06dba90fe8bbbb)) + + +### Bug Fixes + +* **client:** attempt to parse unknown json content types ([#191](https://github.com/Finch-API/finch-api-python/issues/191)) ([aad45fe](https://github.com/Finch-API/finch-api-python/commit/aad45fee8d71d2b6499e71f7e37be08f8cb5a71a)) + + +### Chores + +* **client:** improve copy method ([#193](https://github.com/Finch-API/finch-api-python/issues/193)) ([00fe295](https://github.com/Finch-API/finch-api-python/commit/00fe2958163a4236cd678e6825d636608ad3a9e6)) +* **internal:** update type hint for helper function ([#189](https://github.com/Finch-API/finch-api-python/issues/189)) ([0fb6bff](https://github.com/Finch-API/finch-api-python/commit/0fb6bff18863f51e8abe8c89e70eee72c49f44bf)) +* **package:** add license classifier metadata ([#194](https://github.com/Finch-API/finch-api-python/issues/194)) ([0ab421b](https://github.com/Finch-API/finch-api-python/commit/0ab421b10ab73d796e2a20a649235669047abb33)) + ## 0.7.1 (2023-11-16) Full Changelog: [v0.7.0...v0.7.1](https://github.com/Finch-API/finch-api-python/compare/v0.7.0...v0.7.1) diff --git a/pyproject.toml b/pyproject.toml index 9072a5d8..3fa1fd1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "finch-api" -version = "0.7.1" +version = "0.8.0" description = "The official Python library for the Finch API" readme = "README.md" license = "Apache-2.0" @@ -31,6 +31,7 @@ classifiers = [ "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", "Topic :: Software Development :: Libraries :: Python Modules", + "License :: OSI Approved :: Apache Software License" ] diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 3db8b6fa..a168301f 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -74,7 +74,12 @@ RAW_RESPONSE_HEADER, ) from ._streaming import Stream, AsyncStream -from ._exceptions import APIStatusError, APITimeoutError, APIConnectionError +from ._exceptions import ( + APIStatusError, + APITimeoutError, + APIConnectionError, + APIResponseValidationError, +) log: logging.Logger = logging.getLogger(__name__) @@ -518,13 +523,16 @@ def _process_response_data( if cast_to is UnknownResponse: return cast(ResponseT, data) - if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): - return cast(ResponseT, cast_to.build(response=response, data=data)) + try: + if inspect.isclass(cast_to) and issubclass(cast_to, ModelBuilderProtocol): + return cast(ResponseT, cast_to.build(response=response, data=data)) - if self._strict_response_validation: - return cast(ResponseT, validate_type(type_=cast_to, value=data)) + if self._strict_response_validation: + return cast(ResponseT, validate_type(type_=cast_to, value=data)) - return cast(ResponseT, construct_type(type_=cast_to, value=data)) + return cast(ResponseT, construct_type(type_=cast_to, value=data)) + except pydantic.ValidationError as err: + raise APIResponseValidationError(response=response, body=data) from err @property def qs(self) -> Querystring: diff --git a/src/finch/_client.py b/src/finch/_client.py index 6b129988..a840d0d3 100644 --- a/src/finch/_client.py +++ b/src/finch/_client.py @@ -4,8 +4,8 @@ import os import asyncio -from typing import Union, Mapping -from typing_extensions import override +from typing import Any, Union, Mapping +from typing_extensions import Self, override import httpx @@ -187,12 +187,10 @@ def copy( set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, set_default_query: Mapping[str, object] | None = None, - ) -> Finch: + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: """ Create a new client instance re-using the same options given to the current client with optional overriding. - - It should be noted that this does not share the underlying httpx client class which may lead - to performance issues. """ if default_headers is not None and set_default_headers is not None: raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") @@ -242,6 +240,7 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, + **_extra_kwargs, ) # Alias for `copy` for nicer inline usage, e.g. @@ -491,12 +490,10 @@ def copy( set_default_headers: Mapping[str, str] | None = None, default_query: Mapping[str, object] | None = None, set_default_query: Mapping[str, object] | None = None, - ) -> AsyncFinch: + _extra_kwargs: Mapping[str, Any] = {}, + ) -> Self: """ Create a new client instance re-using the same options given to the current client with optional overriding. - - It should be noted that this does not share the underlying httpx client class which may lead - to performance issues. """ if default_headers is not None and set_default_headers is not None: raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive") @@ -546,6 +543,7 @@ def copy( max_retries=max_retries if is_given(max_retries) else self.max_retries, default_headers=headers, default_query=params, + **_extra_kwargs, ) # Alias for `copy` for nicer inline usage, e.g. diff --git a/src/finch/_models.py b/src/finch/_models.py index 6d5aad59..5b8c9601 100644 --- a/src/finch/_models.py +++ b/src/finch/_models.py @@ -263,6 +263,19 @@ def _construct_field(value: object, field: FieldInfo, key: str) -> object: return construct_type(value=value, type_=type_) +def is_basemodel(type_: type) -> bool: + """Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`""" + origin = get_origin(type_) or type_ + if is_union(type_): + for variant in get_args(type_): + if is_basemodel(variant): + return True + + return False + + return issubclass(origin, BaseModel) or issubclass(origin, GenericModel) + + def construct_type(*, value: object, type_: type) -> object: """Loose coercion to the expected type with construction of nested values. diff --git a/src/finch/_response.py b/src/finch/_response.py index 11eb3199..aba0a86b 100644 --- a/src/finch/_response.py +++ b/src/finch/_response.py @@ -1,17 +1,17 @@ from __future__ import annotations import inspect +import logging import datetime import functools from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast from typing_extensions import Awaitable, ParamSpec, get_args, override, get_origin import httpx -import pydantic from ._types import NoneType, UnknownResponse, BinaryResponseContent from ._utils import is_given -from ._models import BaseModel +from ._models import BaseModel, is_basemodel from ._constants import RAW_RESPONSE_HEADER from ._exceptions import APIResponseValidationError @@ -23,6 +23,8 @@ P = ParamSpec("P") R = TypeVar("R") +log: logging.Logger = logging.getLogger(__name__) + class APIResponse(Generic[R]): _cast_to: type[R] @@ -174,6 +176,18 @@ def _parse(self) -> R: # in the response, e.g. application/json; charset=utf-8 content_type, *_ = response.headers.get("content-type").split(";") if content_type != "application/json": + if is_basemodel(cast_to): + try: + data = response.json() + except Exception as exc: + log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc) + else: + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) + if self._client._strict_response_validation: raise APIResponseValidationError( response=response, @@ -188,14 +202,11 @@ def _parse(self) -> R: data = response.json() - try: - return self._client._process_response_data( - data=data, - cast_to=cast_to, # type: ignore - response=response, - ) - except pydantic.ValidationError as err: - raise APIResponseValidationError(response=response, body=data) from err + return self._client._process_response_data( + data=data, + cast_to=cast_to, # type: ignore + response=response, + ) @override def __repr__(self) -> str: diff --git a/src/finch/_utils/_transform.py b/src/finch/_utils/_transform.py index d953505f..769f7362 100644 --- a/src/finch/_utils/_transform.py +++ b/src/finch/_utils/_transform.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, List, Mapping, TypeVar, cast +from typing import Any, Mapping, TypeVar, cast from datetime import date, datetime from typing_extensions import Literal, get_args, override, get_type_hints @@ -60,7 +60,7 @@ def __repr__(self) -> str: def maybe_transform( - data: Mapping[str, object] | List[Any] | None, + data: object, expected_type: object, ) -> Any | None: """Wrapper over `transform()` that allows `None` to be passed. diff --git a/src/finch/_version.py b/src/finch/_version.py index 77e54dcf..ae244cf0 100644 --- a/src/finch/_version.py +++ b/src/finch/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. __title__ = "finch" -__version__ = "0.7.1" # x-release-please-version +__version__ = "0.8.0" # x-release-please-version diff --git a/src/finch/types/hris/individual.py b/src/finch/types/hris/individual.py index 8f8ae112..65cd42d2 100644 --- a/src/finch/types/hris/individual.py +++ b/src/finch/types/hris/individual.py @@ -29,6 +29,12 @@ class Individual(BaseModel): emails: Optional[List[Email]] = None + encrypted_ssn: Optional[str] = None + """Note: This property is only available if enabled for your account. + + Please reach out to your Finch representative if you would like access. + """ + ethnicity: Optional[ Literal[ "asian", diff --git a/src/finch/types/provider.py b/src/finch/types/provider.py index 0b732b0f..f41eae12 100644 --- a/src/finch/types/provider.py +++ b/src/finch/types/provider.py @@ -25,6 +25,7 @@ "AuthenticationMethodSupportedFieldsEmploymentEmployment", "AuthenticationMethodSupportedFieldsEmploymentIncome", "AuthenticationMethodSupportedFieldsEmploymentLocation", + "AuthenticationMethodSupportedFieldsEmploymentManager", "AuthenticationMethodSupportedFieldsIndividual", "AuthenticationMethodSupportedFieldsIndividualEmails", "AuthenticationMethodSupportedFieldsIndividualPhoneNumbers", @@ -167,6 +168,10 @@ class AuthenticationMethodSupportedFieldsEmploymentLocation(BaseModel): state: Optional[bool] = None +class AuthenticationMethodSupportedFieldsEmploymentManager(BaseModel): + id: Optional[bool] = None + + class AuthenticationMethodSupportedFieldsEmployment(BaseModel): id: Optional[bool] = None @@ -192,7 +197,7 @@ class AuthenticationMethodSupportedFieldsEmployment(BaseModel): location: Optional[AuthenticationMethodSupportedFieldsEmploymentLocation] = None - manager: Optional[object] = None + manager: Optional[AuthenticationMethodSupportedFieldsEmploymentManager] = None middle_name: Optional[bool] = None @@ -234,6 +239,8 @@ class AuthenticationMethodSupportedFieldsIndividual(BaseModel): emails: Optional[AuthenticationMethodSupportedFieldsIndividualEmails] = None + encrypted_ssn: Optional[bool] = None + ethnicity: Optional[bool] = None first_name: Optional[bool] = None diff --git a/tests/test_client.py b/tests/test_client.py index d6013ba5..5c5b16b1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -420,6 +420,27 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 + @pytest.mark.respx(base_url=base_url) + def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + def test_base_url_env(self) -> None: with update_env(FINCH_BASE_URL="http://localhost:5000/from/env"): client = Finch(access_token=access_token, _strict_response_validation=True) @@ -1068,6 +1089,27 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 + @pytest.mark.respx(base_url=base_url) + async def test_non_application_json_content_type_for_json_data(self, respx_mock: MockRouter) -> None: + """ + Response that sets Content-Type to something other than application/json but returns json data + """ + + class Model(BaseModel): + foo: int + + respx_mock.get("/foo").mock( + return_value=httpx.Response( + 200, + content=json.dumps({"foo": 2}), + headers={"Content-Type": "application/text"}, + ) + ) + + response = await self.client.get("/foo", cast_to=Model) + assert isinstance(response, Model) + assert response.foo == 2 + def test_base_url_env(self) -> None: with update_env(FINCH_BASE_URL="http://localhost:5000/from/env"): client = AsyncFinch(access_token=access_token, _strict_response_validation=True)