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/.release-please-manifest.json b/.release-please-manifest.json index 4ad3fef..e756293 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.18.0" + ".": "0.19.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 516dbe0..3913b8d 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,2 @@ configured_endpoints: 15 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/groqcloud%2Fgroqcloud-d1588e103a6ae0234752b8e54a746fb1e4c93a0ee51ede294017bcd4f0ee4ac0.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/groqcloud%2Fgroqcloud-21e2668d8b211239f9b1019b09a89fcbc00855284b2434a52d80abf32de2e8f7.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3988a..3372ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.19.0 (2025-02-26) + +Full Changelog: [v0.18.0...v0.19.0](https://github.com/groq/groq-python/compare/v0.18.0...v0.19.0) + +### Features + +* **client:** allow passing `NotGiven` for body ([#200](https://github.com/groq/groq-python/issues/200)) ([be7cf42](https://github.com/groq/groq-python/commit/be7cf423148825a9b162199780d2fb98df078152)) +* **client:** send `X-Stainless-Read-Timeout` header ([#193](https://github.com/groq/groq-python/issues/193)) ([2eef75e](https://github.com/groq/groq-python/commit/2eef75e7a509eb1f7ea8b5b8bcc05d788a450e00)) + + +### Bug Fixes + +* asyncify on non-asyncio runtimes ([#198](https://github.com/groq/groq-python/issues/198)) ([e5b8db6](https://github.com/groq/groq-python/commit/e5b8db60835f8cec56c0677bdc15db7eae98e419)) +* **client:** mark some request bodies as optional ([be7cf42](https://github.com/groq/groq-python/commit/be7cf423148825a9b162199780d2fb98df078152)) + + +### Chores + +* **internal:** codegen related update ([#199](https://github.com/groq/groq-python/issues/199)) ([db4292e](https://github.com/groq/groq-python/commit/db4292e1e5170afa6cea1526a857d27af586c112)) +* **internal:** fix devcontainers setup ([#201](https://github.com/groq/groq-python/issues/201)) ([a0dd0a1](https://github.com/groq/groq-python/commit/a0dd0a17488ba13cd68a647c2e0abb3a4a86a07e)) +* **internal:** fix type traversing dictionary params ([#195](https://github.com/groq/groq-python/issues/195)) ([3a25838](https://github.com/groq/groq-python/commit/3a25838c0c1aae4013af4c4a629088371b845bf0)) +* **internal:** minor type handling changes ([#196](https://github.com/groq/groq-python/issues/196)) ([fc879c3](https://github.com/groq/groq-python/commit/fc879c3dff72bb4e1388fdd67f2e70f5d02041c8)) +* **internal:** properly set __pydantic_private__ ([#202](https://github.com/groq/groq-python/issues/202)) ([02ea2b1](https://github.com/groq/groq-python/commit/02ea2b1990f74c6efe8b562091b00a4da6d7c671)) +* **internal:** update client tests ([#197](https://github.com/groq/groq-python/issues/197)) ([af2be17](https://github.com/groq/groq-python/commit/af2be17b247fe4922306f7d0706d24befa08c7ef)) + ## 0.18.0 (2025-02-05) Full Changelog: [v0.17.0...v0.18.0](https://github.com/groq/groq-python/compare/v0.17.0...v0.18.0) diff --git a/README.md b/README.md index fa683cb..f5039b6 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,24 @@ 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`. +## File uploads + +Request parameters that correspond to file uploads can be passed as `bytes`, a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance or a tuple of `(filename, contents, media type)`. + +```python +from pathlib import Path +from groq import Groq + +client = Groq() + +client.audio.transcriptions.create( + file=Path("/path/to/file"), + model="whisper-large-v3", +) +``` + +The async client uses the exact same interface. If you pass a [`PathLike`](https://docs.python.org/3/library/os.html#os.PathLike) instance, the file contents will be read asynchronously automatically. + ## 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 `groq.APIConnectionError` is raised. diff --git a/pyproject.toml b/pyproject.toml index d2f3586..f5613cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "groq" -version = "0.18.0" +version = "0.19.0" description = "The official Python library for the groq API" dynamic = ["readme"] license = "Apache-2.0" diff --git a/src/groq/_base_client.py b/src/groq/_base_client.py index c3f3609..53197f6 100644 --- a/src/groq/_base_client.py +++ b/src/groq/_base_client.py @@ -63,7 +63,7 @@ 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 +207,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 +295,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 @@ -418,10 +424,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 +524,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, ) diff --git a/src/groq/_files.py b/src/groq/_files.py index 715cc20..68da809 100644 --- a/src/groq/_files.py +++ b/src/groq/_files.py @@ -34,7 +34,7 @@ def assert_is_file_content(obj: object, *, key: str | None = None) -> None: if not is_file_content(obj): prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`" raise RuntimeError( - f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead." + f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead. See https://github.com/groq/groq-python/tree/main#file-uploads" ) from None diff --git a/src/groq/_models.py b/src/groq/_models.py index 12c34b7..c4401ff 100644 --- a/src/groq/_models.py +++ b/src/groq/_models.py @@ -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/groq/_utils/_sync.py b/src/groq/_utils/_sync.py index 8b3aaf2..ad7ec71 100644 --- a/src/groq/_utils/_sync.py +++ b/src/groq/_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/groq/_utils/_transform.py b/src/groq/_utils/_transform.py index a6b62ca..18afd9d 100644 --- a/src/groq/_utils/_transform.py +++ b/src/groq/_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/groq/_version.py b/src/groq/_version.py index dbdf658..f4939f2 100644 --- a/src/groq/_version.py +++ b/src/groq/_version.py @@ -1,4 +1,4 @@ # File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. __title__ = "groq" -__version__ = "0.18.0" # x-release-please-version +__version__ = "0.19.0" # x-release-please-version diff --git a/tests/test_client.py b/tests/test_client.py index 1615f63..1dc985a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,10 +23,12 @@ from groq import Groq, AsyncGroq, APIResponseValidationError from groq._types import Omit +from groq._utils import maybe_transform from groq._models import BaseModel, FinalRequestOptions from groq._constants import RAW_RESPONSE_HEADER from groq._exceptions import GroqError, APIStatusError, APITimeoutError, APIResponseValidationError from groq._base_client import DEFAULT_TIMEOUT, HTTPX_DEFAULT_TIMEOUT, BaseClient, make_request_options +from groq.types.chat.completion_create_params import CompletionCreateParams from .utils import update_env @@ -706,18 +708,21 @@ def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) -> No "/openai/v1/chat/completions", body=cast( object, - dict( - messages=[ - { - "role": "system", - "content": "You are a helpful assistant.", - }, - { - "role": "user", - "content": "Explain the importance of low latency LLMs", - }, - ], - model="llama3-8b-8192", + maybe_transform( + dict( + messages=[ + { + "role": "system", + "content": "You are a helpful assistant.", + }, + { + "role": "user", + "content": "Explain the importance of low latency LLMs", + }, + ], + model="llama3-8b-8192", + ), + CompletionCreateParams, ), ), cast_to=httpx.Response, @@ -736,18 +741,21 @@ def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) -> Non "/openai/v1/chat/completions", body=cast( object, - dict( - messages=[ - { - "role": "system", - "content": "You are a helpful assistant.", - }, - { - "role": "user", - "content": "Explain the importance of low latency LLMs", - }, - ], - model="llama3-8b-8192", + maybe_transform( + dict( + messages=[ + { + "role": "system", + "content": "You are a helpful assistant.", + }, + { + "role": "user", + "content": "Explain the importance of low latency LLMs", + }, + ], + model="llama3-8b-8192", + ), + CompletionCreateParams, ), ), cast_to=httpx.Response, @@ -1526,18 +1534,21 @@ async def test_retrying_timeout_errors_doesnt_leak(self, respx_mock: MockRouter) "/openai/v1/chat/completions", body=cast( object, - dict( - messages=[ - { - "role": "system", - "content": "You are a helpful assistant.", - }, - { - "role": "user", - "content": "Explain the importance of low latency LLMs", - }, - ], - model="llama3-8b-8192", + maybe_transform( + dict( + messages=[ + { + "role": "system", + "content": "You are a helpful assistant.", + }, + { + "role": "user", + "content": "Explain the importance of low latency LLMs", + }, + ], + model="llama3-8b-8192", + ), + CompletionCreateParams, ), ), cast_to=httpx.Response, @@ -1556,18 +1567,21 @@ async def test_retrying_status_errors_doesnt_leak(self, respx_mock: MockRouter) "/openai/v1/chat/completions", body=cast( object, - dict( - messages=[ - { - "role": "system", - "content": "You are a helpful assistant.", - }, - { - "role": "user", - "content": "Explain the importance of low latency LLMs", - }, - ], - model="llama3-8b-8192", + maybe_transform( + dict( + messages=[ + { + "role": "system", + "content": "You are a helpful assistant.", + }, + { + "role": "user", + "content": "Explain the importance of low latency LLMs", + }, + ], + model="llama3-8b-8192", + ), + CompletionCreateParams, ), ), cast_to=httpx.Response, diff --git a/tests/test_transform.py b/tests/test_transform.py index e29e15c..af72c40 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")]