From 1b728b4af55c2fc41ff09703adbfafae0aa743e0 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 12 May 2023 15:03:33 +0100 Subject: [PATCH 01/14] chore(internal): update changelog config (#5) --- release-please-config.json | 60 +++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index eb41d1e6..0f8206d7 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,12 +1,64 @@ { - "include-v-in-tag": true, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "pull-request-header": "Automated Release PR", "packages": { ".": {} }, "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "pull-request-header": "Automated Release PR", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Miscellaneous Chores", + "hidden": true + }, + { + "type": "docs", + "section": "Documentation", + "hidden": true + }, + { + "type": "style", + "section": "Styles", + "hidden": true + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System", + "hidden": true + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], "release-type": "python", "extra-files": [ "src/finch/_version.py" From 28f6e2bccada387a0697b4e52b58c5462f92eaf6 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 15 May 2023 21:10:52 +0100 Subject: [PATCH 02/14] chore(internal): fix bug with transform utility & key aliases (#7) --- src/finch/_utils/_transform.py | 2 +- tests/test_transform.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/finch/_utils/_transform.py b/src/finch/_utils/_transform.py index 5bb03ea3..839d3d03 100644 --- a/src/finch/_utils/_transform.py +++ b/src/finch/_utils/_transform.py @@ -115,7 +115,7 @@ def _maybe_transform_key(key: str, type_: type) -> str: return key # ignore the first argument as it is the actual type - annotations = get_args(type_)[1:] + annotations = get_args(annotated_type)[1:] for annotation in annotations: if isinstance(annotation, PropertyInfo) and annotation.alias is not None: return annotation.alias diff --git a/tests/test_transform.py b/tests/test_transform.py index 00ca4b72..5becb2f8 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -177,3 +177,12 @@ def test_datetime_custom_format() -> None: result = transform(dt, Annotated[datetime, PropertyInfo(format="custom", format_template="%H")]) assert result == "06" # type: ignore[comparison-overlap] + + +class DateDictWithRequiredAlias(TypedDict, total=False): + required_prop: Required[Annotated[date, PropertyInfo(format="iso8601", alias="prop")]] + + +def test_datetime_with_alias() -> None: + assert transform({"required_prop": None}, DateDictWithRequiredAlias) == {"prop": None} # type: ignore[comparison-overlap] + assert transform({"required_prop": date.fromisoformat("2023-02-23")}, DateDictWithRequiredAlias) == {"prop": "2023-02-23"} # type: ignore[comparison-overlap] From 1e59ecdd062902a4f521c058b2e411edc59d88e9 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 17 May 2023 20:47:11 +0100 Subject: [PATCH 03/14] chore(internal): fix workflow comment url (#8) --- .github/workflows/publish-pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index f2e1fc6f..9e7b6d4d 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,5 +1,5 @@ # workflow for re-running publishing to PyPI in case it fails for some reason -# you can run this workflow by navigating to https://www.github.com/Finch-API/Finch-API/finch-api-python/actions/workflows/publish-pypi.yml +# you can run this workflow by navigating to https://www.github.com/Finch-API/finch-api-python/actions/workflows/publish-pypi.yml name: Publish PyPI on: workflow_dispatch: From ef009cf28779f3f6fd99483c13b9cb3390ee7af3 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 18 May 2023 10:12:02 +0100 Subject: [PATCH 04/14] chore(internal): add tests for base url handling (#9) --- tests/test_client.py | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/tests/test_client.py b/tests/test_client.py index 16bf5b78..1bc0a33e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -342,6 +342,32 @@ class Model2(BaseModel): assert isinstance(response, Model1) assert response.foo == 1 + def test_base_url_trailing_slash(self) -> None: + client = Finch( + base_url="http://localhost:5000/custom/path/", access_token=access_token, _strict_response_validation=True + ) + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + def test_base_url_no_trailing_slash(self) -> None: + client = Finch( + base_url="http://localhost:5000/custom/path", access_token=access_token, _strict_response_validation=True + ) + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + class TestAsyncFinch: client = AsyncFinch(base_url=base_url, access_token=access_token, _strict_response_validation=True) @@ -658,3 +684,29 @@ class Model2(BaseModel): response = await self.client.get("/foo", cast_to=cast(Any, Union[Model1, Model2])) assert isinstance(response, Model1) assert response.foo == 1 + + def test_base_url_trailing_slash(self) -> None: + client = AsyncFinch( + base_url="http://localhost:5000/custom/path/", access_token=access_token, _strict_response_validation=True + ) + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" + + def test_base_url_no_trailing_slash(self) -> None: + client = AsyncFinch( + base_url="http://localhost:5000/custom/path", access_token=access_token, _strict_response_validation=True + ) + request = client._build_request( + FinalRequestOptions( + method="post", + url="/foo", + json_data={"foo": "bar"}, + ), + ) + assert request.url == "http://localhost:5000/custom/path/foo" From 0759138503d13758fc4ab794dcb5aedf16bcdf83 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 19 May 2023 15:02:55 +0100 Subject: [PATCH 05/14] chore(internal): update lock file (#10) --- poetry.lock | 52 ++++++---------------------------------------------- 1 file changed, 6 insertions(+), 46 deletions(-) diff --git a/poetry.lock b/poetry.lock index 5df5bfbc..14297edf 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.0 and should not be changed by hand. [[package]] name = "anyio" version = "3.6.2" description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" optional = false python-versions = ">=3.6.2" files = [ @@ -26,7 +25,6 @@ trio = ["trio (>=0.16,<0.22)"] name = "atomicwrites" version = "1.4.1" description = "Atomic file writes." -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -37,7 +35,6 @@ files = [ name = "attrs" version = "22.1.0" description = "Classes Without Boilerplate" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -55,7 +52,6 @@ tests-no-zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy name = "black" version = "22.10.0" description = "The uncompromising code formatter." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -101,7 +97,6 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2022.9.24" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -113,7 +108,6 @@ files = [ name = "click" version = "8.1.3" description = "Composable command line interface toolkit" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -129,7 +123,6 @@ importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -141,7 +134,6 @@ files = [ name = "distro" version = "1.8.0" description = "Distro - an OS platform information API" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -153,7 +145,6 @@ files = [ name = "h11" version = "0.12.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.6" files = [ @@ -165,7 +156,6 @@ files = [ name = "httpcore" version = "0.15.0" description = "A minimal low-level HTTP client." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -174,20 +164,19 @@ files = [ ] [package.dependencies] -anyio = ">=3.0.0,<4.0.0" +anyio = "==3.*" certifi = "*" h11 = ">=0.11,<0.13" -sniffio = ">=1.0.0,<2.0.0" +sniffio = "==1.*" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "httpx" version = "0.23.0" description = "The next generation HTTP client." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -203,15 +192,14 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<13)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -223,7 +211,6 @@ files = [ name = "importlib-metadata" version = "5.0.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -244,7 +231,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "1.1.1" description = "iniconfig: brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = "*" files = [ @@ -256,7 +242,6 @@ files = [ name = "isort" version = "5.10.1" description = "A Python utility / library to sort Python imports." -category = "dev" optional = false python-versions = ">=3.6.1,<4.0" files = [ @@ -274,7 +259,6 @@ requirements-deprecated-finder = ["pip-api", "pipreqs"] name = "mypy" version = "1.1.1" description = "Optional static typing for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -322,7 +306,6 @@ reports = ["lxml"] name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -334,7 +317,6 @@ files = [ name = "nodeenv" version = "1.7.0" description = "Node.js virtual environment builder" -category = "dev" optional = false python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" files = [ @@ -349,7 +331,6 @@ setuptools = "*" name = "packaging" version = "21.3" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -364,7 +345,6 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" name = "pathspec" version = "0.10.1" description = "Utility library for gitignore style pattern matching of file paths." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -376,7 +356,6 @@ files = [ name = "platformdirs" version = "2.5.2" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -392,7 +371,6 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -411,7 +389,6 @@ testing = ["pytest", "pytest-benchmark"] name = "py" version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -423,7 +400,6 @@ files = [ name = "pydantic" version = "1.10.2" description = "Data validation and settings management using python type hints" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -476,7 +452,6 @@ email = ["email-validator (>=1.0.3)"] name = "pyparsing" version = "3.0.9" description = "pyparsing module - Classes and methods to define and execute parsing grammars" -category = "dev" optional = false python-versions = ">=3.6.8" files = [ @@ -491,7 +466,6 @@ diagrams = ["jinja2", "railroad-diagrams"] name = "pyright" version = "1.1.297" description = "Command line wrapper for pyright" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -511,7 +485,6 @@ dev = ["twine (>=3.4.1)"] name = "pytest" version = "7.1.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -537,7 +510,6 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2. name = "pytest-asyncio" version = "0.18.3" description = "Pytest support for asyncio" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -557,7 +529,6 @@ testing = ["coverage (==6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -572,7 +543,6 @@ six = ">=1.5" name = "respx" version = "0.19.2" description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -587,7 +557,6 @@ httpx = ">=0.21.0" name = "rfc3986" version = "1.5.0" description = "Validating URI References per RFC 3986" -category = "main" optional = false python-versions = "*" files = [ @@ -605,7 +574,6 @@ idna2008 = ["idna"] name = "ruff" version = "0.0.239" description = "An extremely fast Python linter, written in Rust." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -631,7 +599,6 @@ files = [ name = "setuptools" version = "67.4.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -648,7 +615,6 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs ( name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -660,7 +626,6 @@ files = [ name = "sniffio" version = "1.3.0" description = "Sniff out which async library your code is running under" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -672,7 +637,6 @@ files = [ name = "time-machine" version = "2.9.0" description = "Travel through time in your tests." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -738,7 +702,6 @@ python-dateutil = "*" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -750,7 +713,6 @@ files = [ name = "typed-ast" version = "1.5.4" description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -784,7 +746,6 @@ files = [ name = "typing-extensions" version = "4.4.0" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -796,7 +757,6 @@ files = [ name = "zipp" version = "3.10.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ From 1698853caaa6b7dfb8211a23a8dfb75835c3cc2e Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Tue, 23 May 2023 02:55:41 +0100 Subject: [PATCH 06/14] fix(sse): small improvement to handling server-sent events (#11) --- src/finch/_base_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 43e23178..6423b74a 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -626,7 +626,7 @@ def _process_response_data( def _process_stream_line(self, contents: str) -> str: """Pre-process an indiviudal line from a streaming response""" - if contents == "data: [DONE]\n": + if contents.startswith("data: [DONE]"): raise StopStreaming() if contents.startswith("data: "): From db17246715cd21fdd03afce5c0943f345456121a Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Wed, 24 May 2023 11:33:35 +0100 Subject: [PATCH 07/14] chore(internal): minor formatting change (#12) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 7894508d..403a4780 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ ruff = "0.0.239" isort = "5.10.1" time-machine = "^2.9.0" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From 5b2571fbf5ea0f442fe16cee281aba7226b0fc72 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Tue, 30 May 2023 13:57:29 +0100 Subject: [PATCH 08/14] chore(internal): add empty request preparation method (#13) --- src/finch/_base_client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 6423b74a..2cc8772d 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -482,6 +482,15 @@ def _build_headers(self, options: FinalRequestOptions) -> httpx.Headers: return headers + def _prepare_request(self, request: httpx.Request) -> None: + """This method is used as a callback for mutating the `Request` object + after it has been constructed. + + This is useful for cases where you want to add certain headers based off of + the request properties, e.g. `url`, `method` etc. + """ + return None + def _build_request( self, options: FinalRequestOptions, @@ -519,7 +528,7 @@ def _build_request( kwargs["data"] = self._serialize_multipartform(json_data) # TODO: report this error to httpx - return self._client.build_request( # pyright: ignore[reportUnknownMemberType] + request = self._client.build_request( # pyright: ignore[reportUnknownMemberType] headers=headers, timeout=self.timeout if isinstance(options.timeout, NotGiven) else options.timeout, method=options.method, @@ -533,6 +542,8 @@ def _build_request( files=options.files, **kwargs, ) + self._prepare_request(request) + return request def _serialize_multipartform(self, data: Mapping[object, object]) -> dict[str, object]: items = self.qs.stringify_items( From bedcf30a794580e6a112bbdfc8e335e89d972908 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 2 Jun 2023 17:12:23 +0100 Subject: [PATCH 09/14] chore(internal): restructure core streaming implementation (#14) --- README.md | 2 +- src/finch/_base_client.py | 232 +++++++++++++------------------------- src/finch/_client.py | 13 ++- src/finch/_streaming.py | 204 +++++++++++++++++++++++++++++++++ src/finch/_types.py | 11 ++ tests/test_streaming.py | 104 +++++++++++++++++ 6 files changed, 409 insertions(+), 157 deletions(-) create mode 100644 src/finch/_streaming.py create mode 100644 tests/test_streaming.py diff --git a/README.md b/README.md index f7c97805..9313c47e 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ If you need to, you can override it by setting default headers per-request or on from finch import Finch finch = Finch( - default_headers={"Finch-API-Version": My - Custom - Value}, + default_headers={"Finch-API-Version": "My-Custom-Value"}, ) ``` diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index 2cc8772d..b7953442 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -9,7 +9,6 @@ from typing import ( Any, Dict, - List, Type, Union, Generic, @@ -45,6 +44,7 @@ Timeout, NoneType, NotGiven, + ResponseT, Transport, AnyMapping, ProxiesTypes, @@ -61,6 +61,7 @@ validate_type, construct_type, ) +from ._streaming import Stream, AsyncStream from ._base_exceptions import ( APIStatusError, APITimeoutError, @@ -73,128 +74,23 @@ AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]") -ResponseT = TypeVar( - "ResponseT", - bound=Union[ - str, - None, - BaseModel, - List[Any], - Dict[str, Any], - httpx.Response, - UnknownResponse, - ModelBuilderProtocol, - ], -) - _T = TypeVar("_T") _T_co = TypeVar("_T_co", covariant=True) +_StreamT = TypeVar("_StreamT", bound=Stream[Any]) +_AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) + + DEFAULT_TIMEOUT = Timeout(timeout=60.0, connect=5.0) DEFAULT_MAX_RETRIES = 2 DEFAULT_LIMITS = Limits(max_connections=100, max_keepalive_connections=20) -class StopStreaming(Exception): - """Raised internally when processing of a streamed response should be stopped.""" - - -class Stream(Generic[ResponseT]): - response: httpx.Response - - def __init__( - self, - *, - cast_to: type[ResponseT], - response: httpx.Response, - client: SyncAPIClient, - ) -> None: - self.response = response - self._cast_to = cast_to - self._client = client - self._iterator = self.__iter() - - def __next__(self) -> ResponseT: - return self._iterator.__next__() - - def __iter__(self) -> Iterator[ResponseT]: - for item in self._iterator: - yield item - - def __iter(self) -> Iterator[ResponseT]: - cast_to = self._cast_to - response = self.response - process_line = self._client._process_stream_line - process_data = self._client._process_response_data - - awaiting_ping_data = False - for raw_line in response.iter_lines(): - if not raw_line or raw_line == "\n": - continue - - if raw_line.startswith("event: ping"): - awaiting_ping_data = True - continue - if awaiting_ping_data: - awaiting_ping_data = False - continue - - try: - line = process_line(raw_line) - except StopStreaming: - # we are done! - break - - yield process_data(data=json.loads(line), cast_to=cast_to, response=response) - - -class AsyncStream(Generic[ResponseT]): - response: httpx.Response - - def __init__( - self, - *, - cast_to: type[ResponseT], - response: httpx.Response, - client: AsyncAPIClient, - ) -> None: - self.response = response - self._cast_to = cast_to - self._client = client - self._iterator = self.__iter() - - async def __anext__(self) -> ResponseT: - return await self._iterator.__anext__() - - async def __aiter__(self) -> AsyncIterator[ResponseT]: - async for item in self._iterator: - yield item - - async def __iter(self) -> AsyncIterator[ResponseT]: - cast_to = self._cast_to - response = self.response - process_line = self._client._process_stream_line - process_data = self._client._process_response_data - - awaiting_ping_data = False - async for raw_line in response.aiter_lines(): - if not raw_line or raw_line == "\n": - continue - - if raw_line.startswith("event: ping"): - awaiting_ping_data = True - continue - if awaiting_ping_data: - awaiting_ping_data = False - continue - - try: - line = process_line(raw_line) - except StopStreaming: - # we are done! - break - - yield process_data(data=json.loads(line), cast_to=cast_to, response=response) +class MissingStreamClassError(TypeError): + def __init__(self) -> None: + super().__init__( + "The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `finch._streaming` for reference", + ) class PageInfo: @@ -635,16 +531,6 @@ def _process_response_data( return cast(ResponseT, construct_type(type_=cast_to, value=data)) - def _process_stream_line(self, contents: str) -> str: - """Pre-process an indiviudal line from a streaming response""" - if contents.startswith("data: [DONE]"): - raise StopStreaming() - - if contents.startswith("data: "): - return contents[6:] - - return contents - @property def qs(self) -> Querystring: return Querystring() @@ -756,6 +642,7 @@ def _idempotency_key(self) -> str: class SyncAPIClient(BaseClient): _client: httpx.Client + _default_stream_cls: type[Stream[Any]] | None = None def __init__( self, @@ -798,7 +685,8 @@ def request( remaining_retries: Optional[int] = None, *, stream: Literal[True], - ) -> Stream[ResponseT]: + stream_cls: Type[_StreamT], + ) -> _StreamT: ... @overload @@ -820,7 +708,8 @@ def request( remaining_retries: Optional[int] = None, *, stream: bool = False, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: Type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... def request( @@ -830,11 +719,13 @@ def request( remaining_retries: Optional[int] = None, *, stream: bool = False, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: return self._request( cast_to=cast_to, options=options, stream=stream, + stream_cls=stream_cls, remaining_retries=remaining_retries, ) @@ -845,7 +736,8 @@ def _request( options: FinalRequestOptions, remaining_retries: int | None, stream: bool, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: type[_StreamT] | None, + ) -> ResponseT | _StreamT: retries = self._remaining_retries(remaining_retries, options) request = self._build_request(options) @@ -854,7 +746,14 @@ def _request( response.raise_for_status() except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code if retries > 0 and self._should_retry(err.response): - return self._retry_request(options, cast_to, retries, err.response.headers, stream=stream) + return self._retry_request( + options, + cast_to, + retries, + err.response.headers, + stream=stream, + stream_cls=stream_cls, + ) # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. @@ -862,15 +761,18 @@ def _request( raise self._make_status_error_from_response(request, err.response) from None except httpx.TimeoutException as err: if retries > 0: - return self._retry_request(options, cast_to, retries, stream=stream) + return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) raise APITimeoutError(request=request) from err except Exception as err: if retries > 0: - return self._retry_request(options, cast_to, retries, stream=stream) + return self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) raise APIConnectionError(request=request) from err if stream: - return Stream(cast_to=cast_to, response=response, client=self) + stream_cls = stream_cls or cast("type[_StreamT] | None", self._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + return stream_cls(cast_to=cast_to, response=response, client=self) try: rsp = self._process_response(cast_to=cast_to, options=options, response=response) @@ -887,7 +789,8 @@ def _retry_request( response_headers: Optional[httpx.Headers] = None, *, stream: bool, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: type[_StreamT] | None, + ) -> ResponseT | _StreamT: remaining = remaining_retries - 1 timeout = self._calculate_retry_timeout(remaining, options, response_headers) @@ -900,6 +803,7 @@ def _retry_request( cast_to=cast_to, remaining_retries=remaining, stream=stream, + stream_cls=stream_cls, ) def _request_api_list( @@ -951,7 +855,8 @@ def post( options: RequestOptions = {}, files: RequestFiles | None = None, stream: Literal[True], - ) -> Stream[ResponseT]: + stream_cls: type[_StreamT], + ) -> _StreamT: ... @overload @@ -964,7 +869,8 @@ def post( options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: ... def post( @@ -976,9 +882,10 @@ def post( options: RequestOptions = {}, files: RequestFiles | None = None, stream: bool = False, - ) -> ResponseT | Stream[ResponseT]: + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: opts = FinalRequestOptions.construct(method="post", url=path, json_data=body, files=files, **options) - return cast(ResponseT, self.request(cast_to, opts, stream=stream)) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) def patch( self, @@ -1030,6 +937,7 @@ def get_api_list( class AsyncAPIClient(BaseClient): _client: httpx.AsyncClient + _default_stream_cls: type[AsyncStream[Any]] | None = None def __init__( self, @@ -1082,8 +990,9 @@ async def request( options: FinalRequestOptions, *, stream: Literal[True], + stream_cls: type[_AsyncStreamT], remaining_retries: Optional[int] = None, - ) -> AsyncStream[ResponseT]: + ) -> _AsyncStreamT: ... @overload @@ -1093,8 +1002,9 @@ async def request( options: FinalRequestOptions, *, stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, remaining_retries: Optional[int] = None, - ) -> ResponseT | AsyncStream[ResponseT]: + ) -> ResponseT | _AsyncStreamT: ... async def request( @@ -1103,12 +1013,14 @@ async def request( options: FinalRequestOptions, *, stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, remaining_retries: Optional[int] = None, - ) -> ResponseT | AsyncStream[ResponseT]: + ) -> ResponseT | _AsyncStreamT: return await self._request( cast_to=cast_to, options=options, stream=stream, + stream_cls=stream_cls, remaining_retries=remaining_retries, ) @@ -1118,8 +1030,9 @@ async def _request( options: FinalRequestOptions, *, stream: bool, + stream_cls: type[_AsyncStreamT] | None, remaining_retries: int | None, - ) -> ResponseT | AsyncStream[ResponseT]: + ) -> ResponseT | _AsyncStreamT: retries = self._remaining_retries(remaining_retries, options) request = self._build_request(options) @@ -1128,7 +1041,14 @@ async def _request( response.raise_for_status() except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code if retries > 0 and self._should_retry(err.response): - return await self._retry_request(options, cast_to, retries, err.response.headers, stream=stream) + return await self._retry_request( + options, + cast_to, + retries, + err.response.headers, + stream=stream, + stream_cls=stream_cls, + ) # If the response is streamed then we need to explicitly read the response # to completion before attempting to access the response text. @@ -1136,7 +1056,7 @@ async def _request( raise self._make_status_error_from_response(request, err.response) from None except httpx.ConnectTimeout as err: if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream) + 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 @@ -1146,15 +1066,18 @@ async def _request( raise except httpx.TimeoutException as err: if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream) + return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) raise APITimeoutError(request=request) from err except Exception as err: if retries > 0: - return await self._retry_request(options, cast_to, retries, stream=stream) + return await self._retry_request(options, cast_to, retries, stream=stream, stream_cls=stream_cls) raise APIConnectionError(request=request) from err if stream: - return AsyncStream(cast_to=cast_to, response=response, client=self) + stream_cls = stream_cls or cast("type[_AsyncStreamT] | None", self._default_stream_cls) + if stream_cls is None: + raise MissingStreamClassError() + return stream_cls(cast_to=cast_to, response=response, client=self) try: rsp = self._process_response(cast_to=cast_to, options=options, response=response) @@ -1171,7 +1094,8 @@ async def _retry_request( response_headers: Optional[httpx.Headers] = None, *, stream: bool, - ) -> ResponseT | AsyncStream[ResponseT]: + stream_cls: type[_AsyncStreamT] | None, + ) -> ResponseT | _AsyncStreamT: remaining = remaining_retries - 1 timeout = self._calculate_retry_timeout(remaining, options, response_headers) @@ -1182,6 +1106,7 @@ async def _retry_request( cast_to=cast_to, remaining_retries=remaining, stream=stream, + stream_cls=stream_cls, ) def _request_api_list( @@ -1225,7 +1150,8 @@ async def post( files: RequestFiles | None = None, options: RequestOptions = {}, stream: Literal[True], - ) -> AsyncStream[ResponseT]: + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: ... @overload @@ -1238,7 +1164,8 @@ async def post( files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool, - ) -> ResponseT | AsyncStream[ResponseT]: + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: ... async def post( @@ -1250,9 +1177,10 @@ async def post( files: RequestFiles | None = None, options: RequestOptions = {}, stream: bool = False, - ) -> ResponseT | AsyncStream[ResponseT]: + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: opts = FinalRequestOptions.construct(method="post", url=path, json_data=body, files=files, **options) - return await self.request(cast_to, opts, stream=stream) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) async def patch( self, diff --git a/src/finch/_client.py b/src/finch/_client.py index 4cdb3b70..b1f1cbf5 100644 --- a/src/finch/_client.py +++ b/src/finch/_client.py @@ -20,10 +20,15 @@ RequestOptions, ) from ._version import __version__ -from ._base_client import DEFAULT_LIMITS, DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES -from ._base_client import Stream as Stream -from ._base_client import AsyncStream as AsyncStream -from ._base_client import SyncAPIClient, AsyncAPIClient +from ._streaming import Stream as Stream +from ._streaming import AsyncStream as AsyncStream +from ._base_client import ( + DEFAULT_LIMITS, + DEFAULT_TIMEOUT, + DEFAULT_MAX_RETRIES, + SyncAPIClient, + AsyncAPIClient, +) __all__ = [ "Timeout", diff --git a/src/finch/_streaming.py b/src/finch/_streaming.py new file mode 100644 index 00000000..18749b53 --- /dev/null +++ b/src/finch/_streaming.py @@ -0,0 +1,204 @@ +# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Generic, Iterator, AsyncIterator + +import httpx + +from ._types import ResponseT + +if TYPE_CHECKING: + from ._base_client import SyncAPIClient, AsyncAPIClient + + +class Stream(Generic[ResponseT]): + """Provides the core interface to iterate over a synchronous stream response.""" + + response: httpx.Response + + def __init__( + self, + *, + cast_to: type[ResponseT], + response: httpx.Response, + client: SyncAPIClient, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = SSEDecoder() + self._iterator = self.__stream__() + + def __next__(self) -> ResponseT: + return self._iterator.__next__() + + def __iter__(self) -> Iterator[ResponseT]: + for item in self._iterator: + yield item + + def _iter_events(self) -> Iterator[ServerSentEvent]: + yield from self._decoder.iter(self.response.iter_lines()) + + def __stream__(self) -> Iterator[ResponseT]: + cast_to = self._cast_to + response = self.response + process_data = self._client._process_response_data + + for sse in self._iter_events(): + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + +class AsyncStream(Generic[ResponseT]): + """Provides the core interface to iterate over an asynchronous stream response.""" + + response: httpx.Response + + def __init__( + self, + *, + cast_to: type[ResponseT], + response: httpx.Response, + client: AsyncAPIClient, + ) -> None: + self.response = response + self._cast_to = cast_to + self._client = client + self._decoder = SSEDecoder() + self._iterator = self.__stream__() + + async def __anext__(self) -> ResponseT: + return await self._iterator.__anext__() + + async def __aiter__(self) -> AsyncIterator[ResponseT]: + async for item in self._iterator: + yield item + + async def _iter_events(self) -> AsyncIterator[ServerSentEvent]: + async for sse in self._decoder.aiter(self.response.aiter_lines()): + yield sse + + async def __stream__(self) -> AsyncIterator[ResponseT]: + cast_to = self._cast_to + response = self.response + process_data = self._client._process_response_data + + async for sse in self._iter_events(): + yield process_data(data=sse.json(), cast_to=cast_to, response=response) + + +class ServerSentEvent: + def __init__( + self, + *, + event: str | None = None, + data: str | None = None, + id: str | None = None, + retry: int | None = None, + ) -> None: + if data is None: + data = "" + + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> str | None: + return self._event + + @property + def id(self) -> str | None: + return self._id + + @property + def retry(self) -> int | None: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + return json.loads(self.data) + + def __repr__(self) -> str: + return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})" + + +class SSEDecoder: + _data: list[str] + _event: str | None + _retry: int | None + _last_event_id: str | None + + def __init__(self) -> None: + self._event = None + self._data = [] + self._last_event_id = None + self._retry = None + + def iter(self, iterator: Iterator[str]) -> Iterator[ServerSentEvent]: + """Given an iterator that yields lines, iterate over it & yield every event encountered""" + for line in iterator: + line = line.rstrip("\n") + sse = self.decode(line) + if sse is not None: + yield sse + + async def aiter(self, iterator: AsyncIterator[str]) -> AsyncIterator[ServerSentEvent]: + """Given an async iterator that yields lines, iterate over it & yield every event encountered""" + async for line in iterator: + line = line.rstrip("\n") + sse = self.decode(line) + if sse is not None: + yield sse + + def decode(self, line: str) -> ServerSentEvent | None: + # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 + + if not line: + if not self._event and not self._data and not self._last_event_id and self._retry is None: + return None + + sse = ServerSentEvent( + event=self._event, + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + + # NOTE: as per the SSE spec, do not reset last_event_id. + self._event = None + self._data = [] + self._retry = None + + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + + if value.startswith(" "): + value = value[1:] + + if fieldname == "event": + self._event = value + elif fieldname == "data": + self._data.append(value) + elif fieldname == "id": + if "\0" in value: + pass + else: + self._last_event_id = value + elif fieldname == "retry": + try: + self._retry = int(value) + except (TypeError, ValueError): + pass + else: + pass # Field is ignored. + + return None diff --git a/src/finch/_types.py b/src/finch/_types.py index c1ca74bf..3d0a265a 100644 --- a/src/finch/_types.py +++ b/src/finch/_types.py @@ -3,7 +3,9 @@ from typing import ( IO, TYPE_CHECKING, + Any, Dict, + List, Type, Tuple, Union, @@ -14,9 +16,13 @@ ) from typing_extensions import Literal, Protocol, TypedDict, runtime_checkable +import httpx import pydantic from httpx import Proxy, Timeout, Response, BaseTransport +if TYPE_CHECKING: + from ._models import BaseModel + Transport = BaseTransport Query = Mapping[str, object] Body = object @@ -143,3 +149,8 @@ def get(self, __key: str) -> str | None: HeadersLike = Union[Headers, HeadersLikeProtocol] + +ResponseT = TypeVar( + "ResponseT", + bound="Union[str, None, BaseModel, List[Any], Dict[str, Any], httpx.Response, UnknownResponse, ModelBuilderProtocol]", +) diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 00000000..70eb81a3 --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,104 @@ +from typing import Iterator, AsyncIterator + +import pytest + +from finch._streaming import SSEDecoder + + +@pytest.mark.asyncio +async def test_basic_async() -> None: + async def body() -> AsyncIterator[str]: + yield "event: completion" + yield 'data: {"foo":true}' + yield "" + + async for sse in SSEDecoder().aiter(body()): + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + +def test_basic() -> None: + def body() -> Iterator[str]: + yield "event: completion" + yield 'data: {"foo":true}' + yield "" + + it = SSEDecoder().iter(body()) + sse = next(it) + assert sse.event == "completion" + assert sse.json() == {"foo": True} + + with pytest.raises(StopIteration): + next(it) + + +def test_data_missing_event() -> None: + def body() -> Iterator[str]: + yield 'data: {"foo":true}' + yield "" + + it = SSEDecoder().iter(body()) + sse = next(it) + assert sse.event is None + assert sse.json() == {"foo": True} + + with pytest.raises(StopIteration): + next(it) + + +def test_event_missing_data() -> None: + def body() -> Iterator[str]: + yield "event: ping" + yield "" + + it = SSEDecoder().iter(body()) + sse = next(it) + assert sse.event == "ping" + assert sse.data == "" + + with pytest.raises(StopIteration): + next(it) + + +def test_multiple_events() -> None: + def body() -> Iterator[str]: + yield "event: ping" + yield "" + yield "event: completion" + yield "" + + it = SSEDecoder().iter(body()) + + sse = next(it) + assert sse.event == "ping" + assert sse.data == "" + + sse = next(it) + assert sse.event == "completion" + assert sse.data == "" + + with pytest.raises(StopIteration): + next(it) + + +def test_multiple_events_with_data() -> None: + def body() -> Iterator[str]: + yield "event: ping" + yield 'data: {"foo":true}' + yield "" + yield "event: completion" + yield 'data: {"bar":false}' + yield "" + + it = SSEDecoder().iter(body()) + + sse = next(it) + assert sse.event == "ping" + assert sse.json() == {"foo": True} + + sse = next(it) + assert sse.event == "completion" + assert sse.json() == {"bar": False} + + with pytest.raises(StopIteration): + next(it) From b06ae64ff674fd8ea9a40b84e2db8872d2501ae1 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 2 Jun 2023 22:15:52 +0100 Subject: [PATCH 10/14] fix(types): correct items type for `individuals` arg in `enroll_many` (#15) --- src/finch/resources/ats/applications.py | 26 +++++++++++++++++-- src/finch/resources/ats/candidates.py | 18 +++++++++++++ src/finch/resources/ats/jobs.py | 26 +++++++++++++++++-- src/finch/resources/ats/offers.py | 26 +++++++++++++++++-- src/finch/resources/hris/benefits/benefits.py | 22 ++++++++++++++-- .../resources/hris/benefits/individuals.py | 22 ++++++++++++++-- .../resources/hris/individuals/individuals.py | 4 +-- src/finch/resources/hris/payments.py | 4 +-- .../benefits/individual_enroll_many_params.py | 14 +++++----- .../hris/individual_retrieve_many_params.py | 14 +++++----- .../employment_data_retrieve_many_params.py | 10 +++---- .../pay_statement_retrieve_many_params.py | 10 +++---- tests/api_resources/hris/test_benefits.py | 4 +-- tests/api_resources/hris/test_individuals.py | 4 +-- tests/api_resources/hris/test_payments.py | 4 +-- 15 files changed, 165 insertions(+), 43 deletions(-) diff --git a/src/finch/resources/ats/applications.py b/src/finch/resources/ats/applications.py index 615e0bc2..617c2e70 100644 --- a/src/finch/resources/ats/applications.py +++ b/src/finch/resources/ats/applications.py @@ -24,7 +24,18 @@ def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Application: - """Gets an application from an organization.""" + """ + Gets an application from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return self._get( f"/ats/applications/{application_id}", options=make_request_options( @@ -93,7 +104,18 @@ async def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Application: - """Gets an application from an organization.""" + """ + Gets an application from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return await self._get( f"/ats/applications/{application_id}", options=make_request_options( diff --git a/src/finch/resources/ats/candidates.py b/src/finch/resources/ats/candidates.py index 045b23b9..1bb04a89 100644 --- a/src/finch/resources/ats/candidates.py +++ b/src/finch/resources/ats/candidates.py @@ -28,6 +28,15 @@ def retrieve( A candidate represents an individual associated with one or more applications. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return self._get( f"/ats/candidates/{candidate_id}", @@ -103,6 +112,15 @@ async def retrieve( A candidate represents an individual associated with one or more applications. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return await self._get( f"/ats/candidates/{candidate_id}", diff --git a/src/finch/resources/ats/jobs.py b/src/finch/resources/ats/jobs.py index 38e323fa..d2c43f21 100644 --- a/src/finch/resources/ats/jobs.py +++ b/src/finch/resources/ats/jobs.py @@ -24,7 +24,18 @@ def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Job: - """Gets a job from an organization.""" + """ + Gets a job from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return self._get( f"/ats/jobs/{job_id}", options=make_request_options( @@ -93,7 +104,18 @@ async def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Job: - """Gets a job from an organization.""" + """ + Gets a job from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return await self._get( f"/ats/jobs/{job_id}", options=make_request_options( diff --git a/src/finch/resources/ats/offers.py b/src/finch/resources/ats/offers.py index 771e234b..55a8a8ab 100644 --- a/src/finch/resources/ats/offers.py +++ b/src/finch/resources/ats/offers.py @@ -24,7 +24,18 @@ def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Offer: - """Get a single offer from an organization.""" + """ + Get a single offer from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return self._get( f"/ats/offers/{offer_id}", options=make_request_options( @@ -93,7 +104,18 @@ async def retrieve( extra_body: Body | None = None, timeout: float | None | NotGiven = NOT_GIVEN, ) -> Offer: - """Get a single offer from an organization.""" + """ + Get a single offer from an organization. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds + """ return await self._get( f"/ats/offers/{offer_id}", options=make_request_options( diff --git a/src/finch/resources/hris/benefits/benefits.py b/src/finch/resources/hris/benefits/benefits.py index f85ba3a3..bf9b6870 100644 --- a/src/finch/resources/hris/benefits/benefits.py +++ b/src/finch/resources/hris/benefits/benefits.py @@ -68,9 +68,9 @@ def create( "/employer/benefits", body=maybe_transform( { - "type": type, "description": description, "frequency": frequency, + "type": type, }, benefit_create_params.BenefitCreateParams, ), @@ -95,6 +95,15 @@ def retrieve( **Availability: Automated Benefits providers only** Lists benefit information for a given benefit + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return self._get( f"/employer/benefits/{benefit_id}", @@ -232,9 +241,9 @@ async def create( "/employer/benefits", body=maybe_transform( { - "type": type, "description": description, "frequency": frequency, + "type": type, }, benefit_create_params.BenefitCreateParams, ), @@ -259,6 +268,15 @@ async def retrieve( **Availability: Automated Benefits providers only** Lists benefit information for a given benefit + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return await self._get( f"/employer/benefits/{benefit_id}", diff --git a/src/finch/resources/hris/benefits/individuals.py b/src/finch/resources/hris/benefits/individuals.py index d2d25b67..03b4aa33 100644 --- a/src/finch/resources/hris/benefits/individuals.py +++ b/src/finch/resources/hris/benefits/individuals.py @@ -27,7 +27,7 @@ def enroll_many( self, benefit_id: str, *, - individuals: List[individual_enroll_many_params.IndividualEnrollManyParam], + individuals: List[individual_enroll_many_params.Individual], # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -84,6 +84,15 @@ def enrolled_ids( **Availability: Automated Benefits providers only** Lists individuals currently enrolled in a given benefit. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return self._get( f"/employer/benefits/{benefit_id}/enrolled", @@ -185,7 +194,7 @@ def enroll_many( self, benefit_id: str, *, - individuals: List[individual_enroll_many_params.IndividualEnrollManyParam], + individuals: List[individual_enroll_many_params.Individual], # Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs. # The extra values given here take precedence over values defined on the client or passed to this method. extra_headers: Headers | None = None, @@ -242,6 +251,15 @@ async def enrolled_ids( **Availability: Automated Benefits providers only** Lists individuals currently enrolled in a given benefit. + + Args: + extra_headers: Send extra headers + + extra_query: Add additional query parameters to the request + + extra_body: Add additional JSON properties to the request + + timeout: Override the client-level default timeout for this request, in seconds """ return await self._get( f"/employer/benefits/{benefit_id}/enrolled", diff --git a/src/finch/resources/hris/individuals/individuals.py b/src/finch/resources/hris/individuals/individuals.py index 5e2a3d58..43b15d75 100644 --- a/src/finch/resources/hris/individuals/individuals.py +++ b/src/finch/resources/hris/individuals/individuals.py @@ -54,8 +54,8 @@ def retrieve_many( page=SyncResponsesPage[IndividualResponse], body=maybe_transform( { - "requests": requests, "options": options, + "requests": requests, }, individual_retrieve_many_params.IndividualRetrieveManyParams, ), @@ -103,8 +103,8 @@ def retrieve_many( page=AsyncResponsesPage[IndividualResponse], body=maybe_transform( { - "requests": requests, "options": options, + "requests": requests, }, individual_retrieve_many_params.IndividualRetrieveManyParams, ), diff --git a/src/finch/resources/hris/payments.py b/src/finch/resources/hris/payments.py index 2eba7fe6..15856203 100644 --- a/src/finch/resources/hris/payments.py +++ b/src/finch/resources/hris/payments.py @@ -56,8 +56,8 @@ def list( timeout=timeout, query=maybe_transform( { - "start_date": start_date, "end_date": end_date, + "start_date": start_date, }, payment_list_params.PaymentListParams, ), @@ -107,8 +107,8 @@ def list( timeout=timeout, query=maybe_transform( { - "start_date": start_date, "end_date": end_date, + "start_date": start_date, }, payment_list_params.PaymentListParams, ), diff --git a/src/finch/types/hris/benefits/individual_enroll_many_params.py b/src/finch/types/hris/benefits/individual_enroll_many_params.py index 62f2a2f2..2d88ef4d 100644 --- a/src/finch/types/hris/benefits/individual_enroll_many_params.py +++ b/src/finch/types/hris/benefits/individual_enroll_many_params.py @@ -3,16 +3,18 @@ from __future__ import annotations from typing import List -from typing_extensions import TypedDict +from typing_extensions import Required, TypedDict -__all__ = ["IndividualEnrollManyParam"] +__all__ = ["IndividualEnrollManyParams", "Individual"] -class IndividualEnrollManyParam(TypedDict, total=False): +class IndividualEnrollManyParams(TypedDict, total=False): + individuals: Required[List[Individual]] + """Array of the individual_id to enroll and a configuration object.""" + + +class Individual(TypedDict, total=False): configuration: object individual_id: str """Finch id (uuidv4) for the individual to enroll""" - - -IndividualEnrollManyParams = List[IndividualEnrollManyParam] diff --git a/src/finch/types/hris/individual_retrieve_many_params.py b/src/finch/types/hris/individual_retrieve_many_params.py index f937db83..bc95e012 100644 --- a/src/finch/types/hris/individual_retrieve_many_params.py +++ b/src/finch/types/hris/individual_retrieve_many_params.py @@ -5,18 +5,18 @@ from typing import List, Optional from typing_extensions import TypedDict -__all__ = ["IndividualRetrieveManyParams", "Request", "Options"] +__all__ = ["IndividualRetrieveManyParams", "Options", "Request"] -class Request(TypedDict, total=False): - individual_id: str +class IndividualRetrieveManyParams(TypedDict, total=False): + options: Optional[Options] + + requests: List[Request] class Options(TypedDict, total=False): include: List[str] -class IndividualRetrieveManyParams(TypedDict, total=False): - options: Optional[Options] - - requests: List[Request] +class Request(TypedDict, total=False): + individual_id: str diff --git a/src/finch/types/hris/individuals/employment_data_retrieve_many_params.py b/src/finch/types/hris/individuals/employment_data_retrieve_many_params.py index 2e099514..a1ab2fb9 100644 --- a/src/finch/types/hris/individuals/employment_data_retrieve_many_params.py +++ b/src/finch/types/hris/individuals/employment_data_retrieve_many_params.py @@ -8,6 +8,11 @@ __all__ = ["EmploymentDataRetrieveManyParams", "Request"] +class EmploymentDataRetrieveManyParams(TypedDict, total=False): + requests: Required[List[Request]] + """The array of batch requests.""" + + class Request(TypedDict, total=False): individual_id: Required[str] """A stable Finch `id` (UUID v4) for an individual in the company. @@ -16,8 +21,3 @@ class Request(TypedDict, total=False): preferantial to send all ids in a single request for Finch to optimize provider rate-limits. """ - - -class EmploymentDataRetrieveManyParams(TypedDict, total=False): - requests: Required[List[Request]] - """The array of batch requests.""" diff --git a/src/finch/types/hris/pay_statement_retrieve_many_params.py b/src/finch/types/hris/pay_statement_retrieve_many_params.py index d166c486..6329117d 100644 --- a/src/finch/types/hris/pay_statement_retrieve_many_params.py +++ b/src/finch/types/hris/pay_statement_retrieve_many_params.py @@ -8,6 +8,11 @@ __all__ = ["PayStatementRetrieveManyParams", "Request"] +class PayStatementRetrieveManyParams(TypedDict, total=False): + requests: Required[List[Request]] + """The array of batch requests.""" + + class Request(TypedDict, total=False): payment_id: Required[str] """A stable Finch `id` (UUID v4) for a payment.""" @@ -17,8 +22,3 @@ class Request(TypedDict, total=False): offset: int """Index to start from.""" - - -class PayStatementRetrieveManyParams(TypedDict, total=False): - requests: Required[List[Request]] - """The array of batch requests.""" diff --git a/tests/api_resources/hris/test_benefits.py b/tests/api_resources/hris/test_benefits.py index 4ed8a6b3..5ff71c54 100644 --- a/tests/api_resources/hris/test_benefits.py +++ b/tests/api_resources/hris/test_benefits.py @@ -33,9 +33,9 @@ def test_method_create(self, client: Finch) -> None: @parametrize def test_method_create_with_all_params(self, client: Finch) -> None: benefit = client.hris.benefits.create( - type="401k", description="string", frequency="one_time", + type="401k", ) assert_matches_type(CreateCompanyBenefitsResponse, benefit, path=["response"]) @@ -85,9 +85,9 @@ async def test_method_create(self, client: AsyncFinch) -> None: @parametrize async def test_method_create_with_all_params(self, client: AsyncFinch) -> None: benefit = await client.hris.benefits.create( - type="401k", description="string", frequency="one_time", + type="401k", ) assert_matches_type(CreateCompanyBenefitsResponse, benefit, path=["response"]) diff --git a/tests/api_resources/hris/test_individuals.py b/tests/api_resources/hris/test_individuals.py index 6bcb7217..98dc39be 100644 --- a/tests/api_resources/hris/test_individuals.py +++ b/tests/api_resources/hris/test_individuals.py @@ -28,8 +28,8 @@ def test_method_retrieve_many(self, client: Finch) -> None: @parametrize def test_method_retrieve_many_with_all_params(self, client: Finch) -> None: individual = client.hris.individuals.retrieve_many( - requests=[{"individual_id": "string"}, {"individual_id": "string"}, {"individual_id": "string"}], options={"include": ["string", "string", "string"]}, + requests=[{"individual_id": "string"}, {"individual_id": "string"}, {"individual_id": "string"}], ) assert_matches_type(SyncResponsesPage[IndividualResponse], individual, path=["response"]) @@ -47,7 +47,7 @@ async def test_method_retrieve_many(self, client: AsyncFinch) -> None: @parametrize async def test_method_retrieve_many_with_all_params(self, client: AsyncFinch) -> None: individual = await client.hris.individuals.retrieve_many( - requests=[{"individual_id": "string"}, {"individual_id": "string"}, {"individual_id": "string"}], options={"include": ["string", "string", "string"]}, + requests=[{"individual_id": "string"}, {"individual_id": "string"}, {"individual_id": "string"}], ) assert_matches_type(AsyncResponsesPage[IndividualResponse], individual, path=["response"]) diff --git a/tests/api_resources/hris/test_payments.py b/tests/api_resources/hris/test_payments.py index 83e294c8..fc757620 100644 --- a/tests/api_resources/hris/test_payments.py +++ b/tests/api_resources/hris/test_payments.py @@ -24,8 +24,8 @@ class TestPayments: @parametrize def test_method_list(self, client: Finch) -> None: payment = client.hris.payments.list( - start_date=parse_date("2021-01-01"), end_date=parse_date("2021-01-01"), + start_date=parse_date("2021-01-01"), ) assert_matches_type(SyncSinglePage[Payment], payment, path=["response"]) @@ -38,7 +38,7 @@ class TestAsyncPayments: @parametrize async def test_method_list(self, client: AsyncFinch) -> None: payment = await client.hris.payments.list( - start_date=parse_date("2021-01-01"), end_date=parse_date("2021-01-01"), + start_date=parse_date("2021-01-01"), ) assert_matches_type(AsyncSinglePage[Payment], payment, path=["response"]) From 6aec9056a2faccbad273c3d7b3807cb4b4222b77 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Fri, 9 Jun 2023 14:19:49 -0400 Subject: [PATCH 11/14] chore(internal): improve internal test helper (#16) --- src/finch/_qs.py | 8 +++++--- tests/utils.py | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/finch/_qs.py b/src/finch/_qs.py index e7aa3e13..274320ca 100644 --- a/src/finch/_qs.py +++ b/src/finch/_qs.py @@ -34,7 +34,7 @@ def __init__( self.nested_format = nested_format def parse(self, query: str) -> Mapping[str, object]: - # TODO + # Note: custom format syntax is not supported yet return parse_qs(query) def stringify( @@ -89,9 +89,11 @@ def _stringify_item( if isinstance(value, (list, tuple)): array_format = opts.array_format if array_format == "comma": - # TODO: support list of objects? return [ - (key, ",".join(self._primitive_value_to_str(item) for item in value if item is not None)), + ( + key, + ",".join(self._primitive_value_to_str(item) for item in value if item is not None), + ), ] elif array_format == "repeat": items = [] diff --git a/tests/utils.py b/tests/utils.py index 986f79fa..72c546d7 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -25,6 +25,10 @@ def assert_matches_model(model: type[BaseModelT], value: BaseModelT, *, path: li # Note: the `path` argument is only used to improve error messages when `--showlocals` is used def assert_matches_type(type_: Any, value: object, *, path: list[str]) -> None: + if type_ is None: + assert value is None + return + origin = get_origin(type_) or type_ if is_list_type(type_): From 3d706e2be21dd3968e5e6cc5a39f743617bcc89c Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 12 Jun 2023 17:09:43 -0400 Subject: [PATCH 12/14] ci: update release config (#17) --- release-please-config.json | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/release-please-config.json b/release-please-config.json index 0f8206d7..84f82eb9 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,6 +4,7 @@ }, "$schema": "https://raw.githubusercontent.com/stainless-api/release-please/main/schemas/config.json", "include-v-in-tag": true, + "include-component-in-tag": false, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "pull-request-header": "Automated Release PR", @@ -26,18 +27,15 @@ }, { "type": "chore", - "section": "Miscellaneous Chores", - "hidden": true + "section": "Chores" }, { "type": "docs", - "section": "Documentation", - "hidden": true + "section": "Documentation" }, { "type": "style", - "section": "Styles", - "hidden": true + "section": "Styles" }, { "type": "refactor", @@ -50,8 +48,7 @@ }, { "type": "build", - "section": "Build System", - "hidden": true + "section": "Build System" }, { "type": "ci", From fc16e4384ac0c63e5689dea601429a696eadcbe9 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Mon, 12 Jun 2023 22:03:48 -0400 Subject: [PATCH 13/14] docs: point to github repo instead of email contact (#18) --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 9313c47e..9c770c5e 100644 --- a/README.md +++ b/README.md @@ -263,8 +263,7 @@ See the httpx documentation for information about the [`proxies`](https://www.py This package is in beta. Its internals and interfaces are not stable and subject to change without a major semver bump; please reach out if you rely on any undocumented behavior. -We are keen for your feedback; please email us at [founders@tryfinch.com](mailto:founders@tryfinch.com) or open an issue with questions, -bugs, or suggestions. +We are keen for your feedback; please open an [issue](https://www.github.com/Finch-API/finch-api-python/issues) with questions, bugs, or suggestions. ## Requirements From 7414de5459f9dda6dfc252773022242f80e24832 Mon Sep 17 00:00:00 2001 From: Stainless Bot <107565488+stainless-bot@users.noreply.github.com> Date: Thu, 15 Jun 2023 00:10:44 -0400 Subject: [PATCH 14/14] chore(internal): add overloads to `client.get` for streaming (#19) --- src/finch/_base_client.py | 78 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/finch/_base_client.py b/src/finch/_base_client.py index b7953442..857dcadd 100644 --- a/src/finch/_base_client.py +++ b/src/finch/_base_client.py @@ -820,17 +820,54 @@ def _request_api_list( ) return resp + @overload def get( self, path: str, *, cast_to: Type[ResponseT], options: RequestOptions = {}, + stream: Literal[False] = False, ) -> ResponseT: + ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_StreamT], + ) -> _StreamT: + ... + + @overload + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: + ... + + def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_StreamT] | None = None, + ) -> ResponseT | _StreamT: opts = FinalRequestOptions.construct(method="get", url=path, **options) # cast is required because mypy complains about returning Any even though # it understands the type variables - return cast(ResponseT, self.request(cast_to, opts, stream=False)) + return cast(ResponseT, self.request(cast_to, opts, stream=stream, stream_cls=stream_cls)) @overload def post( @@ -1117,15 +1154,52 @@ def _request_api_list( ) -> AsyncPaginator[ModelT, AsyncPageT]: return AsyncPaginator(client=self, options=options, page_cls=page, model=model) + @overload async def get( self, path: str, *, cast_to: Type[ResponseT], options: RequestOptions = {}, + stream: Literal[False] = False, ) -> ResponseT: + ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: Literal[True], + stream_cls: type[_AsyncStreamT], + ) -> _AsyncStreamT: + ... + + @overload + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: + ... + + async def get( + self, + path: str, + *, + cast_to: Type[ResponseT], + options: RequestOptions = {}, + stream: bool = False, + stream_cls: type[_AsyncStreamT] | None = None, + ) -> ResponseT | _AsyncStreamT: opts = FinalRequestOptions.construct(method="get", url=path, **options) - return await self.request(cast_to, opts) + return await self.request(cast_to, opts, stream=stream, stream_cls=stream_cls) @overload async def post(