diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b9da964d..bbeb30b1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -17,6 +17,7 @@ "settings": { "terminal.integrated.shell.linux": "/bin/bash", "python.pythonPath": ".venv/bin/python", + "python.defaultInterpreterPath": ".venv/bin/python", "python.typeChecking": "basic", "terminal.integrated.env.linux": { "PATH": "/home/vscode/.rye/shims:${env:PATH}" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4208b5cb..1b77f506 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.6.0" + ".": "0.7.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d0fc5d..fd51e016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.7.0 (2023-11-15) + +Full Changelog: [v0.6.0...v0.7.0](https://github.com/Finch-API/finch-api-python/compare/v0.6.0...v0.7.0) + +### Features + +* **api:** updates ([#184](https://github.com/Finch-API/finch-api-python/issues/184)) ([1fbf21e](https://github.com/Finch-API/finch-api-python/commit/1fbf21e96dc700db462b619da54897ce156301a9)) +* **client:** support passing chunk size for binary responses ([#175](https://github.com/Finch-API/finch-api-python/issues/175)) ([3d02d4b](https://github.com/Finch-API/finch-api-python/commit/3d02d4ba528ad199981a185def69bb80d939d445)) +* **client:** support reading the base url from an env variable ([#186](https://github.com/Finch-API/finch-api-python/issues/186)) ([bb2dc38](https://github.com/Finch-API/finch-api-python/commit/bb2dc38e25da74fcbcdc2a34b4706ec304e9657f)) + + +### Bug Fixes + +* **client:** retry if SSLWantReadError occurs in the async client ([#181](https://github.com/Finch-API/finch-api-python/issues/181)) ([b2a7d2c](https://github.com/Finch-API/finch-api-python/commit/b2a7d2c0a617f358a12e7556fc53b740880892a4)) +* **client:** serialise pydantic v1 default fields correctly in params ([#180](https://github.com/Finch-API/finch-api-python/issues/180)) ([cb69944](https://github.com/Finch-API/finch-api-python/commit/cb6994491d3c2bf4112443018bc7d2d6d2b78fd4)) +* **models:** mark unknown fields as set in pydantic v1 ([#179](https://github.com/Finch-API/finch-api-python/issues/179)) ([c8261c9](https://github.com/Finch-API/finch-api-python/commit/c8261c9fb39b0a275dde59a78c67feb15d84c0f9)) + + +### Chores + +* **internal:** base client updates ([#178](https://github.com/Finch-API/finch-api-python/issues/178)) ([d06251d](https://github.com/Finch-API/finch-api-python/commit/d06251dfdb660fc7843b55c533f5b59906197160)) +* **internal:** fix devcontainer interpeter path ([#183](https://github.com/Finch-API/finch-api-python/issues/183)) ([ca48726](https://github.com/Finch-API/finch-api-python/commit/ca487263a47beab1f4dce8c052e734358d6893ef)) +* **internal:** fix typo in NotGiven docstring ([#182](https://github.com/Finch-API/finch-api-python/issues/182)) ([ca198e9](https://github.com/Finch-API/finch-api-python/commit/ca198e9a817cf76eadd912909a4581ef1ad2fd29)) + + +### Documentation + +* fix code comment typo ([#185](https://github.com/Finch-API/finch-api-python/issues/185)) ([09df068](https://github.com/Finch-API/finch-api-python/commit/09df068fad1545affb78bb5a982eb4ba4c9ed080)) +* reword package description ([#177](https://github.com/Finch-API/finch-api-python/issues/177)) ([ee5340a](https://github.com/Finch-API/finch-api-python/commit/ee5340ae2a36d26e2c9e12add86e8ba0a1603d2e)) + ## 0.6.0 (2023-11-08) Full Changelog: [v0.5.0...v0.6.0](https://github.com/Finch-API/finch-api-python/compare/v0.5.0...v0.6.0) diff --git a/README.md b/README.md index a404df51..83861cd5 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ import httpx from finch import Finch client = Finch( + # Or use the `FINCH_BASE_URL` env var base_url="http://my.test.server.example.com:8083", http_client=httpx.Client( proxies="http://my.test.proxy.example.com", diff --git a/api.md b/api.md index df158a2e..0fb9d472 100644 --- a/api.md +++ b/api.md @@ -1,3 +1,9 @@ +# Shared Types + +```python +from finch.types import OperationSupport, OperationSupportMatrix +``` + # Finch Methods: @@ -96,11 +102,14 @@ Types: ```python from finch.types.hris import ( BenefitContribution, + BenefitFeaturesAndOperations, BenefitFrequency, BenefitType, + BenefitsSupport, BenfitContribution, CompanyBenefit, CreateCompanyBenefitsResponse, + SupportPerBenefitType, SupportedBenefit, UpdateCompanyBenefitResponse, ) diff --git a/pyproject.toml b/pyproject.toml index 9622e610..cf670e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "finch-api" -version = "0.6.0" -description = "Client library for the Finch API" +version = "0.7.0" +description = "The official Python library for the Finch API" readme = "README.md" license = "Apache-2.0" authors = [ diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index e37759cd..3db8b6fa 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -1320,12 +1320,6 @@ async def _request( if retries > 0: return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) raise APITimeoutError(request=request) from err - except httpx.ReadTimeout as err: - # We explicitly do not retry on ReadTimeout errors as this means - # that the server processing the request has taken 60 seconds - # (our default timeout). This likely indicates that something - # is not working as expected on the server side. - raise except httpx.TimeoutException as err: if retries > 0: return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) @@ -1727,9 +1721,14 @@ def iter_raw(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: return self.response.iter_raw(chunk_size) @override - def stream_to_file(self, file: str | os.PathLike[str]) -> None: + def stream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: with open(file, mode="wb") as f: - for data in self.response.iter_bytes(): + for data in self.response.iter_bytes(chunk_size): f.write(data) @override @@ -1757,10 +1756,15 @@ async def aiter_raw(self, chunk_size: Optional[int] = None) -> AsyncIterator[byt return self.response.aiter_raw(chunk_size) @override - async def astream_to_file(self, file: str | os.PathLike[str]) -> None: + async def astream_to_file( + self, + file: str | os.PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: path = anyio.Path(file) async with await path.open(mode="wb") as f: - async for data in self.response.aiter_bytes(): + async for data in self.response.aiter_bytes(chunk_size): await f.write(data) @override diff --git a/src/finch/_client.py b/src/finch/_client.py index 7da9096f..6b129988 100644 --- a/src/finch/_client.py +++ b/src/finch/_client.py @@ -112,6 +112,8 @@ def __init__( webhook_secret = os.environ.get("FINCH_WEBHOOK_SECRET") self.webhook_secret = webhook_secret + if base_url is None: + base_url = os.environ.get("FINCH_BASE_URL") if base_url is None: base_url = f"https://api.tryfinch.com" @@ -414,6 +416,8 @@ def __init__( webhook_secret = os.environ.get("FINCH_WEBHOOK_SECRET") self.webhook_secret = webhook_secret + if base_url is None: + base_url = os.environ.get("FINCH_BASE_URL") if base_url is None: base_url = f"https://api.tryfinch.com" diff --git a/src/finch/_models.py b/src/finch/_models.py index 00d787ca..6d5aad59 100644 --- a/src/finch/_models.py +++ b/src/finch/_models.py @@ -121,6 +121,7 @@ def construct( if PYDANTIC_V2: _extra[key] = value else: + _fields_set.add(key) fields_values[key] = value object.__setattr__(m, "__dict__", fields_values) @@ -148,7 +149,7 @@ def construct( if not PYDANTIC_V2: # we define aliases for some of the new pydantic v2 methods so # that we can just document these methods without having to specify - # a specifc pydantic version as some users may not know which + # a specific pydantic version as some users may not know which # pydantic version they are currently using @override diff --git a/src/finch/_streaming.py b/src/finch/_streaming.py index b0600fc9..913159fd 100644 --- a/src/finch/_streaming.py +++ b/src/finch/_streaming.py @@ -45,10 +45,15 @@ def __stream__(self) -> Iterator[ResponseT]: cast_to = self._cast_to response = self.response process_data = self._client._process_response_data + iterator = self._iter_events() - for sse in self._iter_events(): + for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) + # Ensure the entire stream is consumed + for sse in iterator: + ... + class AsyncStream(Generic[ResponseT]): """Provides the core interface to iterate over an asynchronous stream response.""" @@ -83,10 +88,15 @@ async def __stream__(self) -> AsyncIterator[ResponseT]: cast_to = self._cast_to response = self.response process_data = self._client._process_response_data + iterator = self._iter_events() - async for sse in self._iter_events(): + async for sse in iterator: yield process_data(data=sse.json(), cast_to=cast_to, response=response) + # Ensure the entire stream is consumed + async for sse in iterator: + ... + class ServerSentEvent: def __init__( diff --git a/src/finch/_types.py b/src/finch/_types.py index 1fd2729a..e12f064d 100644 --- a/src/finch/_types.py +++ b/src/finch/_types.py @@ -123,7 +123,12 @@ def iter_raw(self, chunk_size: Optional[int] = None) -> Iterator[bytes]: pass @abstractmethod - def stream_to_file(self, file: str | PathLike[str]) -> None: + def stream_to_file( + self, + file: str | PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: """ Stream the output to the given file. """ @@ -172,7 +177,13 @@ async def aiter_raw(self, chunk_size: Optional[int] = None) -> AsyncIterator[byt """ pass - async def astream_to_file(self, file: str | PathLike[str]) -> None: + @abstractmethod + async def astream_to_file( + self, + file: str | PathLike[str], + *, + chunk_size: int | None = None, + ) -> None: """ Stream the output to the given file. """ @@ -268,8 +279,8 @@ class NotGiven: ```py def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ... - get(timout=1) # 1s timeout - get(timout=None) # No timeout + get(timeout=1) # 1s timeout + get(timeout=None) # No timeout get() # Default timeout behavior, which may not be statically known at the method definition. ``` """ diff --git a/src/finch/_utils/_transform.py b/src/finch/_utils/_transform.py index dc497ea3..d953505f 100644 --- a/src/finch/_utils/_transform.py +++ b/src/finch/_utils/_transform.py @@ -168,7 +168,7 @@ def _transform_recursive( return data if isinstance(data, pydantic.BaseModel): - return model_dump(data, exclude_unset=True, exclude_defaults=True) + return model_dump(data, exclude_unset=True) return _transform_value(data, annotation) diff --git a/src/finch/_version.py b/src/finch/_version.py index 991bac2b..da6748b7 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.6.0" # x-release-please-version +__version__ = "0.7.0" # x-release-please-version diff --git a/src/finch/resources/request_forwarding.py b/src/finch/resources/request_forwarding.py index 21d6e44b..2464bb35 100644 --- a/src/finch/resources/request_forwarding.py +++ b/src/finch/resources/request_forwarding.py @@ -41,12 +41,16 @@ def forward( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> RequestForwardingForwardResponse: - """ - The Forward API allows you to make direct requests to an employment system. + """The Forward API allows you to make direct requests to an employment system. + + If + Finch’s unified API doesn’t have a data model that cleanly fits your needs, then + Forward allows you to push or pull data models directly against an integration’s + API. Args: - method: The HTTP method for the forwarded request. Valid values include: `GET`, `POST`, - `PUT`, `DELETE`, and `PATCH`. + method: The HTTP method for the forwarded request. Valid values include: `GET` , `POST` + , `PUT` , `DELETE` , and `PATCH`. route: The URL route path for the forwarded request. This value must begin with a forward-slash ( / ) and may only contain alphanumeric characters, hyphens, and @@ -111,12 +115,16 @@ async def forward( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN, ) -> RequestForwardingForwardResponse: - """ - The Forward API allows you to make direct requests to an employment system. + """The Forward API allows you to make direct requests to an employment system. + + If + Finch’s unified API doesn’t have a data model that cleanly fits your needs, then + Forward allows you to push or pull data models directly against an integration’s + API. Args: - method: The HTTP method for the forwarded request. Valid values include: `GET`, `POST`, - `PUT`, `DELETE`, and `PATCH`. + method: The HTTP method for the forwarded request. Valid values include: `GET` , `POST` + , `PUT` , `DELETE` , and `PATCH`. route: The URL route path for the forwarded request. This value must begin with a forward-slash ( / ) and may only contain alphanumeric characters, hyphens, and diff --git a/src/finch/types/__init__.py b/src/finch/types/__init__.py index 7f44cbc5..eee945a5 100644 --- a/src/finch/types/__init__.py +++ b/src/finch/types/__init__.py @@ -5,6 +5,8 @@ from .money import Money as Money from .income import Income as Income from .paging import Paging as Paging +from .shared import OperationSupport as OperationSupport +from .shared import OperationSupportMatrix as OperationSupportMatrix from .location import Location as Location from .provider import Provider as Provider from .introspection import Introspection as Introspection diff --git a/src/finch/types/hris/__init__.py b/src/finch/types/hris/__init__.py index 95dcb512..da28d4a5 100644 --- a/src/finch/types/hris/__init__.py +++ b/src/finch/types/hris/__init__.py @@ -9,6 +9,7 @@ from .pay_statement import PayStatement as PayStatement from .company_benefit import CompanyBenefit as CompanyBenefit from .employment_data import EmploymentData as EmploymentData +from .benefits_support import BenefitsSupport as BenefitsSupport from .benefit_frequency import BenefitFrequency as BenefitFrequency from .supported_benefit import SupportedBenefit as SupportedBenefit from .benfit_contribution import BenfitContribution as BenfitContribution @@ -21,9 +22,13 @@ from .pay_statement_response import PayStatementResponse as PayStatementResponse from .individual_in_directory import IndividualInDirectory as IndividualInDirectory from .employment_data_response import EmploymentDataResponse as EmploymentDataResponse +from .support_per_benefit_type import SupportPerBenefitType as SupportPerBenefitType from .pay_statement_response_body import ( PayStatementResponseBody as PayStatementResponseBody, ) +from .benefit_features_and_operations import ( + BenefitFeaturesAndOperations as BenefitFeaturesAndOperations, +) from .employment_retrieve_many_params import ( EmploymentRetrieveManyParams as EmploymentRetrieveManyParams, ) diff --git a/src/finch/types/hris/benefit_features_and_operations.py b/src/finch/types/hris/benefit_features_and_operations.py new file mode 100644 index 00000000..78fc27b3 --- /dev/null +++ b/src/finch/types/hris/benefit_features_and_operations.py @@ -0,0 +1,51 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing import List, Optional +from typing_extensions import Literal + +from ..._models import BaseModel +from .benefit_frequency import BenefitFrequency +from .support_per_benefit_type import SupportPerBenefitType + +__all__ = ["BenefitFeaturesAndOperations", "SupportedFeatures"] + + +class SupportedFeatures(BaseModel): + annual_maximum: Optional[bool] = None + """Whether the provider supports an annual maximum for this benefit.""" + + catch_up: Optional[bool] = None + """Whether the provider supports catch up for this benefit. + + This field will only be true for retirement benefits. + """ + + company_contribution: Optional[List[Literal["fixed", "percent"]]] = None + """Supported contribution types. + + An empty array indicates contributions are not supported. + """ + + description: Optional[str] = None + + employee_deduction: Optional[List[Literal["fixed", "percent"]]] = None + """Supported deduction types. + + An empty array indicates deductions are not supported. + """ + + frequencies: Optional[List[Optional[BenefitFrequency]]] = None + """The list of frequencies supported by the provider for this benefit""" + + hsa_contribution_limit: Optional[List[Literal["individual", "family"]]] = None + """Whether the provider supports HSA contribution limits. + + Empty if this feature is not supported for the benefit. This array only has + values for HSA benefits. + """ + + +class BenefitFeaturesAndOperations(BaseModel): + supported_features: Optional[SupportedFeatures] = None + + supported_operations: Optional[SupportPerBenefitType] = None diff --git a/src/finch/types/hris/benefit_frequency.py b/src/finch/types/hris/benefit_frequency.py index b4a7414f..515774fe 100644 --- a/src/finch/types/hris/benefit_frequency.py +++ b/src/finch/types/hris/benefit_frequency.py @@ -5,4 +5,4 @@ __all__ = ["BenefitFrequency"] -BenefitFrequency = Optional[Literal["one_time", "every_paycheck"]] +BenefitFrequency = Optional[Literal["one_time", "every_paycheck", "monthly"]] diff --git a/src/finch/types/hris/benefits_support.py b/src/finch/types/hris/benefits_support.py new file mode 100644 index 00000000..7cf88efa --- /dev/null +++ b/src/finch/types/hris/benefits_support.py @@ -0,0 +1,41 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing import TYPE_CHECKING, Optional + +from ..._models import BaseModel +from .benefit_features_and_operations import BenefitFeaturesAndOperations + +__all__ = ["BenefitsSupport"] + + +class BenefitsSupport(BaseModel): + commuter: Optional[BenefitFeaturesAndOperations] = None + + custom_post_tax: Optional[BenefitFeaturesAndOperations] = None + + custom_pre_tax: Optional[BenefitFeaturesAndOperations] = None + + fsa_dependent_care: Optional[BenefitFeaturesAndOperations] = None + + fsa_medical: Optional[BenefitFeaturesAndOperations] = None + + hsa_post: Optional[BenefitFeaturesAndOperations] = None + + hsa_pre: Optional[BenefitFeaturesAndOperations] = None + + s125_dental: Optional[BenefitFeaturesAndOperations] = None + + s125_medical: Optional[BenefitFeaturesAndOperations] = None + + s125_vision: Optional[BenefitFeaturesAndOperations] = None + + simple: Optional[BenefitFeaturesAndOperations] = None + + simple_ira: Optional[BenefitFeaturesAndOperations] = None + + if TYPE_CHECKING: + # Stub to indicate that arbitrary properties are accepted. + # To access properties that are not valid identifiers you can use `getattr`, e.g. + # `getattr(obj, '$type')` + def __getattr__(self, attr: str) -> Optional[BenefitFeaturesAndOperations]: + ... diff --git a/src/finch/types/hris/employment_data.py b/src/finch/types/hris/employment_data.py index 1de9c0f8..a0bf1220 100644 --- a/src/finch/types/hris/employment_data.py +++ b/src/finch/types/hris/employment_data.py @@ -3,13 +3,17 @@ from typing import List, Optional from typing_extensions import Literal -from pydantic import Field as FieldInfo - from ..income import Income from ..._models import BaseModel from ..location import Location -__all__ = ["EmploymentData", "Department", "Employment", "Manager"] +__all__ = ["EmploymentData", "CustomField", "Department", "Employment", "Manager"] + + +class CustomField(BaseModel): + name: Optional[str] = None + + value: Optional[object] = None class Department(BaseModel): @@ -41,6 +45,12 @@ class EmploymentData(BaseModel): class_code: Optional[str] = None """Worker's compensation classification code for this employee""" + custom_fields: Optional[List[CustomField]] = None + """Custom fields for the individual. + + These are fields which are defined by the employer in the system. + """ + department: Optional[Department] = None """The department object.""" @@ -96,7 +106,7 @@ class EmploymentData(BaseModel): Please reach out to your Finch representative if you would like access. """ - work_id2: Optional[str] = FieldInfo(alias="work_id_2", default=None) + work_id_2: 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. diff --git a/src/finch/types/hris/individual.py b/src/finch/types/hris/individual.py index 89e3e5bf..8f8ae112 100644 --- a/src/finch/types/hris/individual.py +++ b/src/finch/types/hris/individual.py @@ -29,6 +29,20 @@ class Individual(BaseModel): emails: Optional[List[Email]] = None + ethnicity: Optional[ + Literal[ + "asian", + "white", + "black_or_african_american", + "native_hawaiian_or_pacific_islander", + "american_indian_or_alaska_native", + "hispanic_or_latino", + "two_or_more_races", + "decline_to_specify", + ] + ] = None + """The EEOC-defined ethnicity of the individual.""" + first_name: Optional[str] = None """The legal first name of the individual.""" diff --git a/src/finch/types/hris/support_per_benefit_type.py b/src/finch/types/hris/support_per_benefit_type.py new file mode 100644 index 00000000..2ce0dcee --- /dev/null +++ b/src/finch/types/hris/support_per_benefit_type.py @@ -0,0 +1,14 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing import Optional + +from ..shared import OperationSupportMatrix +from ..._models import BaseModel + +__all__ = ["SupportPerBenefitType"] + + +class SupportPerBenefitType(BaseModel): + company_benefits: Optional[OperationSupportMatrix] = None + + individual_benefits: Optional[OperationSupportMatrix] = None diff --git a/src/finch/types/introspection.py b/src/finch/types/introspection.py index a8f26da6..b9d15202 100644 --- a/src/finch/types/introspection.py +++ b/src/finch/types/introspection.py @@ -8,6 +8,9 @@ class Introspection(BaseModel): + account_id: str + """The Finch uuid of the account used to connect this company.""" + client_id: str """The client id of the application associated with the `access_token`.""" diff --git a/src/finch/types/provider.py b/src/finch/types/provider.py index 880077a0..0b732b0f 100644 --- a/src/finch/types/provider.py +++ b/src/finch/types/provider.py @@ -1,16 +1,397 @@ # File generated from our OpenAPI spec by Stainless. from typing import List, Optional +from typing_extensions import Literal +from .hris import BenefitsSupport from .._models import BaseModel -__all__ = ["Provider"] +__all__ = [ + "Provider", + "AuthenticationMethod", + "AuthenticationMethodSupportedFields", + "AuthenticationMethodSupportedFieldsCompany", + "AuthenticationMethodSupportedFieldsCompanyAccounts", + "AuthenticationMethodSupportedFieldsCompanyDepartments", + "AuthenticationMethodSupportedFieldsCompanyDepartmentsParent", + "AuthenticationMethodSupportedFieldsCompanyEntity", + "AuthenticationMethodSupportedFieldsCompanyLocations", + "AuthenticationMethodSupportedFieldsDirectory", + "AuthenticationMethodSupportedFieldsDirectoryIndividuals", + "AuthenticationMethodSupportedFieldsDirectoryIndividualsManager", + "AuthenticationMethodSupportedFieldsDirectoryPaging", + "AuthenticationMethodSupportedFieldsEmployment", + "AuthenticationMethodSupportedFieldsEmploymentDepartment", + "AuthenticationMethodSupportedFieldsEmploymentEmployment", + "AuthenticationMethodSupportedFieldsEmploymentIncome", + "AuthenticationMethodSupportedFieldsEmploymentLocation", + "AuthenticationMethodSupportedFieldsIndividual", + "AuthenticationMethodSupportedFieldsIndividualEmails", + "AuthenticationMethodSupportedFieldsIndividualPhoneNumbers", + "AuthenticationMethodSupportedFieldsIndividualResidence", + "AuthenticationMethodSupportedFieldsPayStatement", + "AuthenticationMethodSupportedFieldsPayStatementPaging", + "AuthenticationMethodSupportedFieldsPayStatementPayStatements", + "AuthenticationMethodSupportedFieldsPayStatementPayStatementsEarnings", + "AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployeeDeductions", + "AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployerDeductions", + "AuthenticationMethodSupportedFieldsPayStatementPayStatementsTaxes", + "AuthenticationMethodSupportedFieldsPayment", + "AuthenticationMethodSupportedFieldsPaymentPayPeriod", +] + + +class AuthenticationMethodSupportedFieldsCompanyAccounts(BaseModel): + account_name: Optional[bool] = None + + account_number: Optional[bool] = None + + account_type: Optional[bool] = None + + institution_name: Optional[bool] = None + + routing_number: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsCompanyDepartmentsParent(BaseModel): + name: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsCompanyDepartments(BaseModel): + name: Optional[bool] = None + + parent: Optional[AuthenticationMethodSupportedFieldsCompanyDepartmentsParent] = None + + +class AuthenticationMethodSupportedFieldsCompanyEntity(BaseModel): + subtype: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsCompanyLocations(BaseModel): + city: Optional[bool] = None + + country: Optional[bool] = None + + line1: Optional[bool] = None + + line2: Optional[bool] = None + + postal_code: Optional[bool] = None + + state: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsCompany(BaseModel): + id: Optional[bool] = None + + accounts: Optional[AuthenticationMethodSupportedFieldsCompanyAccounts] = None + + departments: Optional[AuthenticationMethodSupportedFieldsCompanyDepartments] = None + + ein: Optional[bool] = None + + entity: Optional[AuthenticationMethodSupportedFieldsCompanyEntity] = None + + legal_name: Optional[bool] = None + + locations: Optional[AuthenticationMethodSupportedFieldsCompanyLocations] = None + + primary_email: Optional[bool] = None + + primary_phone_number: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsDirectoryIndividualsManager(BaseModel): + id: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsDirectoryIndividuals(BaseModel): + id: Optional[bool] = None + + department: Optional[bool] = None + + first_name: Optional[bool] = None + + is_active: Optional[bool] = None + + last_name: Optional[bool] = None + + manager: Optional[AuthenticationMethodSupportedFieldsDirectoryIndividualsManager] = None + + middle_name: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsDirectoryPaging(BaseModel): + count: Optional[bool] = None + + offset: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsDirectory(BaseModel): + individuals: Optional[AuthenticationMethodSupportedFieldsDirectoryIndividuals] = None + + paging: Optional[AuthenticationMethodSupportedFieldsDirectoryPaging] = None + + +class AuthenticationMethodSupportedFieldsEmploymentDepartment(BaseModel): + name: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsEmploymentEmployment(BaseModel): + subtype: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsEmploymentIncome(BaseModel): + amount: Optional[bool] = None + + currency: Optional[bool] = None + + unit: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsEmploymentLocation(BaseModel): + city: Optional[bool] = None + + country: Optional[bool] = None + + line1: Optional[bool] = None + + line2: Optional[bool] = None + + postal_code: Optional[bool] = None + + state: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsEmployment(BaseModel): + id: Optional[bool] = None + + class_code: Optional[bool] = None + + custom_fields: Optional[bool] = None + + department: Optional[AuthenticationMethodSupportedFieldsEmploymentDepartment] = None + + employment: Optional[AuthenticationMethodSupportedFieldsEmploymentEmployment] = None + + end_date: Optional[bool] = None + + first_name: Optional[bool] = None + + income_history: Optional[bool] = None + + income: Optional[AuthenticationMethodSupportedFieldsEmploymentIncome] = None + + is_active: Optional[bool] = None + + last_name: Optional[bool] = None + + location: Optional[AuthenticationMethodSupportedFieldsEmploymentLocation] = None + + manager: Optional[object] = None + + middle_name: Optional[bool] = None + + start_date: Optional[bool] = None + + title: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsIndividualEmails(BaseModel): + data: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsIndividualPhoneNumbers(BaseModel): + data: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsIndividualResidence(BaseModel): + city: Optional[bool] = None + + country: Optional[bool] = None + + line1: Optional[bool] = None + + line2: Optional[bool] = None + + postal_code: Optional[bool] = None + + state: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsIndividual(BaseModel): + id: Optional[bool] = None + + dob: Optional[bool] = None + + emails: Optional[AuthenticationMethodSupportedFieldsIndividualEmails] = None + + ethnicity: Optional[bool] = None + + first_name: Optional[bool] = None + + gender: Optional[bool] = None + + last_name: Optional[bool] = None + + middle_name: Optional[bool] = None + + phone_numbers: Optional[AuthenticationMethodSupportedFieldsIndividualPhoneNumbers] = None + + preferred_name: Optional[bool] = None + + residence: Optional[AuthenticationMethodSupportedFieldsIndividualResidence] = None + + ssn: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatementPaging(BaseModel): + count: bool + + offset: bool + + +class AuthenticationMethodSupportedFieldsPayStatementPayStatementsEarnings(BaseModel): + amount: Optional[bool] = None + + currency: Optional[bool] = None + + name: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployeeDeductions(BaseModel): + amount: Optional[bool] = None + + currency: Optional[bool] = None + + name: Optional[bool] = None + + pre_tax: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployerDeductions(BaseModel): + amount: Optional[bool] = None + + currency: Optional[bool] = None + + name: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatementPayStatementsTaxes(BaseModel): + amount: Optional[bool] = None + + currency: Optional[bool] = None + + employer: Optional[bool] = None + + name: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatementPayStatements(BaseModel): + earnings: Optional[AuthenticationMethodSupportedFieldsPayStatementPayStatementsEarnings] = None + + employee_deductions: Optional[AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployeeDeductions] = None + + employer_deductions: Optional[AuthenticationMethodSupportedFieldsPayStatementPayStatementsEmployerDeductions] = None + + gross_pay: Optional[bool] = None + + individual_id: Optional[bool] = None + + net_pay: Optional[bool] = None + + payment_method: Optional[bool] = None + + taxes: Optional[AuthenticationMethodSupportedFieldsPayStatementPayStatementsTaxes] = None + + total_hours: Optional[bool] = None + + type: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayStatement(BaseModel): + paging: Optional[AuthenticationMethodSupportedFieldsPayStatementPaging] = None + + pay_statements: Optional[AuthenticationMethodSupportedFieldsPayStatementPayStatements] = None + + +class AuthenticationMethodSupportedFieldsPaymentPayPeriod(BaseModel): + end_date: Optional[bool] = None + + start_date: Optional[bool] = None + + +class AuthenticationMethodSupportedFieldsPayment(BaseModel): + id: Optional[bool] = None + + company_debit: Optional[bool] = None + + debit_date: Optional[bool] = None + + employee_taxes: Optional[bool] = None + + employer_taxes: Optional[bool] = None + + gross_pay: Optional[bool] = None + + individual_ids: Optional[bool] = None + + net_pay: Optional[bool] = None + + pay_date: Optional[bool] = None + + pay_period: Optional[AuthenticationMethodSupportedFieldsPaymentPayPeriod] = None + + +class AuthenticationMethodSupportedFields(BaseModel): + company: Optional[AuthenticationMethodSupportedFieldsCompany] = None + + directory: Optional[AuthenticationMethodSupportedFieldsDirectory] = None + + employment: Optional[AuthenticationMethodSupportedFieldsEmployment] = None + + individual: Optional[AuthenticationMethodSupportedFieldsIndividual] = None + + pay_statement: Optional[AuthenticationMethodSupportedFieldsPayStatement] = None + + payment: Optional[AuthenticationMethodSupportedFieldsPayment] = None + + +class AuthenticationMethod(BaseModel): + benefits_support: Optional[BenefitsSupport] = None + """Each benefit type and their supported features. + + If the benefit type is not supported, the property will be null + """ + + supported_fields: Optional[AuthenticationMethodSupportedFields] = None + """The supported data fields returned by our HR and payroll endpoints""" + + type: Optional[Literal["assisted", "credential", "api_token", "api_credential", "oauth"]] = None + """The type of authentication method.""" class Provider(BaseModel): id: Optional[str] = None """The id of the payroll provider used in Connect.""" + authentication_methods: Optional[List[AuthenticationMethod]] = None + """The list of authentication methods supported by the provider.""" + display_name: Optional[str] = None """The display name of the payroll provider.""" @@ -22,8 +403,9 @@ class Provider(BaseModel): manual: Optional[bool] = None """ - Whether the Finch integration with this provider uses the Assisted Connect Flow - by default. + [DEPRECATED] Whether the Finch integration with this provider uses the Assisted + Connect Flow by default. This field is now deprecated. Please check for a `type` + of `assisted` in the `authentication_methods` field instead. """ mfa_required: Optional[bool] = None diff --git a/src/finch/types/request_forwarding_forward_params.py b/src/finch/types/request_forwarding_forward_params.py index 50b7dfcc..19fe0062 100644 --- a/src/finch/types/request_forwarding_forward_params.py +++ b/src/finch/types/request_forwarding_forward_params.py @@ -12,7 +12,7 @@ class RequestForwardingForwardParams(TypedDict, total=False): method: Required[str] """The HTTP method for the forwarded request. - Valid values include: `GET`, `POST`, `PUT`, `DELETE`, and `PATCH`. + Valid values include: `GET` , `POST` , `PUT` , `DELETE` , and `PATCH`. """ route: Required[str] diff --git a/src/finch/types/request_forwarding_forward_response.py b/src/finch/types/request_forwarding_forward_response.py index 508c8e89..267a4cf9 100644 --- a/src/finch/types/request_forwarding_forward_response.py +++ b/src/finch/types/request_forwarding_forward_response.py @@ -26,7 +26,7 @@ class Request(BaseModel): method: str """The HTTP method that was specified for the forwarded request. - Valid values include: `GET`, `POST`, `PUT` , `DELETE`, and `PATCH`. + Valid values include: `GET` , `POST` , `PUT` , `DELETE` , and `PATCH`. """ params: Optional[object] diff --git a/src/finch/types/shared/__init__.py b/src/finch/types/shared/__init__.py new file mode 100644 index 00000000..3be971aa --- /dev/null +++ b/src/finch/types/shared/__init__.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. + +from .operation_support import OperationSupport as OperationSupport +from .operation_support_matrix import OperationSupportMatrix as OperationSupportMatrix diff --git a/src/finch/types/shared/operation_support.py b/src/finch/types/shared/operation_support.py new file mode 100644 index 00000000..9ae5ebf6 --- /dev/null +++ b/src/finch/types/shared/operation_support.py @@ -0,0 +1,7 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing_extensions import Literal + +__all__ = ["OperationSupport"] + +OperationSupport = Literal["supported", "not_supported_by_finch", "not_supported_by_provider", "client_access_only"] diff --git a/src/finch/types/shared/operation_support_matrix.py b/src/finch/types/shared/operation_support_matrix.py new file mode 100644 index 00000000..c8b8ec58 --- /dev/null +++ b/src/finch/types/shared/operation_support_matrix.py @@ -0,0 +1,54 @@ +# File generated from our OpenAPI spec by Stainless. + +from typing import Optional + +from ..._models import BaseModel +from .operation_support import OperationSupport + +__all__ = ["OperationSupportMatrix"] + + +class OperationSupportMatrix(BaseModel): + create: Optional[OperationSupport] = None + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + delete: Optional[OperationSupport] = None + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + read: Optional[OperationSupport] = None + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + update: Optional[OperationSupport] = None + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ diff --git a/src/finch/types/shared_params/__init__.py b/src/finch/types/shared_params/__init__.py new file mode 100644 index 00000000..3be971aa --- /dev/null +++ b/src/finch/types/shared_params/__init__.py @@ -0,0 +1,4 @@ +# File generated from our OpenAPI spec by Stainless. + +from .operation_support import OperationSupport as OperationSupport +from .operation_support_matrix import OperationSupportMatrix as OperationSupportMatrix diff --git a/src/finch/types/shared_params/operation_support.py b/src/finch/types/shared_params/operation_support.py new file mode 100644 index 00000000..290a5214 --- /dev/null +++ b/src/finch/types/shared_params/operation_support.py @@ -0,0 +1,9 @@ +# File generated from our OpenAPI spec by Stainless. + +from __future__ import annotations + +from typing_extensions import Literal + +__all__ = ["OperationSupport"] + +OperationSupport = Literal["supported", "not_supported_by_finch", "not_supported_by_provider", "client_access_only"] diff --git a/src/finch/types/shared_params/operation_support_matrix.py b/src/finch/types/shared_params/operation_support_matrix.py new file mode 100644 index 00000000..4fa6df6b --- /dev/null +++ b/src/finch/types/shared_params/operation_support_matrix.py @@ -0,0 +1,56 @@ +# File generated from our OpenAPI spec by Stainless. + +from __future__ import annotations + +from typing_extensions import TypedDict + +from ..shared import OperationSupport +from .operation_support import OperationSupport + +__all__ = ["OperationSupportMatrix"] + + +class OperationSupportMatrix(TypedDict, total=False): + create: OperationSupport + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + delete: OperationSupport + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + read: OperationSupport + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ + + update: OperationSupport + """ + - `supported`: This operation is supported by both the provider and Finch
+ - `not_supported_by_finch`: This operation is not supported by Finch but + supported by the provider
+ - `not_supported_by_provider`: This operation is not supported by the provider, + so Finch cannot support
+ - `client_access_only`: This behavior is supported by the provider, but only + available to the client and not to Finch + """ diff --git a/tests/test_client.py b/tests/test_client.py index d2644f51..d6013ba5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,6 +26,8 @@ make_request_options, ) +from .utils import update_env + base_url = os.environ.get("TEST_API_BASE_URL", "http://127.0.0.1:4010") access_token = "My Access Token" @@ -41,12 +43,12 @@ class TestFinch: @pytest.mark.respx(base_url=base_url) def test_raw_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json='{"foo": "bar"}')) + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) response = self.client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) - assert response.json() == '{"foo": "bar"}' + assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: @@ -57,7 +59,7 @@ def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: response = self.client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) - assert response.json() == '{"foo": "bar"}' + assert response.json() == {"foo": "bar"} def test_copy(self) -> None: copied = self.client.copy() @@ -418,6 +420,11 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 + 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) + assert client.base_url == "http://localhost:5000/from/env/" + @pytest.mark.parametrize( "client", [ @@ -683,12 +690,12 @@ class TestAsyncFinch: @pytest.mark.respx(base_url=base_url) @pytest.mark.asyncio async def test_raw_response(self, respx_mock: MockRouter) -> None: - respx_mock.post("/foo").mock(return_value=httpx.Response(200, json='{"foo": "bar"}')) + respx_mock.post("/foo").mock(return_value=httpx.Response(200, json={"foo": "bar"})) response = await self.client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) - assert response.json() == '{"foo": "bar"}' + assert response.json() == {"foo": "bar"} @pytest.mark.respx(base_url=base_url) @pytest.mark.asyncio @@ -700,7 +707,7 @@ async def test_raw_response_for_binary(self, respx_mock: MockRouter) -> None: response = await self.client.post("/foo", cast_to=httpx.Response) assert response.status_code == 200 assert isinstance(response, httpx.Response) - assert response.json() == '{"foo": "bar"}' + assert response.json() == {"foo": "bar"} def test_copy(self) -> None: copied = self.client.copy() @@ -1061,6 +1068,11 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 + 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) + assert client.base_url == "http://localhost:5000/from/env/" + @pytest.mark.parametrize( "client", [ diff --git a/tests/test_transform.py b/tests/test_transform.py index 83a905bb..7b5f1f7c 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -7,6 +7,7 @@ import pytest from finch._utils import PropertyInfo, transform, parse_datetime +from finch._compat import PYDANTIC_V2 from finch._models import BaseModel @@ -210,14 +211,20 @@ def test_pydantic_unknown_field() -> None: def test_pydantic_mismatched_types() -> None: model = MyModel.construct(foo=True) - with pytest.warns(UserWarning): + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = transform(model, Any) + else: params = transform(model, Any) assert params == {"foo": True} def test_pydantic_mismatched_object_type() -> None: model = MyModel.construct(foo=MyModel.construct(hello="world")) - with pytest.warns(UserWarning): + if PYDANTIC_V2: + with pytest.warns(UserWarning): + params = transform(model, Any) + else: params = transform(model, Any) assert params == {"foo": {"hello": "world"}} @@ -230,3 +237,29 @@ def test_pydantic_nested_objects() -> None: model = ModelNestedObjects.construct(nested={"foo": "stainless"}) assert isinstance(model.nested, MyModel) assert transform(model, Any) == {"nested": {"foo": "stainless"}} + + +class ModelWithDefaultField(BaseModel): + foo: str + with_none_default: Union[str, None] = None + with_str_default: str = "foo" + + +def test_pydantic_default_field() -> None: + # should be excluded when defaults are used + model = ModelWithDefaultField.construct() + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert transform(model, Any) == {} + + # should be included when the default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default=None, with_str_default="foo") + assert model.with_none_default is None + assert model.with_str_default == "foo" + assert transform(model, Any) == {"with_none_default": None, "with_str_default": "foo"} + + # should be included when a non-default value is explicitly given + model = ModelWithDefaultField.construct(with_none_default="bar", with_str_default="baz") + assert model.with_none_default == "bar" + assert model.with_str_default == "baz" + assert transform(model, Any) == {"with_none_default": "bar", "with_str_default": "baz"} diff --git a/tests/utils.py b/tests/utils.py index be21e620..bc62203d 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,9 @@ from __future__ import annotations +import os import traceback -from typing import Any, TypeVar, cast +import contextlib +from typing import Any, TypeVar, Iterator, cast from datetime import date, datetime from typing_extensions import Literal, get_args, get_origin, assert_type @@ -103,3 +105,16 @@ def _assert_list_type(type_: type[object], value: object) -> None: inner_type = get_args(type_)[0] for entry in value: assert_type(inner_type, entry) # type: ignore + + +@contextlib.contextmanager +def update_env(**new_env: str) -> Iterator[None]: + old = os.environ.copy() + + try: + os.environ.update(new_env) + + yield None + finally: + os.environ.clear() + os.environ.update(old)