diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 55d20255..ff261bad 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT} USER vscode -RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash +RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.44.0" RYE_INSTALL_OPTION="--yes" bash ENV PATH=/home/vscode/.rye/shims:$PATH RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a8a4f7..5b1a5598 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,18 @@ name: CI on: push: - branches: - - main - pull_request: - branches: - - main - - next + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: + timeout-minutes: 10 name: lint - runs-on: ubuntu-latest - + runs-on: ${{ github.repository == 'stainless-sdks/arcade-engine-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 @@ -21,7 +21,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Install dependencies @@ -31,9 +31,9 @@ jobs: run: ./scripts/lint test: + timeout-minutes: 10 name: test - runs-on: ubuntu-latest - + runs-on: ${{ github.repository == 'stainless-sdks/arcade-engine-python' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 @@ -42,7 +42,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Bootstrap diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index e68c2875..0080ea4c 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -21,7 +21,7 @@ jobs: curl -sSf https://rye.astral.sh/get | bash echo "$HOME/.rye/shims" >> $GITHUB_PATH env: - RYE_VERSION: '0.35.0' + RYE_VERSION: '0.44.0' RYE_INSTALL_OPTION: '--yes' - name: Publish to PyPI diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0e5b256d..3e9af1b3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.3.1" + ".": "1.4.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 9a0cc5e6..b53e0604 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,4 @@ configured_endpoints: 19 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/arcade-ai%2Farcade-engine-3c7443a5e05ad4ade2ac36325d1def05cb3842bb53a180fc76feb565ea875cc7.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/arcade-ai%2Farcade-engine-4ab7584c83c5c3a70e1dbe129614563b90a0cca67a62e77a394964af6d495187.yml +openapi_spec_hash: 28328429c60e98b6713f717951688ba9 +config_hash: d86655f9af7ae4c4c444d9a16685a7c5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7525cc77..e47270ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +## 1.4.0 (2025-04-25) + +Full Changelog: [v1.3.1...v1.4.0](https://github.com/ArcadeAI/arcade-py/compare/v1.3.1...v1.4.0) + +### Features + +* **api:** api update ([8ebf770](https://github.com/ArcadeAI/arcade-py/commit/8ebf77068b377450788bd7436bd3ed264a195805)) +* **api:** api update ([db1894b](https://github.com/ArcadeAI/arcade-py/commit/db1894bccf5cfc6078133a0cbceeefc25f473532)) +* **api:** api update ([fb62a65](https://github.com/ArcadeAI/arcade-py/commit/fb62a6583c4c3af75eb3789e80d79f0306a5c7ec)) +* **api:** api update ([3133b01](https://github.com/ArcadeAI/arcade-py/commit/3133b01155fe75e7cfd14315ff8ab8fbabfe4ab1)) +* **api:** api update ([7290dc8](https://github.com/ArcadeAI/arcade-py/commit/7290dc846a4b31d4eb5836c8d997561cacb9f2dd)) +* **api:** api update ([#137](https://github.com/ArcadeAI/arcade-py/issues/137)) ([7ce1f6a](https://github.com/ArcadeAI/arcade-py/commit/7ce1f6aee46e2d8763c194a88f28428df9eb6d5f)) +* **api:** api update ([#138](https://github.com/ArcadeAI/arcade-py/issues/138)) ([cb71d87](https://github.com/ArcadeAI/arcade-py/commit/cb71d87280889f8d1200be0d069a088560c1bc8b)) +* **api:** api update ([#139](https://github.com/ArcadeAI/arcade-py/issues/139)) ([9a0ef5a](https://github.com/ArcadeAI/arcade-py/commit/9a0ef5a37cd20950fde494f690dd403715029243)) + + +### Bug Fixes + +* **ci:** ensure pip is always available ([#135](https://github.com/ArcadeAI/arcade-py/issues/135)) ([fd63bd1](https://github.com/ArcadeAI/arcade-py/commit/fd63bd10897027f3a2ea9e82043943213c1f897f)) +* **ci:** remove publishing patch ([#136](https://github.com/ArcadeAI/arcade-py/issues/136)) ([bd4bfc8](https://github.com/ArcadeAI/arcade-py/commit/bd4bfc8d40a1a59ac65e862eb6f0e04e0184c75d)) +* **perf:** optimize some hot paths ([c544b05](https://github.com/ArcadeAI/arcade-py/commit/c544b05d5afac34fe9119b791add080567f0e4cf)) +* **perf:** skip traversing types for NotGiven values ([af97129](https://github.com/ArcadeAI/arcade-py/commit/af97129e7ce0dc09566e4166cbaaa2b6a2246864)) +* **pydantic v1:** more robust ModelField.annotation check ([be8bb32](https://github.com/ArcadeAI/arcade-py/commit/be8bb32f9fab2a893fd6b11649c4f48b77fbd79d)) +* **types:** handle more discriminated union shapes ([#134](https://github.com/ArcadeAI/arcade-py/issues/134)) ([2e8aa54](https://github.com/ArcadeAI/arcade-py/commit/2e8aa5469824223540f3c1f970662bf0ed5f62ac)) + + +### Chores + +* broadly detect json family of content-type headers ([b562715](https://github.com/ArcadeAI/arcade-py/commit/b562715e721a8aa1b53414c989fd573602e2197a)) +* **ci:** add timeout thresholds for CI jobs ([467dfb2](https://github.com/ArcadeAI/arcade-py/commit/467dfb2ae0da1a048b9055630efa9a40126060dd)) +* **ci:** only use depot for staging repos ([eb51e05](https://github.com/ArcadeAI/arcade-py/commit/eb51e05c0d9095ddf0cda7b97984254b03b62be1)) +* **client:** minor internal fixes ([4e548b8](https://github.com/ArcadeAI/arcade-py/commit/4e548b86970ec4af361c474223255a8c847c4bff)) +* **internal:** base client updates ([1a4a717](https://github.com/ArcadeAI/arcade-py/commit/1a4a717957f74170069bfff0a415ddc38e66c5ef)) +* **internal:** bump pyright version ([6681c14](https://github.com/ArcadeAI/arcade-py/commit/6681c14818b989c4968408fe7432414c326d9038)) +* **internal:** bump rye to 0.44.0 ([#133](https://github.com/ArcadeAI/arcade-py/issues/133)) ([0068d95](https://github.com/ArcadeAI/arcade-py/commit/0068d951be52d203c558d6a241feca26eb1f3613)) +* **internal:** codegen related update ([b551ba9](https://github.com/ArcadeAI/arcade-py/commit/b551ba99820f93fdecb24663e83dff11897f8f62)) +* **internal:** codegen related update ([#132](https://github.com/ArcadeAI/arcade-py/issues/132)) ([85ff426](https://github.com/ArcadeAI/arcade-py/commit/85ff426032303355d1d8c331813aaeeeaef0f69d)) +* **internal:** expand CI branch coverage ([02cc295](https://github.com/ArcadeAI/arcade-py/commit/02cc2952f5b3bc36645d1da528f46ac65b681fd5)) +* **internal:** fix list file params ([1f6d1f3](https://github.com/ArcadeAI/arcade-py/commit/1f6d1f33d2323d2d0dec2ccfa9e21cf0b208bdd1)) +* **internal:** import reformatting ([772a87c](https://github.com/ArcadeAI/arcade-py/commit/772a87cd938e2d0f008cc54f3843ca4dad567225)) +* **internal:** reduce CI branch coverage ([f6d1892](https://github.com/ArcadeAI/arcade-py/commit/f6d1892c845ee924614c174ef5bd24241b111c8c)) +* **internal:** refactor retries to not use recursion ([709debf](https://github.com/ArcadeAI/arcade-py/commit/709debf17814c59c6048b996195090ee677119ed)) +* **internal:** remove extra empty newlines ([#131](https://github.com/ArcadeAI/arcade-py/issues/131)) ([4a0f409](https://github.com/ArcadeAI/arcade-py/commit/4a0f4094477d25a8c213f07e33f42296864d2866)) +* **internal:** remove trailing character ([#140](https://github.com/ArcadeAI/arcade-py/issues/140)) ([850838e](https://github.com/ArcadeAI/arcade-py/commit/850838edc42110c22d2c4342e4340f8322fb0e86)) +* **internal:** slight transform perf improvement ([#142](https://github.com/ArcadeAI/arcade-py/issues/142)) ([af27cc2](https://github.com/ArcadeAI/arcade-py/commit/af27cc24050a658f27d49909b0596b41309c1326)) +* **internal:** update models test ([ced3495](https://github.com/ArcadeAI/arcade-py/commit/ced349577a3c0c5b033ac53ebd239f333bc13fdf)) +* **internal:** update pyright settings ([950f294](https://github.com/ArcadeAI/arcade-py/commit/950f294ea47c8aa6ac423b6ffd2cec37278a4b4b)) + + +### Documentation + +* revise readme docs about nested params ([#128](https://github.com/ArcadeAI/arcade-py/issues/128)) ([52f818d](https://github.com/ArcadeAI/arcade-py/commit/52f818d5bdee294add53ebc8257c6d3f48cbdd65)) +* swap examples used in readme ([#141](https://github.com/ArcadeAI/arcade-py/issues/141)) ([fe028d0](https://github.com/ArcadeAI/arcade-py/commit/fe028d08aa5db60e540bca6f0635911ffcb79486)) + ## 1.3.1 (2025-03-11) Full Changelog: [v1.3.0...v1.3.1](https://github.com/ArcadeAI/arcade-py/compare/v1.3.0...v1.3.1) diff --git a/README.md b/README.md index 90889480..d55f97ed 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,21 @@ Nested request parameters are [TypedDicts](https://docs.python.org/3/library/typ Typed requests and responses provide autocomplete and documentation within your editor. If you would like to see type errors in VS Code to help catch bugs earlier, set `python.analysis.typeCheckingMode` to `basic`. +## Nested params + +Nested parameters are dictionaries, typed using `TypedDict`, for example: + +```python +from arcadepy import Arcade + +client = Arcade() + +chat_response = client.chat.completions.create( + response_format={"type": "json_object"}, +) +print(chat_response.response_format) +``` + ## Handling errors When the library is unable to connect to the API (for example, due to network connection problems or a timeout), a subclass of `arcadepy.APIConnectionError` is raised. diff --git a/api.md b/api.md index 0f79d040..0baadae2 100644 --- a/api.md +++ b/api.md @@ -64,7 +64,7 @@ Methods: - client.tools.list(\*\*params) -> SyncOffsetPage[ToolDefinition] - client.tools.authorize(\*\*params) -> AuthorizationResponse - client.tools.execute(\*\*params) -> ExecuteToolResponse -- client.tools.get(name) -> ToolDefinition +- client.tools.get(name, \*\*params) -> ToolDefinition ## Scheduled diff --git a/bin/publish-pypi b/bin/publish-pypi index 05bfccbb..826054e9 100644 --- a/bin/publish-pypi +++ b/bin/publish-pypi @@ -3,7 +3,4 @@ set -eux mkdir -p dist rye build --clean -# Patching importlib-metadata version until upstream library version is updated -# https://github.com/pypa/twine/issues/977#issuecomment-2189800841 -"$HOME/.rye/self/bin/python3" -m pip install 'importlib-metadata==7.2.1' rye publish --yes --token=$PYPI_TOKEN diff --git a/pyproject.toml b/pyproject.toml index 03ab7607..38b33dbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcadepy" -version = "1.3.1" +version = "1.4.0" description = "The official Python library for the Arcade API" dynamic = ["readme"] license = "MIT" @@ -14,6 +14,7 @@ dependencies = [ "anyio>=3.5.0, <5", "distro>=1.7.0, <2", "sniffio", + "certifi<=2025.1.31", ] requires-python = ">= 3.8" classifiers = [ @@ -38,12 +39,11 @@ Homepage = "https://github.com/ArcadeAI/arcade-py" Repository = "https://github.com/ArcadeAI/arcade-py" - [tool.rye] managed = true # version pins are in requirements-dev.lock dev-dependencies = [ - "pyright>=1.1.359", + "pyright==1.1.399", "mypy", "respx", "pytest", @@ -87,7 +87,7 @@ typecheck = { chain = [ "typecheck:mypy" = "mypy ." [build-system] -requires = ["hatchling", "hatch-fancy-pypi-readme"] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] build-backend = "hatchling.build" [tool.hatch.build] @@ -148,11 +148,11 @@ exclude = [ ] reportImplicitOverride = true +reportOverlappingOverload = false reportImportCycles = false reportPrivateUsage = false - [tool.ruff] line-length = 120 output-format = "grouped" diff --git a/requirements-dev.lock b/requirements-dev.lock index 65cd1c61..340940c6 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -7,6 +7,7 @@ # all-features: true # with-sources: false # generate-hashes: false +# universal: false -e file:. annotated-types==0.6.0 @@ -68,7 +69,7 @@ pydantic-core==2.27.1 # via pydantic pygments==2.18.0 # via rich -pyright==1.1.392.post0 +pyright==1.1.399 pytest==8.3.3 # via pytest-asyncio pytest-asyncio==0.24.0 diff --git a/requirements.lock b/requirements.lock index 39dd71d6..4abe5db2 100644 --- a/requirements.lock +++ b/requirements.lock @@ -7,6 +7,7 @@ # all-features: true # with-sources: false # generate-hashes: false +# universal: false -e file:. annotated-types==0.6.0 diff --git a/scripts/test b/scripts/test index 4fa5698b..2b878456 100755 --- a/scripts/test +++ b/scripts/test @@ -52,6 +52,8 @@ else echo fi +export DEFER_PYDANTIC_BUILD=false + echo "==> Running tests" rye run pytest "$@" diff --git a/src/arcadepy/_base_client.py b/src/arcadepy/_base_client.py index d58046d3..4e204d4d 100644 --- a/src/arcadepy/_base_client.py +++ b/src/arcadepy/_base_client.py @@ -98,7 +98,11 @@ _AsyncStreamT = TypeVar("_AsyncStreamT", bound=AsyncStream[Any]) if TYPE_CHECKING: - from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT + from httpx._config import ( + DEFAULT_TIMEOUT_CONFIG, # pyright: ignore[reportPrivateImportUsage] + ) + + HTTPX_DEFAULT_TIMEOUT = DEFAULT_TIMEOUT_CONFIG else: try: from httpx._config import DEFAULT_TIMEOUT_CONFIG as HTTPX_DEFAULT_TIMEOUT @@ -115,6 +119,7 @@ class PageInfo: url: URL | NotGiven params: Query | NotGiven + json: Body | NotGiven @overload def __init__( @@ -130,19 +135,30 @@ def __init__( params: Query, ) -> None: ... + @overload + def __init__( + self, + *, + json: Body, + ) -> None: ... + def __init__( self, *, url: URL | NotGiven = NOT_GIVEN, + json: Body | NotGiven = NOT_GIVEN, params: Query | NotGiven = NOT_GIVEN, ) -> None: self.url = url + self.json = json self.params = params @override def __repr__(self) -> str: if self.url: return f"{self.__class__.__name__}(url={self.url})" + if self.json: + return f"{self.__class__.__name__}(json={self.json})" return f"{self.__class__.__name__}(params={self.params})" @@ -191,6 +207,19 @@ def _info_to_options(self, info: PageInfo) -> FinalRequestOptions: options.url = str(url) return options + if not isinstance(info.json, NotGiven): + if not is_mapping(info.json): + raise TypeError("Pagination is only supported with mappings") + + if not options.json_data: + options.json_data = {**info.json} + else: + if not is_mapping(options.json_data): + raise TypeError("Pagination is only supported with mappings") + + options.json_data = {**options.json_data, **info.json} + return options + raise ValueError("Unexpected PageInfo state") @@ -408,8 +437,8 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 headers = httpx.Headers(headers_dict) idempotency_header = self._idempotency_header - if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: - headers[idempotency_header] = options.idempotency_key or self._idempotency_key() + if idempotency_header and options.idempotency_key and idempotency_header not in headers: + headers[idempotency_header] = options.idempotency_key # Don't set these headers if they were already set or removed by the caller. We check # `custom_headers`, which can contain `Omit()`, instead of `headers` to account for the removal case. @@ -873,7 +902,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[True], stream_cls: Type[_StreamT], @@ -884,7 +912,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: Literal[False] = False, ) -> ResponseT: ... @@ -894,7 +921,6 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: bool = False, stream_cls: Type[_StreamT] | None = None, @@ -904,121 +930,109 @@ def request( self, cast_to: Type[ResponseT], options: FinalRequestOptions, - remaining_retries: Optional[int] = None, *, stream: bool = False, stream_cls: type[_StreamT] | None = None, ) -> ResponseT | _StreamT: - if remaining_retries is not None: - retries_taken = options.get_max_retries(self.max_retries) - remaining_retries - else: - retries_taken = 0 - - return self._request( - cast_to=cast_to, - options=options, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) + cast_to = self._maybe_override_cast_to(cast_to, options) - def _request( - self, - *, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - retries_taken: int, - stream: bool, - stream_cls: type[_StreamT] | None, - ) -> ResponseT | _StreamT: # create a copy of the options we were given so that if the # options are mutated later & we then retry, the retries are # given the original options input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() - cast_to = self._maybe_override_cast_to(cast_to, options) - options = self._prepare_options(options) - - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - self._prepare_request(request) - - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) - log.debug("Sending HTTP Request: %s %s", request.method, request.url) + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = self._prepare_options(options) - try: - response = self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + self._prepare_request(request) - if remaining_retries > 0: - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, - ) + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) + log.debug("Sending HTTP Request: %s %s", request.method, request.url) - if remaining_retries > 0: - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, + response = None + try: + response = self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err - - log.debug( - 'HTTP Response: %s %s "%i %s" %s', - request.method, - request.url, - response.status_code, - response.reason_phrase, - response.headers, - ) + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + err.response.close() + self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - err.response.close() - return self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - response_headers=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. + if not err.response.is_closed: + err.response.read() - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - err.response.read() + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None + break + assert response is not None, "could not resolve response (should never happen)" return self._process_response( cast_to=cast_to, options=options, @@ -1028,37 +1042,20 @@ def _request( retries_taken=retries_taken, ) - def _retry_request( - self, - options: FinalRequestOptions, - cast_to: Type[ResponseT], - *, - retries_taken: int, - response_headers: httpx.Headers | None, - stream: bool, - stream_cls: type[_StreamT] | None, - ) -> ResponseT | _StreamT: - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken + def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken if remaining_retries == 1: log.debug("1 retry left") else: log.debug("%i retries left", remaining_retries) - timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers) + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) log.info("Retrying request to %s in %f seconds", options.url, timeout) - # In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a - # different thread if necessary. time.sleep(timeout) - return self._request( - options=options, - cast_to=cast_to, - retries_taken=retries_taken + 1, - stream=stream, - stream_cls=stream_cls, - ) - def _process_response( self, *, @@ -1402,7 +1399,6 @@ async def request( options: FinalRequestOptions, *, stream: Literal[False] = False, - remaining_retries: Optional[int] = None, ) -> ResponseT: ... @overload @@ -1413,7 +1409,6 @@ async def request( *, stream: Literal[True], stream_cls: type[_AsyncStreamT], - remaining_retries: Optional[int] = None, ) -> _AsyncStreamT: ... @overload @@ -1424,7 +1419,6 @@ async def request( *, stream: bool, stream_cls: type[_AsyncStreamT] | None = None, - remaining_retries: Optional[int] = None, ) -> ResponseT | _AsyncStreamT: ... async def request( @@ -1434,116 +1428,111 @@ async def request( *, stream: bool = False, stream_cls: type[_AsyncStreamT] | None = None, - remaining_retries: Optional[int] = None, - ) -> ResponseT | _AsyncStreamT: - if remaining_retries is not None: - retries_taken = options.get_max_retries(self.max_retries) - remaining_retries - else: - retries_taken = 0 - - return await self._request( - cast_to=cast_to, - options=options, - stream=stream, - stream_cls=stream_cls, - retries_taken=retries_taken, - ) - - async def _request( - self, - cast_to: Type[ResponseT], - options: FinalRequestOptions, - *, - stream: bool, - stream_cls: type[_AsyncStreamT] | None, - retries_taken: int, ) -> ResponseT | _AsyncStreamT: if self._platform is None: # `get_platform` can make blocking IO calls so we # execute it earlier while we are in an async context self._platform = await asyncify(get_platform)() + cast_to = self._maybe_override_cast_to(cast_to, options) + # create a copy of the options we were given so that if the # options are mutated later & we then retry, the retries are # given the original options input_options = model_copy(options) + if input_options.idempotency_key is None and input_options.method.lower() != "get": + # ensure the idempotency key is reused between requests + input_options.idempotency_key = self._idempotency_key() - cast_to = self._maybe_override_cast_to(cast_to, options) - options = await self._prepare_options(options) + response: httpx.Response | None = None + max_retries = input_options.get_max_retries(self.max_retries) - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken - request = self._build_request(options, retries_taken=retries_taken) - await self._prepare_request(request) + retries_taken = 0 + for retries_taken in range(max_retries + 1): + options = model_copy(input_options) + options = await self._prepare_options(options) - kwargs: HttpxSendArgs = {} - if self.custom_auth is not None: - kwargs["auth"] = self.custom_auth + remaining_retries = max_retries - retries_taken + request = self._build_request(options, retries_taken=retries_taken) + await self._prepare_request(request) - try: - response = await self._client.send( - request, - stream=stream or self._should_stream_response_body(request=request), - **kwargs, - ) - except httpx.TimeoutException as err: - log.debug("Encountered httpx.TimeoutException", exc_info=True) - - if remaining_retries > 0: - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, - ) + kwargs: HttpxSendArgs = {} + if self.custom_auth is not None: + kwargs["auth"] = self.custom_auth - log.debug("Raising timeout error") - raise APITimeoutError(request=request) from err - except Exception as err: - log.debug("Encountered Exception", exc_info=True) + log.debug("Sending HTTP Request: %s %s", request.method, request.url) - if remaining_retries > 0: - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - stream=stream, - stream_cls=stream_cls, - response_headers=None, + response = None + try: + response = await self._client.send( + request, + stream=stream or self._should_stream_response_body(request=request), + **kwargs, ) + except httpx.TimeoutException as err: + log.debug("Encountered httpx.TimeoutException", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising timeout error") + raise APITimeoutError(request=request) from err + except Exception as err: + log.debug("Encountered Exception", exc_info=True) + + if remaining_retries > 0: + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=None, + ) + continue + + log.debug("Raising connection error") + raise APIConnectionError(request=request) from err + + log.debug( + 'HTTP Response: %s %s "%i %s" %s', + request.method, + request.url, + response.status_code, + response.reason_phrase, + response.headers, + ) - log.debug("Raising connection error") - raise APIConnectionError(request=request) from err + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code + log.debug("Encountered httpx.HTTPStatusError", exc_info=True) + + if remaining_retries > 0 and self._should_retry(err.response): + await err.response.aclose() + await self._sleep_for_retry( + retries_taken=retries_taken, + max_retries=max_retries, + options=input_options, + response=response, + ) + continue - log.debug( - 'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase - ) + # If the response is streamed then we need to explicitly read the response + # to completion before attempting to access the response text. + if not err.response.is_closed: + await err.response.aread() - try: - response.raise_for_status() - except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code - log.debug("Encountered httpx.HTTPStatusError", exc_info=True) - - if remaining_retries > 0 and self._should_retry(err.response): - await err.response.aclose() - return await self._retry_request( - input_options, - cast_to, - retries_taken=retries_taken, - response_headers=err.response.headers, - stream=stream, - stream_cls=stream_cls, - ) + log.debug("Re-raising status error") + raise self._make_status_error_from_response(err.response) from None - # If the response is streamed then we need to explicitly read the response - # to completion before attempting to access the response text. - if not err.response.is_closed: - await err.response.aread() - - log.debug("Re-raising status error") - raise self._make_status_error_from_response(err.response) from None + break + assert response is not None, "could not resolve response (should never happen)" return await self._process_response( cast_to=cast_to, options=options, @@ -1553,35 +1542,20 @@ async def _request( retries_taken=retries_taken, ) - async def _retry_request( - self, - options: FinalRequestOptions, - cast_to: Type[ResponseT], - *, - retries_taken: int, - response_headers: httpx.Headers | None, - stream: bool, - stream_cls: type[_AsyncStreamT] | None, - ) -> ResponseT | _AsyncStreamT: - remaining_retries = options.get_max_retries(self.max_retries) - retries_taken + async def _sleep_for_retry( + self, *, retries_taken: int, max_retries: int, options: FinalRequestOptions, response: httpx.Response | None + ) -> None: + remaining_retries = max_retries - retries_taken if remaining_retries == 1: log.debug("1 retry left") else: log.debug("%i retries left", remaining_retries) - timeout = self._calculate_retry_timeout(remaining_retries, options, response_headers) + timeout = self._calculate_retry_timeout(remaining_retries, options, response.headers if response else None) log.info("Retrying request to %s in %f seconds", options.url, timeout) await anyio.sleep(timeout) - return await self._request( - options=options, - cast_to=cast_to, - retries_taken=retries_taken + 1, - stream=stream, - stream_cls=stream_cls, - ) - async def _process_response( self, *, diff --git a/src/arcadepy/_client.py b/src/arcadepy/_client.py index 1cd8c83f..e8e9e51d 100644 --- a/src/arcadepy/_client.py +++ b/src/arcadepy/_client.py @@ -19,10 +19,7 @@ ProxiesTypes, RequestOptions, ) -from ._utils import ( - is_given, - get_async_library, -) +from ._utils import is_given, get_async_library from ._version import __version__ from .resources import auth, health, workers from ._streaming import Stream as Stream, AsyncStream as AsyncStream diff --git a/src/arcadepy/_models.py b/src/arcadepy/_models.py index c4401ff8..798956f1 100644 --- a/src/arcadepy/_models.py +++ b/src/arcadepy/_models.py @@ -19,7 +19,6 @@ ) import pydantic -import pydantic.generics from pydantic.fields import FieldInfo from ._types import ( @@ -65,7 +64,7 @@ from ._constants import RAW_RESPONSE_HEADER if TYPE_CHECKING: - from pydantic_core.core_schema import ModelField, LiteralSchema, ModelFieldsSchema + from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema __all__ = ["BaseModel", "GenericModel"] @@ -627,8 +626,8 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, # Note: if one variant defines an alias then they all should discriminator_alias = field_info.alias - if field_info.annotation and is_literal_type(field_info.annotation): - for entry in get_args(field_info.annotation): + if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation): + for entry in get_args(annotation): if isinstance(entry, str): mapping[entry] = variant @@ -646,15 +645,18 @@ def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None: schema = model.__pydantic_core_schema__ + if schema["type"] == "definitions": + schema = schema["schema"] + if schema["type"] != "model": return None + schema = cast("ModelSchema", schema) fields_schema = schema["schema"] if fields_schema["type"] != "model-fields": return None fields_schema = cast("ModelFieldsSchema", fields_schema) - field = fields_schema["fields"].get(field_name) if not field: return None @@ -678,7 +680,7 @@ def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None: setattr(typ, "__pydantic_config__", config) # noqa: B010 -# our use of subclasssing here causes weirdness for type checkers, +# our use of subclassing here causes weirdness for type checkers, # so we just pretend that we don't subclass if TYPE_CHECKING: GenericModel = BaseModel diff --git a/src/arcadepy/_response.py b/src/arcadepy/_response.py index cfc188e2..eb5ab8c2 100644 --- a/src/arcadepy/_response.py +++ b/src/arcadepy/_response.py @@ -233,7 +233,7 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: # split is required to handle cases where additional information is included # in the response, e.g. application/json; charset=utf-8 content_type, *_ = response.headers.get("content-type", "*").split(";") - if content_type != "application/json": + if not content_type.endswith("json"): if is_basemodel(cast_to): try: data = response.json() diff --git a/src/arcadepy/_utils/_transform.py b/src/arcadepy/_utils/_transform.py index 18afd9d8..b0cc20a7 100644 --- a/src/arcadepy/_utils/_transform.py +++ b/src/arcadepy/_utils/_transform.py @@ -5,13 +5,15 @@ import pathlib from typing import Any, Mapping, TypeVar, cast from datetime import date, datetime -from typing_extensions import Literal, get_args, override, get_type_hints +from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints import anyio import pydantic from ._utils import ( is_list, + is_given, + lru_cache, is_mapping, is_iterable, ) @@ -108,6 +110,7 @@ class Params(TypedDict, total=False): return cast(_T, transformed) +@lru_cache(maxsize=8096) def _get_annotated_type(type_: type) -> type | None: """If the given type is an `Annotated` type then it is returned, if not `None` is returned. @@ -126,7 +129,7 @@ def _get_annotated_type(type_: type) -> type | None: def _maybe_transform_key(key: str, type_: type) -> str: """Transform the given `data` based on the annotations provided in `type_`. - Note: this function only looks at `Annotated` types that contain `PropertInfo` metadata. + Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata. """ annotated_type = _get_annotated_type(type_) if annotated_type is None: @@ -142,6 +145,10 @@ def _maybe_transform_key(key: str, type_: type) -> str: return key +def _no_transform_needed(annotation: type) -> bool: + return annotation == float or annotation == int + + def _transform_recursive( data: object, *, @@ -184,6 +191,15 @@ def _transform_recursive( return cast(object, data) inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] if is_union_type(stripped_type): @@ -245,6 +261,11 @@ def _transform_typeddict( result: dict[str, object] = {} annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + type_ = annotations.get(key) if type_ is None: # we do not have a type annotation for this field, leave it as is @@ -332,6 +353,15 @@ async def _async_transform_recursive( return cast(object, data) inner_type = extract_type_arg(stripped_type, 0) + if _no_transform_needed(inner_type): + # for some types there is no need to transform anything, so we can get a small + # perf boost from skipping that work. + # + # but we still need to convert to a list to ensure the data is json-serializable + if is_list(data): + return data + return list(data) + return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data] if is_union_type(stripped_type): @@ -393,6 +423,11 @@ async def _async_transform_typeddict( result: dict[str, object] = {} annotations = get_type_hints(expected_type, include_extras=True) for key, value in data.items(): + if not is_given(value): + # we don't need to include `NotGiven` values here as they'll + # be stripped out before the request is sent anyway + continue + type_ = annotations.get(key) if type_ is None: # we do not have a type annotation for this field, leave it as is @@ -400,3 +435,13 @@ async def _async_transform_typeddict( else: result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_) return result + + +@lru_cache(maxsize=8096) +def get_type_hints( + obj: Any, + globalns: dict[str, Any] | None = None, + localns: Mapping[str, Any] | None = None, + include_extras: bool = False, +) -> dict[str, Any]: + return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras) diff --git a/src/arcadepy/_utils/_typing.py b/src/arcadepy/_utils/_typing.py index 278749b1..1bac9542 100644 --- a/src/arcadepy/_utils/_typing.py +++ b/src/arcadepy/_utils/_typing.py @@ -13,6 +13,7 @@ get_origin, ) +from ._utils import lru_cache from .._types import InheritsGeneric from .._compat import is_union as _is_union @@ -66,6 +67,7 @@ def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]: # Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]] +@lru_cache(maxsize=8096) def strip_annotated_type(typ: type) -> type: if is_required_type(typ) or is_annotated_type(typ): return strip_annotated_type(cast(type, get_args(typ)[0])) @@ -108,7 +110,7 @@ class MyResponse(Foo[_T]): ``` """ cls = cast(object, get_origin(typ) or typ) - if cls in generic_bases: + if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains] # we're given the class directly return extract_type_arg(typ, index) diff --git a/src/arcadepy/_utils/_utils.py b/src/arcadepy/_utils/_utils.py index e5811bba..ea3cf3f2 100644 --- a/src/arcadepy/_utils/_utils.py +++ b/src/arcadepy/_utils/_utils.py @@ -72,8 +72,16 @@ def _extract_items( from .._files import assert_is_file_content # We have exhausted the path, return the entry we found. - assert_is_file_content(obj, key=flattened_key) assert flattened_key is not None + + if is_list(obj): + files: list[tuple[str, FileTypes]] = [] + for entry in obj: + assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "") + files.append((flattened_key + "[]", cast(FileTypes, entry))) + return files + + assert_is_file_content(obj, key=flattened_key) return [(flattened_key, cast(FileTypes, obj))] index += 1 diff --git a/src/arcadepy/_version.py b/src/arcadepy/_version.py index 710f3552..a532eb91 100644 --- a/src/arcadepy/_version.py +++ b/src/arcadepy/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "arcadepy" -__version__ = "1.3.1" # x-release-please-version +__version__ = "1.4.0" # x-release-please-version diff --git a/src/arcadepy/resources/auth.py b/src/arcadepy/resources/auth.py index 2a8c256f..115632c2 100644 --- a/src/arcadepy/resources/auth.py +++ b/src/arcadepy/resources/auth.py @@ -6,10 +6,7 @@ from ..types import auth_status_params, auth_authorize_params from .._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from .._utils import ( - maybe_transform, - async_maybe_transform, -) +from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( diff --git a/src/arcadepy/resources/chat/completions.py b/src/arcadepy/resources/chat/completions.py index d2703c64..d7577e97 100644 --- a/src/arcadepy/resources/chat/completions.py +++ b/src/arcadepy/resources/chat/completions.py @@ -7,10 +7,7 @@ import httpx from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import ( - maybe_transform, - async_maybe_transform, -) +from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( diff --git a/src/arcadepy/resources/tools/formatted.py b/src/arcadepy/resources/tools/formatted.py index e4d87fc6..ebd8a1ad 100644 --- a/src/arcadepy/resources/tools/formatted.py +++ b/src/arcadepy/resources/tools/formatted.py @@ -5,10 +5,7 @@ import httpx from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import ( - maybe_transform, - async_maybe_transform, -) +from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from ..._resource import SyncAPIResource, AsyncAPIResource from ..._response import ( diff --git a/src/arcadepy/resources/tools/tools.py b/src/arcadepy/resources/tools/tools.py index 4ce0a37f..78b47bbc 100644 --- a/src/arcadepy/resources/tools/tools.py +++ b/src/arcadepy/resources/tools/tools.py @@ -2,16 +2,14 @@ from __future__ import annotations -from typing import Dict +from typing import Dict, List +from typing_extensions import Literal import httpx -from ...types import tool_list_params, tool_execute_params, tool_authorize_params +from ...types import tool_get_params, tool_list_params, tool_execute_params, tool_authorize_params from ..._types import NOT_GIVEN, Body, Query, Headers, NotGiven -from ..._utils import ( - maybe_transform, - async_maybe_transform, -) +from ..._utils import maybe_transform, async_maybe_transform from ..._compat import cached_property from .formatted import ( FormattedResource, @@ -76,6 +74,7 @@ def with_streaming_response(self) -> ToolsResourceWithStreamingResponse: def list( self, *, + include_format: List[Literal["arcade", "openai", "anthropic"]] | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, offset: int | NotGiven = NOT_GIVEN, toolkit: str | NotGiven = NOT_GIVEN, @@ -91,6 +90,8 @@ def list( toolkit Args: + include_format: Comma separated tool formats that will be included in the response. + limit: Number of items to return (default: 25, max: 100) offset: Offset from the start of the list (default: 0) @@ -115,6 +116,7 @@ def list( timeout=timeout, query=maybe_transform( { + "include_format": include_format, "limit": limit, "offset": offset, "toolkit": toolkit, @@ -192,7 +194,7 @@ def execute( input: JSON input to the tool, if any run_at: The time at which the tool should be run (optional). If not provided, the tool - is run immediately + is run immediately. Format ISO 8601: YYYY-MM-DDTHH:MM:SS tool_version: The tool version to use (optional). If not provided, any version is used @@ -226,6 +228,7 @@ def get( self, name: str, *, + include_format: List[Literal["arcade", "openai", "anthropic"]] | NotGiven = NOT_GIVEN, # 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, @@ -237,6 +240,8 @@ def get( Returns the arcade tool specification for a specific tool Args: + include_format: Comma separated tool formats that will be included in the response. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -250,7 +255,11 @@ def get( return self._get( f"/v1/tools/{name}", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=maybe_transform({"include_format": include_format}, tool_get_params.ToolGetParams), ), cast_to=ToolDefinition, ) @@ -287,6 +296,7 @@ def with_streaming_response(self) -> AsyncToolsResourceWithStreamingResponse: def list( self, *, + include_format: List[Literal["arcade", "openai", "anthropic"]] | NotGiven = NOT_GIVEN, limit: int | NotGiven = NOT_GIVEN, offset: int | NotGiven = NOT_GIVEN, toolkit: str | NotGiven = NOT_GIVEN, @@ -302,6 +312,8 @@ def list( toolkit Args: + include_format: Comma separated tool formats that will be included in the response. + limit: Number of items to return (default: 25, max: 100) offset: Offset from the start of the list (default: 0) @@ -326,6 +338,7 @@ def list( timeout=timeout, query=maybe_transform( { + "include_format": include_format, "limit": limit, "offset": offset, "toolkit": toolkit, @@ -403,7 +416,7 @@ async def execute( input: JSON input to the tool, if any run_at: The time at which the tool should be run (optional). If not provided, the tool - is run immediately + is run immediately. Format ISO 8601: YYYY-MM-DDTHH:MM:SS tool_version: The tool version to use (optional). If not provided, any version is used @@ -437,6 +450,7 @@ async def get( self, name: str, *, + include_format: List[Literal["arcade", "openai", "anthropic"]] | NotGiven = NOT_GIVEN, # 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, @@ -448,6 +462,8 @@ async def get( Returns the arcade tool specification for a specific tool Args: + include_format: Comma separated tool formats that will be included in the response. + extra_headers: Send extra headers extra_query: Add additional query parameters to the request @@ -461,7 +477,11 @@ async def get( return await self._get( f"/v1/tools/{name}", options=make_request_options( - extra_headers=extra_headers, extra_query=extra_query, extra_body=extra_body, timeout=timeout + extra_headers=extra_headers, + extra_query=extra_query, + extra_body=extra_body, + timeout=timeout, + query=await async_maybe_transform({"include_format": include_format}, tool_get_params.ToolGetParams), ), cast_to=ToolDefinition, ) diff --git a/src/arcadepy/resources/workers.py b/src/arcadepy/resources/workers.py index 05893630..3fa6e2c6 100644 --- a/src/arcadepy/resources/workers.py +++ b/src/arcadepy/resources/workers.py @@ -6,10 +6,7 @@ from ..types import worker_list_params, worker_tools_params, worker_create_params, worker_update_params from .._types import NOT_GIVEN, Body, Query, Headers, NoneType, NotGiven -from .._utils import ( - maybe_transform, - async_maybe_transform, -) +from .._utils import maybe_transform, async_maybe_transform from .._compat import cached_property from .._resource import SyncAPIResource, AsyncAPIResource from .._response import ( @@ -51,8 +48,10 @@ def create( self, *, id: str, - enabled: bool, + enabled: bool | NotGiven = NOT_GIVEN, http: worker_create_params.HTTP | NotGiven = NOT_GIVEN, + mcp: worker_create_params.Mcp | NotGiven = NOT_GIVEN, + type: str | NotGiven = NOT_GIVEN, # 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, @@ -79,6 +78,8 @@ def create( "id": id, "enabled": enabled, "http": http, + "mcp": mcp, + "type": type, }, worker_create_params.WorkerCreateParams, ), @@ -94,6 +95,7 @@ def update( *, enabled: bool | NotGiven = NOT_GIVEN, http: worker_update_params.HTTP | NotGiven = NOT_GIVEN, + mcp: worker_update_params.Mcp | NotGiven = NOT_GIVEN, # 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, @@ -121,6 +123,7 @@ def update( { "enabled": enabled, "http": http, + "mcp": mcp, }, worker_update_params.WorkerUpdateParams, ), @@ -352,8 +355,10 @@ async def create( self, *, id: str, - enabled: bool, + enabled: bool | NotGiven = NOT_GIVEN, http: worker_create_params.HTTP | NotGiven = NOT_GIVEN, + mcp: worker_create_params.Mcp | NotGiven = NOT_GIVEN, + type: str | NotGiven = NOT_GIVEN, # 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, @@ -380,6 +385,8 @@ async def create( "id": id, "enabled": enabled, "http": http, + "mcp": mcp, + "type": type, }, worker_create_params.WorkerCreateParams, ), @@ -395,6 +402,7 @@ async def update( *, enabled: bool | NotGiven = NOT_GIVEN, http: worker_update_params.HTTP | NotGiven = NOT_GIVEN, + mcp: worker_update_params.Mcp | NotGiven = NOT_GIVEN, # 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, @@ -422,6 +430,7 @@ async def update( { "enabled": enabled, "http": http, + "mcp": mcp, }, worker_update_params.WorkerUpdateParams, ), diff --git a/src/arcadepy/types/__init__.py b/src/arcadepy/types/__init__.py index 1b6a4beb..6fd782f2 100644 --- a/src/arcadepy/types/__init__.py +++ b/src/arcadepy/types/__init__.py @@ -15,6 +15,7 @@ from .health_schema import HealthSchema as HealthSchema from .tool_execution import ToolExecution as ToolExecution from .tool_definition import ToolDefinition as ToolDefinition +from .tool_get_params import ToolGetParams as ToolGetParams from .worker_response import WorkerResponse as WorkerResponse from .tool_list_params import ToolListParams as ToolListParams from .tool_get_response import ToolGetResponse as ToolGetResponse diff --git a/src/arcadepy/types/tool_definition.py b/src/arcadepy/types/tool_definition.py index 71a7ea62..1fdbc15c 100644 --- a/src/arcadepy/types/tool_definition.py +++ b/src/arcadepy/types/tool_definition.py @@ -1,6 +1,6 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. -from typing import List, Optional +from typing import Dict, List, Optional from .._models import BaseModel from .value_schema import ValueSchema @@ -75,15 +75,19 @@ class Requirements(BaseModel): class ToolDefinition(BaseModel): + fully_qualified_name: str + input: Input name: str + qualified_name: str + toolkit: Toolkit description: Optional[str] = None - fully_qualified_name: Optional[str] = None + formatted_schema: Optional[Dict[str, object]] = None output: Optional[Output] = None diff --git a/src/arcadepy/types/tool_execute_params.py b/src/arcadepy/types/tool_execute_params.py index a3692a7b..8c354997 100644 --- a/src/arcadepy/types/tool_execute_params.py +++ b/src/arcadepy/types/tool_execute_params.py @@ -17,7 +17,8 @@ class ToolExecuteParams(TypedDict, total=False): run_at: str """The time at which the tool should be run (optional). - If not provided, the tool is run immediately + If not provided, the tool is run immediately. Format ISO 8601: + YYYY-MM-DDTHH:MM:SS """ tool_version: str diff --git a/src/arcadepy/types/tool_get_params.py b/src/arcadepy/types/tool_get_params.py new file mode 100644 index 00000000..b3b25ae4 --- /dev/null +++ b/src/arcadepy/types/tool_get_params.py @@ -0,0 +1,13 @@ +# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +from __future__ import annotations + +from typing import List +from typing_extensions import Literal, TypedDict + +__all__ = ["ToolGetParams"] + + +class ToolGetParams(TypedDict, total=False): + include_format: List[Literal["arcade", "openai", "anthropic"]] + """Comma separated tool formats that will be included in the response.""" diff --git a/src/arcadepy/types/tool_list_params.py b/src/arcadepy/types/tool_list_params.py index 336d56a3..e2428ed3 100644 --- a/src/arcadepy/types/tool_list_params.py +++ b/src/arcadepy/types/tool_list_params.py @@ -2,12 +2,16 @@ from __future__ import annotations -from typing_extensions import TypedDict +from typing import List +from typing_extensions import Literal, TypedDict __all__ = ["ToolListParams"] class ToolListParams(TypedDict, total=False): + include_format: List[Literal["arcade", "openai", "anthropic"]] + """Comma separated tool formats that will be included in the response.""" + limit: int """Number of items to return (default: 25, max: 100)""" diff --git a/src/arcadepy/types/worker_create_params.py b/src/arcadepy/types/worker_create_params.py index d08266e3..00c69663 100644 --- a/src/arcadepy/types/worker_create_params.py +++ b/src/arcadepy/types/worker_create_params.py @@ -4,16 +4,20 @@ from typing_extensions import Required, TypedDict -__all__ = ["WorkerCreateParams", "HTTP"] +__all__ = ["WorkerCreateParams", "HTTP", "Mcp"] class WorkerCreateParams(TypedDict, total=False): id: Required[str] - enabled: Required[bool] + enabled: bool http: HTTP + mcp: Mcp + + type: str + class HTTP(TypedDict, total=False): retry: Required[int] @@ -23,3 +27,11 @@ class HTTP(TypedDict, total=False): timeout: Required[int] uri: Required[str] + + +class Mcp(TypedDict, total=False): + retry: Required[int] + + timeout: Required[int] + + uri: Required[str] diff --git a/src/arcadepy/types/worker_response.py b/src/arcadepy/types/worker_response.py index 0f7f434d..01276285 100644 --- a/src/arcadepy/types/worker_response.py +++ b/src/arcadepy/types/worker_response.py @@ -5,11 +5,17 @@ from .._models import BaseModel -__all__ = ["WorkerResponse", "HTTP", "HTTPSecret"] +__all__ = ["WorkerResponse", "Binding", "HTTP", "HTTPSecret", "Mcp", "Oxp", "OxpSecret"] + + +class Binding(BaseModel): + id: Optional[str] = None + + type: Optional[Literal["static", "tenant", "project", "account"]] = None class HTTPSecret(BaseModel): - binding: Optional[Literal["static", "tenant", "organization", "account"]] = None + binding: Optional[Literal["static", "tenant", "project", "account"]] = None editable: Optional[bool] = None @@ -30,11 +36,47 @@ class HTTP(BaseModel): uri: Optional[str] = None +class Mcp(BaseModel): + retry: Optional[int] = None + + timeout: Optional[int] = None + + uri: Optional[str] = None + + +class OxpSecret(BaseModel): + binding: Optional[Literal["static", "tenant", "project", "account"]] = None + + editable: Optional[bool] = None + + exists: Optional[bool] = None + + hint: Optional[str] = None + + value: Optional[str] = None + + +class Oxp(BaseModel): + retry: Optional[int] = None + + secret: Optional[OxpSecret] = None + + timeout: Optional[int] = None + + uri: Optional[str] = None + + class WorkerResponse(BaseModel): id: Optional[str] = None + binding: Optional[Binding] = None + enabled: Optional[bool] = None http: Optional[HTTP] = None - type: Optional[str] = None + mcp: Optional[Mcp] = None + + oxp: Optional[Oxp] = None + + type: Optional[Literal["http", "mcp", "unknown"]] = None diff --git a/src/arcadepy/types/worker_update_params.py b/src/arcadepy/types/worker_update_params.py index 0215ac17..a07fbb69 100644 --- a/src/arcadepy/types/worker_update_params.py +++ b/src/arcadepy/types/worker_update_params.py @@ -4,7 +4,7 @@ from typing_extensions import TypedDict -__all__ = ["WorkerUpdateParams", "HTTP"] +__all__ = ["WorkerUpdateParams", "HTTP", "Mcp"] class WorkerUpdateParams(TypedDict, total=False): @@ -12,6 +12,8 @@ class WorkerUpdateParams(TypedDict, total=False): http: HTTP + mcp: Mcp + class HTTP(TypedDict, total=False): retry: int @@ -21,3 +23,11 @@ class HTTP(TypedDict, total=False): timeout: int uri: str + + +class Mcp(TypedDict, total=False): + retry: int + + timeout: int + + uri: str diff --git a/tests/api_resources/test_tools.py b/tests/api_resources/test_tools.py index 3727f7c6..6fd1456b 100644 --- a/tests/api_resources/test_tools.py +++ b/tests/api_resources/test_tools.py @@ -30,6 +30,7 @@ def test_method_list(self, client: Arcade) -> None: @parametrize def test_method_list_with_all_params(self, client: Arcade) -> None: tool = client.tools.list( + include_format=["arcade"], limit=0, offset=0, toolkit="toolkit", @@ -141,14 +142,22 @@ def test_streaming_response_execute(self, client: Arcade) -> None: @parametrize def test_method_get(self, client: Arcade) -> None: tool = client.tools.get( - "name", + name="name", + ) + assert_matches_type(ToolDefinition, tool, path=["response"]) + + @parametrize + def test_method_get_with_all_params(self, client: Arcade) -> None: + tool = client.tools.get( + name="name", + include_format=["arcade"], ) assert_matches_type(ToolDefinition, tool, path=["response"]) @parametrize def test_raw_response_get(self, client: Arcade) -> None: response = client.tools.with_raw_response.get( - "name", + name="name", ) assert response.is_closed is True @@ -159,7 +168,7 @@ def test_raw_response_get(self, client: Arcade) -> None: @parametrize def test_streaming_response_get(self, client: Arcade) -> None: with client.tools.with_streaming_response.get( - "name", + name="name", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -173,7 +182,7 @@ def test_streaming_response_get(self, client: Arcade) -> None: def test_path_params_get(self, client: Arcade) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): client.tools.with_raw_response.get( - "", + name="", ) @@ -188,6 +197,7 @@ async def test_method_list(self, async_client: AsyncArcade) -> None: @parametrize async def test_method_list_with_all_params(self, async_client: AsyncArcade) -> None: tool = await async_client.tools.list( + include_format=["arcade"], limit=0, offset=0, toolkit="toolkit", @@ -299,14 +309,22 @@ async def test_streaming_response_execute(self, async_client: AsyncArcade) -> No @parametrize async def test_method_get(self, async_client: AsyncArcade) -> None: tool = await async_client.tools.get( - "name", + name="name", + ) + assert_matches_type(ToolDefinition, tool, path=["response"]) + + @parametrize + async def test_method_get_with_all_params(self, async_client: AsyncArcade) -> None: + tool = await async_client.tools.get( + name="name", + include_format=["arcade"], ) assert_matches_type(ToolDefinition, tool, path=["response"]) @parametrize async def test_raw_response_get(self, async_client: AsyncArcade) -> None: response = await async_client.tools.with_raw_response.get( - "name", + name="name", ) assert response.is_closed is True @@ -317,7 +335,7 @@ async def test_raw_response_get(self, async_client: AsyncArcade) -> None: @parametrize async def test_streaming_response_get(self, async_client: AsyncArcade) -> None: async with async_client.tools.with_streaming_response.get( - "name", + name="name", ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -331,5 +349,5 @@ async def test_streaming_response_get(self, async_client: AsyncArcade) -> None: async def test_path_params_get(self, async_client: AsyncArcade) -> None: with pytest.raises(ValueError, match=r"Expected a non-empty value for `name` but received ''"): await async_client.tools.with_raw_response.get( - "", + name="", ) diff --git a/tests/api_resources/test_workers.py b/tests/api_resources/test_workers.py index ab065073..155e1c09 100644 --- a/tests/api_resources/test_workers.py +++ b/tests/api_resources/test_workers.py @@ -26,7 +26,6 @@ class TestWorkers: def test_method_create(self, client: Arcade) -> None: worker = client.workers.create( id="id", - enabled=True, ) assert_matches_type(WorkerResponse, worker, path=["response"]) @@ -41,6 +40,12 @@ def test_method_create_with_all_params(self, client: Arcade) -> None: "timeout": 1, "uri": "uri", }, + mcp={ + "retry": 0, + "timeout": 1, + "uri": "uri", + }, + type="type", ) assert_matches_type(WorkerResponse, worker, path=["response"]) @@ -48,7 +53,6 @@ def test_method_create_with_all_params(self, client: Arcade) -> None: def test_raw_response_create(self, client: Arcade) -> None: response = client.workers.with_raw_response.create( id="id", - enabled=True, ) assert response.is_closed is True @@ -60,7 +64,6 @@ def test_raw_response_create(self, client: Arcade) -> None: def test_streaming_response_create(self, client: Arcade) -> None: with client.workers.with_streaming_response.create( id="id", - enabled=True, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -88,6 +91,11 @@ def test_method_update_with_all_params(self, client: Arcade) -> None: "timeout": 1, "uri": "uri", }, + mcp={ + "retry": 0, + "timeout": 1, + "uri": "uri", + }, ) assert_matches_type(WorkerResponse, worker, path=["response"]) @@ -324,7 +332,6 @@ class TestAsyncWorkers: async def test_method_create(self, async_client: AsyncArcade) -> None: worker = await async_client.workers.create( id="id", - enabled=True, ) assert_matches_type(WorkerResponse, worker, path=["response"]) @@ -339,6 +346,12 @@ async def test_method_create_with_all_params(self, async_client: AsyncArcade) -> "timeout": 1, "uri": "uri", }, + mcp={ + "retry": 0, + "timeout": 1, + "uri": "uri", + }, + type="type", ) assert_matches_type(WorkerResponse, worker, path=["response"]) @@ -346,7 +359,6 @@ async def test_method_create_with_all_params(self, async_client: AsyncArcade) -> async def test_raw_response_create(self, async_client: AsyncArcade) -> None: response = await async_client.workers.with_raw_response.create( id="id", - enabled=True, ) assert response.is_closed is True @@ -358,7 +370,6 @@ async def test_raw_response_create(self, async_client: AsyncArcade) -> None: async def test_streaming_response_create(self, async_client: AsyncArcade) -> None: async with async_client.workers.with_streaming_response.create( id="id", - enabled=True, ) as response: assert not response.is_closed assert response.http_request.headers.get("X-Stainless-Lang") == "python" @@ -386,6 +397,11 @@ async def test_method_update_with_all_params(self, async_client: AsyncArcade) -> "timeout": 1, "uri": "uri", }, + mcp={ + "retry": 0, + "timeout": 1, + "uri": "uri", + }, ) assert_matches_type(WorkerResponse, worker, path=["response"]) diff --git a/tests/conftest.py b/tests/conftest.py index 2345a0b2..5dbc7840 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from arcadepy import Arcade, AsyncArcade if TYPE_CHECKING: - from _pytest.fixtures import FixtureRequest + from _pytest.fixtures import FixtureRequest # pyright: ignore[reportPrivateImportUsage] pytest.register_assert_rewrite("tests.utils") diff --git a/tests/test_client.py b/tests/test_client.py index 7a0b0109..19e02d8b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1663,7 +1663,7 @@ def test_get_platform(self) -> None: import threading from arcadepy._utils import asyncify - from arcadepy._base_client import get_platform + from arcadepy._base_client import get_platform async def test_main() -> None: result = await asyncify(get_platform)() diff --git a/tests/test_models.py b/tests/test_models.py index bd512ab8..d96a3763 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -492,12 +492,15 @@ class Model(BaseModel): resource_id: Optional[str] = None m = Model.construct() + assert m.resource_id is None assert "resource_id" not in m.model_fields_set m = Model.construct(resource_id=None) + assert m.resource_id is None assert "resource_id" in m.model_fields_set m = Model.construct(resource_id="foo") + assert m.resource_id == "foo" assert "resource_id" in m.model_fields_set @@ -832,7 +835,7 @@ class B(BaseModel): @pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") def test_type_alias_type() -> None: - Alias = TypeAliasType("Alias", str) + Alias = TypeAliasType("Alias", str) # pyright: ignore class Model(BaseModel): alias: Alias @@ -854,3 +857,35 @@ class Model(BaseModel): m = construct_type(value={"cls": "foo"}, type_=Model) assert isinstance(m, Model) assert isinstance(m.cls, str) + + +def test_discriminated_union_case() -> None: + class A(BaseModel): + type: Literal["a"] + + data: bool + + class B(BaseModel): + type: Literal["b"] + + data: List[Union[A, object]] + + class ModelA(BaseModel): + type: Literal["modelA"] + + data: int + + class ModelB(BaseModel): + type: Literal["modelB"] + + required: str + + data: Union[A, B] + + # when constructing ModelA | ModelB, value data doesn't match ModelB exactly - missing `required` + m = construct_type( + value={"type": "modelB", "data": {"type": "a", "data": True}}, + type_=cast(Any, Annotated[Union[ModelA, ModelB], PropertyInfo(discriminator="type")]), + ) + + assert isinstance(m, ModelB) diff --git a/tests/test_transform.py b/tests/test_transform.py index 9029329f..c6bb27cf 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -8,7 +8,7 @@ import pytest -from arcadepy._types import Base64FileInput +from arcadepy._types import NOT_GIVEN, Base64FileInput from arcadepy._utils import ( PropertyInfo, transform as _transform, @@ -432,3 +432,22 @@ async def test_base64_file_input(use_async: bool) -> None: assert await transform({"foo": io.BytesIO(b"Hello, world!")}, TypedDictBase64Input, use_async) == { "foo": "SGVsbG8sIHdvcmxkIQ==" } # type: ignore[comparison-overlap] + + +@parametrize +@pytest.mark.asyncio +async def test_transform_skipping(use_async: bool) -> None: + # lists of ints are left as-is + data = [1, 2, 3] + assert await transform(data, List[int], use_async) is data + + # iterables of ints are converted to a list + data = iter([1, 2, 3]) + assert await transform(data, Iterable[int], use_async) == [1, 2, 3] + + +@parametrize +@pytest.mark.asyncio +async def test_strips_notgiven(use_async: bool) -> None: + assert await transform({"foo_bar": "bar"}, Foo1, use_async) == {"fooBar": "bar"} + assert await transform({"foo_bar": NOT_GIVEN}, Foo1, use_async) == {}