diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ac9a2e7..55d2025 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -6,4 +6,4 @@ USER vscode RUN curl -sSf https://rye.astral.sh/get | RYE_VERSION="0.35.0" RYE_INSTALL_OPTION="--yes" bash ENV PATH=/home/vscode/.rye/shims:$PATH -RUN echo "[[ -d .venv ]] && source .venv/bin/activate" >> /home/vscode/.bashrc +RUN echo "[[ -d .venv ]] && source .venv/bin/activate || export PATH=\$PATH" >> /home/vscode/.bashrc diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index bbeb30b..c17fdc1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -24,6 +24,9 @@ } } } + }, + "features": { + "ghcr.io/devcontainers/features/node:1": {} } // Features to add to the dev container. More info: https://containers.dev/features. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4029396..c8a8a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ jobs: lint: name: lint runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 @@ -30,6 +29,7 @@ jobs: - name: Run lints run: ./scripts/lint + test: name: test runs-on: ubuntu-latest @@ -50,4 +50,3 @@ jobs: - name: Run tests run: ./scripts/test - diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ccdf8aa..6b7b74c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.2.3" + ".": "0.3.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8b84e..c60e493 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 0.3.0 (2025-03-11) + +Full Changelog: [v0.2.3...v0.3.0](https://github.com/bespokelabsai/bespokelabs-python/compare/v0.2.3...v0.3.0) + +### Features + +* **client:** allow passing `NotGiven` for body ([#63](https://github.com/bespokelabsai/bespokelabs-python/issues/63)) ([68cbf55](https://github.com/bespokelabsai/bespokelabs-python/commit/68cbf556fa6939420be452cb2614a8cd59657f24)) +* **client:** send `X-Stainless-Read-Timeout` header ([#58](https://github.com/bespokelabsai/bespokelabs-python/issues/58)) ([851c55f](https://github.com/bespokelabsai/bespokelabs-python/commit/851c55f37a80dcc515857fe0e0b1f568034f2a14)) + + +### Bug Fixes + +* asyncify on non-asyncio runtimes ([#62](https://github.com/bespokelabsai/bespokelabs-python/issues/62)) ([e4ff682](https://github.com/bespokelabsai/bespokelabs-python/commit/e4ff682580c7fb4d903e5699a7f828bad4fabd93)) +* **client:** mark some request bodies as optional ([68cbf55](https://github.com/bespokelabsai/bespokelabs-python/commit/68cbf556fa6939420be452cb2614a8cd59657f24)) +* correctly handle deserialising `cls` fields ([#48](https://github.com/bespokelabsai/bespokelabs-python/issues/48)) ([2c35a23](https://github.com/bespokelabsai/bespokelabs-python/commit/2c35a2394ea424fd93ba993cc3a00d601fa26245)) +* **tests:** make test_get_platform less flaky ([#52](https://github.com/bespokelabsai/bespokelabs-python/issues/52)) ([1512d9d](https://github.com/bespokelabsai/bespokelabs-python/commit/1512d9dadae92fd5fa0b1262d28bbc086b3d6523)) + + +### Chores + +* **docs:** update client docstring ([#67](https://github.com/bespokelabsai/bespokelabs-python/issues/67)) ([73aca81](https://github.com/bespokelabsai/bespokelabs-python/commit/73aca8174d7247fd66672dcaa046a7cc1fc7aeaf)) +* **internal:** avoid pytest-asyncio deprecation warning ([#53](https://github.com/bespokelabsai/bespokelabs-python/issues/53)) ([a074b2c](https://github.com/bespokelabsai/bespokelabs-python/commit/a074b2c660f9617b3771f7028366ef5afce6bc9e)) +* **internal:** bummp ruff dependency ([#57](https://github.com/bespokelabsai/bespokelabs-python/issues/57)) ([66e89ec](https://github.com/bespokelabsai/bespokelabs-python/commit/66e89ec55166c4428dce2d79f8a88f597fb59b24)) +* **internal:** change default timeout to an int ([#56](https://github.com/bespokelabsai/bespokelabs-python/issues/56)) ([68f2e60](https://github.com/bespokelabsai/bespokelabs-python/commit/68f2e605d133b699e685a3d006fdaf7fc2dff068)) +* **internal:** codegen related update ([#50](https://github.com/bespokelabsai/bespokelabs-python/issues/50)) ([95246ac](https://github.com/bespokelabsai/bespokelabs-python/commit/95246ac9708bf03aeb7bfd93c57c73d2ec7c4d1f)) +* **internal:** fix devcontainers setup ([#64](https://github.com/bespokelabsai/bespokelabs-python/issues/64)) ([900f05a](https://github.com/bespokelabsai/bespokelabs-python/commit/900f05ad058b3e42de948cdd942b59a86e2f512a)) +* **internal:** fix type traversing dictionary params ([#59](https://github.com/bespokelabsai/bespokelabs-python/issues/59)) ([706e9ac](https://github.com/bespokelabsai/bespokelabs-python/commit/706e9ac51914e6eb1aef6f18ee5b9390c49d8c40)) +* **internal:** minor formatting changes ([#55](https://github.com/bespokelabsai/bespokelabs-python/issues/55)) ([6f4fc83](https://github.com/bespokelabsai/bespokelabs-python/commit/6f4fc83dedd0ce8538cc651a6ff6e8cedca5e5af)) +* **internal:** minor style changes ([#54](https://github.com/bespokelabsai/bespokelabs-python/issues/54)) ([f381d90](https://github.com/bespokelabsai/bespokelabs-python/commit/f381d90ec1a5637392dd9772be23a4e4ad3224ac)) +* **internal:** minor type handling changes ([#60](https://github.com/bespokelabsai/bespokelabs-python/issues/60)) ([02ebbad](https://github.com/bespokelabsai/bespokelabs-python/commit/02ebbadfc5c199bef8e6065f18d5ad5ecd7df0f4)) +* **internal:** properly set __pydantic_private__ ([#65](https://github.com/bespokelabsai/bespokelabs-python/issues/65)) ([6f99289](https://github.com/bespokelabsai/bespokelabs-python/commit/6f99289294ada243b2b221ca7aff097aca9f9696)) +* **internal:** remove unused http client options forwarding ([#68](https://github.com/bespokelabsai/bespokelabs-python/issues/68)) ([3ca5954](https://github.com/bespokelabsai/bespokelabs-python/commit/3ca59544326fd68f92afcdb1dc5fd9d12baaf77a)) +* **internal:** update client tests ([#61](https://github.com/bespokelabsai/bespokelabs-python/issues/61)) ([be4c23a](https://github.com/bespokelabsai/bespokelabs-python/commit/be4c23aaedade0075457b1f2326902d85b2048fb)) + + +### Documentation + +* **raw responses:** fix duplicate `the` ([#51](https://github.com/bespokelabsai/bespokelabs-python/issues/51)) ([ad4d17f](https://github.com/bespokelabsai/bespokelabs-python/commit/ad4d17f7ddceadfcc210383fe3c580738b9dffcd)) +* update URLs from stainlessapi.com to stainless.com ([#66](https://github.com/bespokelabsai/bespokelabs-python/issues/66)) ([9935243](https://github.com/bespokelabsai/bespokelabs-python/commit/99352431d0860e6ec7be7d43a5f19839a60378a4)) + ## 0.2.3 (2025-01-09) Full Changelog: [v0.2.2...v0.2.3](https://github.com/bespokelabsai/bespokelabs-python/compare/v0.2.2...v0.2.3) diff --git a/README.md b/README.md index 2e7b473..4cb2781 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The Bespoke Labs Python library provides convenient access to the Bespoke Labs R application. The library includes type definitions for all request params and response fields, and offers both synchronous and asynchronous clients powered by [httpx](https://github.com/encode/httpx). -It is generated with [Stainless](https://www.stainlessapi.com/). +It is generated with [Stainless](https://www.stainless.com/). ## Documentation diff --git a/SECURITY.md b/SECURITY.md index 9cf2e26..720ed9e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,9 +2,9 @@ ## Reporting Security Issues -This SDK is generated by [Stainless Software Inc](http://stainlessapi.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. -To report a security issue, please contact the Stainless team at security@stainlessapi.com. +To report a security issue, please contact the Stainless team at security@stainless.com. ## Responsible Disclosure diff --git a/mypy.ini b/mypy.ini index a499ae1..ee0ca8b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -41,7 +41,7 @@ cache_fine_grained = True # ``` # Changing this codegen to make mypy happy would increase complexity # and would not be worth it. -disable_error_code = func-returns-value +disable_error_code = func-returns-value,overload-cannot-match # https://github.com/python/mypy/issues/12162 [mypy.overrides] diff --git a/pyproject.toml b/pyproject.toml index 7c3f87b..57ecd19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bespokelabs" -version = "0.2.3" +version = "0.3.0" description = "The official Python library for the bespoke_labs API" dynamic = ["readme"] license = "Apache-2.0" @@ -129,6 +129,7 @@ testpaths = ["tests"] addopts = "--tb=short" xfail_strict = true asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" filterwarnings = [ "error" ] @@ -176,7 +177,7 @@ select = [ "T201", "T203", # misuse of typing.TYPE_CHECKING - "TCH004", + "TC004", # import rules "TID251", ] diff --git a/requirements-dev.lock b/requirements-dev.lock index 5bd676f..5456688 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -48,7 +48,7 @@ markdown-it-py==3.0.0 # via rich mdurl==0.1.2 # via markdown-it-py -mypy==1.13.0 +mypy==1.14.1 mypy-extensions==1.0.0 # via mypy nest-asyncio==1.6.0 @@ -68,7 +68,7 @@ pydantic-core==2.27.1 # via pydantic pygments==2.18.0 # via rich -pyright==1.1.390 +pyright==1.1.392.post0 pytest==8.3.3 # via pytest-asyncio pytest-asyncio==0.24.0 @@ -78,7 +78,7 @@ pytz==2023.3.post1 # via dirty-equals respx==0.22.0 rich==13.7.1 -ruff==0.6.9 +ruff==0.9.4 setuptools==68.2.2 # via nodeenv six==1.16.0 diff --git a/scripts/bootstrap b/scripts/bootstrap index 8c5c60e..e84fe62 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then +if ! command -v rye >/dev/null 2>&1 && [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ]; then brew bundle check >/dev/null 2>&1 || { echo "==> Installing Homebrew dependencies…" brew bundle diff --git a/scripts/lint b/scripts/lint index 55183f9..e3176c7 100755 --- a/scripts/lint +++ b/scripts/lint @@ -9,4 +9,3 @@ rye run lint echo "==> Making sure it imports" rye run python -c 'import bespokelabs' - diff --git a/scripts/test b/scripts/test index 4fa5698..2b87845 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/scripts/utils/ruffen-docs.py b/scripts/utils/ruffen-docs.py index 37b3d94..0cf2bd2 100644 --- a/scripts/utils/ruffen-docs.py +++ b/scripts/utils/ruffen-docs.py @@ -47,7 +47,7 @@ def _md_match(match: Match[str]) -> str: with _collect_error(match): code = format_code_block(code) code = textwrap.indent(code, match["indent"]) - return f'{match["before"]}{code}{match["after"]}' + return f"{match['before']}{code}{match['after']}" def _pycon_match(match: Match[str]) -> str: code = "" @@ -97,7 +97,7 @@ def finish_fragment() -> None: def _md_pycon_match(match: Match[str]) -> str: code = _pycon_match(match) code = textwrap.indent(code, match["indent"]) - return f'{match["before"]}{code}{match["after"]}' + return f"{match['before']}{code}{match['after']}" src = MD_RE.sub(_md_match, src) src = MD_PYCON_RE.sub(_md_pycon_match, src) diff --git a/src/bespokelabs/_base_client.py b/src/bespokelabs/_base_client.py index 59290c7..f0366db 100644 --- a/src/bespokelabs/_base_client.py +++ b/src/bespokelabs/_base_client.py @@ -9,7 +9,6 @@ import inspect import logging import platform -import warnings import email.utils from types import TracebackType from random import random @@ -36,7 +35,7 @@ import httpx import distro import pydantic -from httpx import URL, Limits +from httpx import URL from pydantic import PrivateAttr from . import _exceptions @@ -51,19 +50,16 @@ Timeout, NotGiven, ResponseT, - Transport, AnyMapping, PostParser, - ProxiesTypes, RequestFiles, HttpxSendArgs, - AsyncTransport, RequestOptions, HttpxRequestFiles, ModelBuilderProtocol, ) from ._utils import is_dict, is_list, asyncify, is_given, lru_cache, is_mapping -from ._compat import model_copy, model_dump +from ._compat import PYDANTIC_V2, model_copy, model_dump from ._models import GenericModel, FinalRequestOptions, validate_type, construct_type from ._response import ( APIResponse, @@ -207,6 +203,9 @@ def _set_private_attributes( model: Type[_T], options: FinalRequestOptions, ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + self._model = model self._client = client self._options = options @@ -292,6 +291,9 @@ def _set_private_attributes( client: AsyncAPIClient, options: FinalRequestOptions, ) -> None: + if PYDANTIC_V2 and getattr(self, "__pydantic_private__", None) is None: + self.__pydantic_private__ = {} + self._model = model self._client = client self._options = options @@ -331,9 +333,6 @@ class BaseClient(Generic[_HttpxClientT, _DefaultStreamT]): _base_url: URL max_retries: int timeout: Union[float, Timeout, None] - _limits: httpx.Limits - _proxies: ProxiesTypes | None - _transport: Transport | AsyncTransport | None _strict_response_validation: bool _idempotency_header: str | None _default_stream_cls: type[_DefaultStreamT] | None = None @@ -346,9 +345,6 @@ def __init__( _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None = DEFAULT_TIMEOUT, - limits: httpx.Limits, - transport: Transport | AsyncTransport | None, - proxies: ProxiesTypes | None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: @@ -356,9 +352,6 @@ def __init__( self._base_url = self._enforce_trailing_slash(URL(base_url)) self.max_retries = max_retries self.timeout = timeout - self._limits = limits - self._proxies = proxies - self._transport = transport self._custom_headers = custom_headers or {} self._custom_query = custom_query or {} self._strict_response_validation = _strict_response_validation @@ -418,10 +411,17 @@ def _build_headers(self, options: FinalRequestOptions, *, retries_taken: int = 0 if idempotency_header and options.method.lower() != "get" and idempotency_header not in headers: headers[idempotency_header] = options.idempotency_key or self._idempotency_key() - # Don't set the retry count header if it was already set or removed by the caller. We check + # 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. - if "x-stainless-retry-count" not in (header.lower() for header in custom_headers): + lower_custom_headers = [header.lower() for header in custom_headers] + if "x-stainless-retry-count" not in lower_custom_headers: headers["x-stainless-retry-count"] = str(retries_taken) + if "x-stainless-read-timeout" not in lower_custom_headers: + timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout + if isinstance(timeout, Timeout): + timeout = timeout.read + if timeout is not None: + headers["x-stainless-read-timeout"] = str(timeout) return headers @@ -511,7 +511,7 @@ def _build_request( # so that passing a `TypedDict` doesn't cause an error. # https://github.com/microsoft/pyright/issues/3526#event-6715453066 params=self.qs.stringify(cast(Mapping[str, Any], params)) if params else None, - json=json_data, + json=json_data if is_given(json_data) else None, files=files, **kwargs, ) @@ -787,46 +787,11 @@ def __init__( base_url: str | URL, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - transport: Transport | None = None, - proxies: ProxiesTypes | None = None, - limits: Limits | None = None, http_client: httpx.Client | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, _strict_response_validation: bool, ) -> None: - kwargs: dict[str, Any] = {} - if limits is not None: - warnings.warn( - "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") - else: - limits = DEFAULT_CONNECTION_LIMITS - - if transport is not None: - kwargs["transport"] = transport - warnings.warn( - "The `transport` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `transport`") - - if proxies is not None: - kwargs["proxies"] = proxies - warnings.warn( - "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") - if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -847,12 +812,9 @@ def __init__( super().__init__( version=version, - limits=limits, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, base_url=base_url, - transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, @@ -862,9 +824,6 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - limits=limits, - follow_redirects=True, - **kwargs, # type: ignore ) def is_closed(self) -> bool: @@ -1359,45 +1318,10 @@ def __init__( _strict_response_validation: bool, max_retries: int = DEFAULT_MAX_RETRIES, timeout: float | Timeout | None | NotGiven = NOT_GIVEN, - transport: AsyncTransport | None = None, - proxies: ProxiesTypes | None = None, - limits: Limits | None = None, http_client: httpx.AsyncClient | None = None, custom_headers: Mapping[str, str] | None = None, custom_query: Mapping[str, object] | None = None, ) -> None: - kwargs: dict[str, Any] = {} - if limits is not None: - warnings.warn( - "The `connection_pool_limits` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `connection_pool_limits`") - else: - limits = DEFAULT_CONNECTION_LIMITS - - if transport is not None: - kwargs["transport"] = transport - warnings.warn( - "The `transport` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `transport`") - - if proxies is not None: - kwargs["proxies"] = proxies - warnings.warn( - "The `proxies` argument is deprecated. The `http_client` argument should be passed instead", - category=DeprecationWarning, - stacklevel=3, - ) - if http_client is not None: - raise ValueError("The `http_client` argument is mutually exclusive with `proxies`") - if not is_given(timeout): # if the user passed in a custom http client with a non-default # timeout set then we use that timeout. @@ -1419,11 +1343,8 @@ def __init__( super().__init__( version=version, base_url=base_url, - limits=limits, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - proxies=proxies, - transport=transport, max_retries=max_retries, custom_query=custom_query, custom_headers=custom_headers, @@ -1433,9 +1354,6 @@ def __init__( base_url=base_url, # cast to a valid type because mypy doesn't understand our type narrowing timeout=cast(Timeout, timeout), - limits=limits, - follow_redirects=True, - **kwargs, # type: ignore ) def is_closed(self) -> bool: diff --git a/src/bespokelabs/_client.py b/src/bespokelabs/_client.py index f37a8d2..78c9d43 100644 --- a/src/bespokelabs/_client.py +++ b/src/bespokelabs/_client.py @@ -76,7 +76,7 @@ def __init__( # part of our public interface in the future. _strict_response_validation: bool = False, ) -> None: - """Construct a new synchronous bespoke_labs client instance. + """Construct a new synchronous BespokeLabs client instance. This automatically infers the `auth_token` argument from the `BESPOKE_API_KEY` environment variable if it is not provided. """ @@ -244,7 +244,7 @@ def __init__( # part of our public interface in the future. _strict_response_validation: bool = False, ) -> None: - """Construct a new async bespoke_labs client instance. + """Construct a new async AsyncBespokeLabs client instance. This automatically infers the `auth_token` argument from the `BESPOKE_API_KEY` environment variable if it is not provided. """ diff --git a/src/bespokelabs/_constants.py b/src/bespokelabs/_constants.py index a2ac3b6..6ddf2c7 100644 --- a/src/bespokelabs/_constants.py +++ b/src/bespokelabs/_constants.py @@ -6,7 +6,7 @@ OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to" # default timeout is 1 minute -DEFAULT_TIMEOUT = httpx.Timeout(timeout=60.0, connect=5.0) +DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0) DEFAULT_MAX_RETRIES = 2 DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20) diff --git a/src/bespokelabs/_models.py b/src/bespokelabs/_models.py index d56ea1d..c4401ff 100644 --- a/src/bespokelabs/_models.py +++ b/src/bespokelabs/_models.py @@ -172,21 +172,21 @@ def to_json( @override def __str__(self) -> str: # mypy complains about an invalid self arg - return f'{self.__repr_name__()}({self.__repr_str__(", ")})' # type: ignore[misc] + return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc] # Override the 'construct' method in a way that supports recursive parsing without validation. # Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836. @classmethod @override def construct( # pyright: ignore[reportIncompatibleMethodOverride] - cls: Type[ModelT], + __cls: Type[ModelT], _fields_set: set[str] | None = None, **values: object, ) -> ModelT: - m = cls.__new__(cls) + m = __cls.__new__(__cls) fields_values: dict[str, object] = {} - config = get_model_config(cls) + config = get_model_config(__cls) populate_by_name = ( config.allow_population_by_field_name if isinstance(config, _ConfigProtocol) @@ -196,7 +196,7 @@ def construct( # pyright: ignore[reportIncompatibleMethodOverride] if _fields_set is None: _fields_set = set() - model_fields = get_model_fields(cls) + model_fields = get_model_fields(__cls) for name, field in model_fields.items(): key = field.alias if key is None or (key not in values and populate_by_name): @@ -426,10 +426,16 @@ def construct_type(*, value: object, type_: object) -> object: If the given value does not match the expected type then it is returned as-is. """ + + # store a reference to the original type we were given before we extract any inner + # types so that we can properly resolve forward references in `TypeAliasType` annotations + original_type = None + # we allow `object` as the input type because otherwise, passing things like # `Literal['value']` will be reported as a type error by type checkers type_ = cast("type[object]", type_) if is_type_alias_type(type_): + original_type = type_ # type: ignore[unreachable] type_ = type_.__value__ # type: ignore[unreachable] # unwrap `Annotated[T, ...]` -> `T` @@ -446,7 +452,7 @@ def construct_type(*, value: object, type_: object) -> object: if is_union(origin): try: - return validate_type(type_=cast("type[object]", type_), value=value) + return validate_type(type_=cast("type[object]", original_type or type_), value=value) except Exception: pass diff --git a/src/bespokelabs/_response.py b/src/bespokelabs/_response.py index 3d81773..090dd0a 100644 --- a/src/bespokelabs/_response.py +++ b/src/bespokelabs/_response.py @@ -136,6 +136,8 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if cast_to and is_annotated_type(cast_to): cast_to = extract_type_arg(cast_to, 0) + origin = get_origin(cast_to) or cast_to + if self._is_sse_stream: if to: if not is_stream_class_type(to): @@ -195,8 +197,6 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: if cast_to == bool: return cast(R, response.text.lower() == "true") - origin = get_origin(cast_to) or cast_to - if origin == APIResponse: raise RuntimeError("Unexpected state - cast_to is `APIResponse`") @@ -210,7 +210,13 @@ def _parse(self, *, to: type[_T] | None = None) -> R | _T: raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`") return cast(R, response) - if inspect.isclass(origin) and not issubclass(origin, BaseModel) and issubclass(origin, pydantic.BaseModel): + if ( + inspect.isclass( + origin # pyright: ignore[reportUnknownArgumentType] + ) + and not issubclass(origin, BaseModel) + and issubclass(origin, pydantic.BaseModel) + ): raise TypeError( "Pydantic models must subclass our base model type, e.g. `from bespokelabs import BaseModel`" ) diff --git a/src/bespokelabs/_utils/_sync.py b/src/bespokelabs/_utils/_sync.py index 8b3aaf2..ad7ec71 100644 --- a/src/bespokelabs/_utils/_sync.py +++ b/src/bespokelabs/_utils/_sync.py @@ -7,16 +7,20 @@ from typing import Any, TypeVar, Callable, Awaitable from typing_extensions import ParamSpec +import anyio +import sniffio +import anyio.to_thread + T_Retval = TypeVar("T_Retval") T_ParamSpec = ParamSpec("T_ParamSpec") if sys.version_info >= (3, 9): - to_thread = asyncio.to_thread + _asyncio_to_thread = asyncio.to_thread else: # backport of https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread # for Python 3.8 support - async def to_thread( + async def _asyncio_to_thread( func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs ) -> Any: """Asynchronously run function *func* in a separate thread. @@ -34,6 +38,17 @@ async def to_thread( return await loop.run_in_executor(None, func_call) +async def to_thread( + func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs +) -> T_Retval: + if sniffio.current_async_library() == "asyncio": + return await _asyncio_to_thread(func, *args, **kwargs) + + return await anyio.to_thread.run_sync( + functools.partial(func, *args, **kwargs), + ) + + # inspired by `asyncer`, https://github.com/tiangolo/asyncer def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]: """ diff --git a/src/bespokelabs/_utils/_transform.py b/src/bespokelabs/_utils/_transform.py index a6b62ca..18afd9d 100644 --- a/src/bespokelabs/_utils/_transform.py +++ b/src/bespokelabs/_utils/_transform.py @@ -25,7 +25,7 @@ is_annotated_type, strip_annotated_type, ) -from .._compat import model_dump, is_typeddict +from .._compat import get_origin, model_dump, is_typeddict _T = TypeVar("_T") @@ -164,9 +164,14 @@ def _transform_recursive( inner_type = annotation stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type if is_typeddict(stripped_type) and is_mapping(data): return _transform_typeddict(data, stripped_type) + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + if ( # List[T] (is_list_type(stripped_type) and is_list(data)) @@ -307,9 +312,14 @@ async def _async_transform_recursive( inner_type = annotation stripped_type = strip_annotated_type(inner_type) + origin = get_origin(stripped_type) or stripped_type if is_typeddict(stripped_type) and is_mapping(data): return await _async_transform_typeddict(data, stripped_type) + if origin == dict and is_mapping(data): + items_type = get_args(stripped_type)[1] + return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()} + if ( # List[T] (is_list_type(stripped_type) and is_list(data)) diff --git a/src/bespokelabs/_version.py b/src/bespokelabs/_version.py index 33d819e..55b646b 100644 --- a/src/bespokelabs/_version.py +++ b/src/bespokelabs/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "bespokelabs" -__version__ = "0.2.3" # x-release-please-version +__version__ = "0.3.0" # x-release-please-version diff --git a/src/bespokelabs/resources/minicheck/factcheck.py b/src/bespokelabs/resources/minicheck/factcheck.py index 31354fc..42081e1 100644 --- a/src/bespokelabs/resources/minicheck/factcheck.py +++ b/src/bespokelabs/resources/minicheck/factcheck.py @@ -28,7 +28,7 @@ class FactcheckResource(SyncAPIResource): @cached_property def with_raw_response(self) -> FactcheckResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/bespokelabsai/bespokelabs-python#accessing-raw-response-data-eg-headers @@ -92,7 +92,7 @@ class AsyncFactcheckResource(AsyncAPIResource): @cached_property def with_raw_response(self) -> AsyncFactcheckResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/bespokelabsai/bespokelabs-python#accessing-raw-response-data-eg-headers diff --git a/src/bespokelabs/resources/minicheck/minicheck.py b/src/bespokelabs/resources/minicheck/minicheck.py index 5a751fb..eb5cc20 100644 --- a/src/bespokelabs/resources/minicheck/minicheck.py +++ b/src/bespokelabs/resources/minicheck/minicheck.py @@ -24,7 +24,7 @@ def factcheck(self) -> FactcheckResource: @cached_property def with_raw_response(self) -> MinicheckResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/bespokelabsai/bespokelabs-python#accessing-raw-response-data-eg-headers @@ -49,7 +49,7 @@ def factcheck(self) -> AsyncFactcheckResource: @cached_property def with_raw_response(self) -> AsyncMinicheckResourceWithRawResponse: """ - This property can be used as a prefix for any HTTP method call to return the + This property can be used as a prefix for any HTTP method call to return the raw response object instead of the parsed content. For more information, see https://www.github.com/bespokelabsai/bespokelabs-python#accessing-raw-response-data-eg-headers diff --git a/tests/test_client.py b/tests/test_client.py index 5930542..4314355 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -6,6 +6,7 @@ import os import sys import json +import time import asyncio import inspect import subprocess @@ -22,6 +23,7 @@ from bespokelabs import BespokeLabs, AsyncBespokeLabs, APIResponseValidationError from bespokelabs._types import Omit +from bespokelabs._utils import maybe_transform from bespokelabs._models import BaseModel, FinalRequestOptions from bespokelabs._constants import RAW_RESPONSE_HEADER from bespokelabs._exceptions import APIStatusError, APITimeoutError, BespokeLabsError, APIResponseValidationError @@ -31,6 +33,7 @@ BaseClient, make_request_options, ) +from bespokelabs.types.minicheck.factcheck_create_params import FactcheckCreateParams from .utils import update_env @@ -729,7 +732,7 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> No with pytest.raises(APITimeoutError): self.client.post( "/v0/minicheck/factcheck", - body=cast(object, dict(claim="claim", context="context")), + body=cast(object, maybe_transform(dict(claim="claim", context="context"), FactcheckCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -744,7 +747,7 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> Non with pytest.raises(APIStatusError): self.client.post( "/v0/minicheck/factcheck", - body=cast(object, dict(claim="claim", context="context")), + body=cast(object, maybe_transform(dict(claim="claim", context="context"), FactcheckCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1512,7 +1515,7 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) with pytest.raises(APITimeoutError): await self.client.post( "/v0/minicheck/factcheck", - body=cast(object, dict(claim="claim", context="context")), + body=cast(object, maybe_transform(dict(claim="claim", context="context"), FactcheckCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1527,7 +1530,7 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) with pytest.raises(APIStatusError): await self.client.post( "/v0/minicheck/factcheck", - body=cast(object, dict(claim="claim", context="context")), + body=cast(object, maybe_transform(dict(claim="claim", context="context"), FactcheckCreateParams)), cast_to=httpx.Response, options={"headers": {RAW_RESPONSE_HEADER: "stream"}}, ) @@ -1645,10 +1648,20 @@ async def test_main() -> None: [sys.executable, "-c", test_code], text=True, ) as process: - try: - process.wait(2) - if process.returncode: - raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") - except subprocess.TimeoutExpired as e: - process.kill() - raise AssertionError("calling get_platform using asyncify resulted in a hung process") from e + timeout = 10 # seconds + + start_time = time.monotonic() + while True: + return_code = process.poll() + if return_code is not None: + if return_code != 0: + raise AssertionError("calling get_platform using asyncify resulted in a non-zero exit code") + + # success + break + + if time.monotonic() - start_time > timeout: + process.kill() + raise AssertionError("calling get_platform using asyncify resulted in a hung process") + + time.sleep(0.1) diff --git a/tests/test_models.py b/tests/test_models.py index 78c86d5..0829168 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -844,3 +844,13 @@ class Model(BaseModel): assert m.alias == "foo" assert isinstance(m.union, str) assert m.union == "bar" + + +@pytest.mark.skipif(not PYDANTIC_V2, reason="TypeAliasType is not supported in Pydantic v1") +def test_field_named_cls() -> None: + class Model(BaseModel): + cls: str + + m = construct_type(value={"cls": "foo"}, type_=Model) + assert isinstance(m, Model) + assert isinstance(m.cls, str) diff --git a/tests/test_transform.py b/tests/test_transform.py index 661b0ea..93cd573 100644 --- a/tests/test_transform.py +++ b/tests/test_transform.py @@ -2,7 +2,7 @@ import io import pathlib -from typing import Any, List, Union, TypeVar, Iterable, Optional, cast +from typing import Any, Dict, List, Union, TypeVar, Iterable, Optional, cast from datetime import date, datetime from typing_extensions import Required, Annotated, TypedDict @@ -388,6 +388,15 @@ def my_iter() -> Iterable[Baz8]: } +@parametrize +@pytest.mark.asyncio +async def test_dictionary_items(use_async: bool) -> None: + class DictItems(TypedDict): + foo_baz: Annotated[str, PropertyInfo(alias="fooBaz")] + + assert await transform({"foo": {"foo_baz": "bar"}}, Dict[str, DictItems], use_async) == {"foo": {"fooBaz": "bar"}} + + class TypedDictIterableUnionStr(TypedDict): foo: Annotated[Union[str, Iterable[Baz8]], PropertyInfo(alias="FOO")]