From fadf2ae8beb061e5a863a7fbcb4a438841e30350 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 12:22:16 +0300 Subject: [PATCH 01/64] initial release, with chat completions, files, and limited apps API support --- .flake8 | 1 + Makefile | 33 +- aidial_client/__init__.py | 26 + aidial_client/_auth.py | 60 ++ aidial_client/_client.py | 248 +++++ aidial_client/_client_pool.py | 86 ++ aidial_client/_compatibility/openai.py | 10 + aidial_client/_compatibility/pydantic_v1.py | 4 + aidial_client/_constants.py | 18 + aidial_client/_exception.py | 89 ++ aidial_client/_http_client/__init__.py | 5 + aidial_client/_http_client/_async.py | 110 +++ aidial_client/_http_client/_base.py | 139 +++ aidial_client/_http_client/_sync.py | 110 +++ aidial_client/_internal_types/__init__.py | 0 aidial_client/_internal_types/_defaults.py | 15 + aidial_client/_internal_types/_generic.py | 23 + .../_internal_types/_http_request.py | 61 ++ aidial_client/_internal_types/_model.py | 19 + aidial_client/_internal_types/_stream.py | 52 + aidial_client/_log.py | 4 + aidial_client/_utils/__init__.py | 0 aidial_client/_utils/_alias.py | 40 + aidial_client/_utils/_dict.py | 5 + aidial_client/_utils/_openai.py | 79 ++ aidial_client/_utils/_response_processing.py | 35 + aidial_client/_utils/_type_guard.py | 7 + aidial_client/helpers/__init__.py | 0 aidial_client/helpers/storage_resource.py | 154 +++ aidial_client/helpers/url.py | 20 + aidial_client/resources/__init__.py | 22 + aidial_client/resources/application.py | 47 + aidial_client/resources/base.py | 16 + aidial_client/resources/bucket.py | 47 + aidial_client/resources/chat/__init__.py | 13 + aidial_client/resources/chat/completions.py | 398 ++++++++ aidial_client/resources/deployments.py | 27 + aidial_client/resources/files.py | 152 +++ aidial_client/resources/metadata.py | 89 ++ aidial_client/types/__init__.py | 0 aidial_client/types/application.py | 39 + aidial_client/types/bucket.py | 24 + aidial_client/types/chat/__init__.py | 30 + aidial_client/types/chat/addon.py | 17 + aidial_client/types/chat/function.py | 18 + aidial_client/types/chat/legacy/__init__.py | 3 + .../types/chat/legacy/application_request.py | 64 ++ .../types/chat/legacy/chat_completion.py | 168 ++++ aidial_client/types/chat/request.py | 44 + aidial_client/types/chat/request_param.py | 69 ++ aidial_client/types/chat/response.py | 91 ++ aidial_client/types/chat/tool.py | 25 + aidial_client/types/deployment.py | 23 + aidial_client/types/file.py | 45 + aidial_client/types/metadata.py | 56 ++ noxfile.py | 38 +- poetry.lock | 932 ++++++++++++++++++ pyproject.toml | 92 ++ tests/client_mock.py | 96 ++ tests/helpers/test_storage_resource_mixin.py | 176 ++++ tests/helpers/test_storage_resource_parser.py | 111 +++ tests/helpers/test_url_helpers.py | 33 + tests/integration/__init__.py | 0 tests/integration/fixtures.py | 38 + tests/integration/test_async_completions.py | 128 +++ tests/integration/test_async_files.py | 63 ++ tests/integration/test_sync_completions.py | 99 ++ tests/integration/test_sync_files.py | 58 ++ tests/resources/completions/__init__.py | 0 .../completions/test_completions_streaming.py | 69 ++ tests/resources/files/test_metadata.py | 68 ++ tests/resources/files/test_upload.py | 72 ++ tests/resources/test_bucket.py | 59 ++ tests/test_auth.py | 89 ++ tests/test_client_pool.py | 48 + tests/test_client_retry.py | 125 +++ 76 files changed, 5355 insertions(+), 19 deletions(-) create mode 100644 aidial_client/__init__.py create mode 100644 aidial_client/_auth.py create mode 100644 aidial_client/_client.py create mode 100644 aidial_client/_client_pool.py create mode 100644 aidial_client/_compatibility/openai.py create mode 100644 aidial_client/_compatibility/pydantic_v1.py create mode 100644 aidial_client/_constants.py create mode 100644 aidial_client/_exception.py create mode 100644 aidial_client/_http_client/__init__.py create mode 100644 aidial_client/_http_client/_async.py create mode 100644 aidial_client/_http_client/_base.py create mode 100644 aidial_client/_http_client/_sync.py create mode 100644 aidial_client/_internal_types/__init__.py create mode 100644 aidial_client/_internal_types/_defaults.py create mode 100644 aidial_client/_internal_types/_generic.py create mode 100644 aidial_client/_internal_types/_http_request.py create mode 100644 aidial_client/_internal_types/_model.py create mode 100644 aidial_client/_internal_types/_stream.py create mode 100644 aidial_client/_log.py create mode 100644 aidial_client/_utils/__init__.py create mode 100644 aidial_client/_utils/_alias.py create mode 100644 aidial_client/_utils/_dict.py create mode 100644 aidial_client/_utils/_openai.py create mode 100644 aidial_client/_utils/_response_processing.py create mode 100644 aidial_client/_utils/_type_guard.py create mode 100644 aidial_client/helpers/__init__.py create mode 100644 aidial_client/helpers/storage_resource.py create mode 100644 aidial_client/helpers/url.py create mode 100644 aidial_client/resources/__init__.py create mode 100644 aidial_client/resources/application.py create mode 100644 aidial_client/resources/base.py create mode 100644 aidial_client/resources/bucket.py create mode 100644 aidial_client/resources/chat/__init__.py create mode 100644 aidial_client/resources/chat/completions.py create mode 100644 aidial_client/resources/deployments.py create mode 100644 aidial_client/resources/files.py create mode 100644 aidial_client/resources/metadata.py create mode 100644 aidial_client/types/__init__.py create mode 100644 aidial_client/types/application.py create mode 100644 aidial_client/types/bucket.py create mode 100644 aidial_client/types/chat/__init__.py create mode 100644 aidial_client/types/chat/addon.py create mode 100644 aidial_client/types/chat/function.py create mode 100644 aidial_client/types/chat/legacy/__init__.py create mode 100644 aidial_client/types/chat/legacy/application_request.py create mode 100644 aidial_client/types/chat/legacy/chat_completion.py create mode 100644 aidial_client/types/chat/request.py create mode 100644 aidial_client/types/chat/request_param.py create mode 100644 aidial_client/types/chat/response.py create mode 100644 aidial_client/types/chat/tool.py create mode 100644 aidial_client/types/deployment.py create mode 100644 aidial_client/types/file.py create mode 100644 aidial_client/types/metadata.py create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 tests/client_mock.py create mode 100644 tests/helpers/test_storage_resource_mixin.py create mode 100644 tests/helpers/test_storage_resource_parser.py create mode 100644 tests/helpers/test_url_helpers.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/fixtures.py create mode 100644 tests/integration/test_async_completions.py create mode 100644 tests/integration/test_async_files.py create mode 100644 tests/integration/test_sync_completions.py create mode 100644 tests/integration/test_sync_files.py create mode 100644 tests/resources/completions/__init__.py create mode 100644 tests/resources/completions/test_completions_streaming.py create mode 100644 tests/resources/files/test_metadata.py create mode 100644 tests/resources/files/test_upload.py create mode 100644 tests/resources/test_bucket.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_client_pool.py create mode 100644 tests/test_client_retry.py diff --git a/.flake8 b/.flake8 index b43b56e..0193efb 100644 --- a/.flake8 +++ b/.flake8 @@ -4,6 +4,7 @@ # E203 whitespace before ':' (triggered on list slices like xs[i : i + 5]) # E704 multiple statements on one line (def) ignore = E501, W503, E203, E704 +max-line-length = 80 exclude = .git, .tmp, diff --git a/Makefile b/Makefile index e2d65ca..92a955b 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,13 @@ +.PHONY: all install clean lint format test spell_check + all: build install: - poetry install --all-extras - -build: install - poetry build + poetry install clean: - rm -rf $$(poetry env info --path) - rm -rf .nox - rm -rf .pytest_cache - rm -rf dist - find . -type d -name __pycache__ | xargs rm -r - -publish: build - poetry publish -u __token__ -p ${PYPI_TOKEN} --skip-existing + poetry run clean + poetry env remove --all lint: install poetry run nox -s lint @@ -25,14 +18,20 @@ format: install test: install poetry run nox -s test $(if $(PYTHON),--python=$(PYTHON),) +integration_test: install + poetry run nox -s integration_test $(if $(PYTHON),--python=$(PYTHON),) + +coverage: install + poetry run nox -s coverage + +publish: build + poetry publish -u __token__ -p ${PYPI_TOKEN} --skip-existing + help: @echo '====================' - @echo 'build - build the library' + @echo 'install - install virtual env and dependencies' @echo 'clean - clean virtual env and build artifacts' - @echo 'publish - publish the library to Pypi' @echo '-- LINTING --' @echo 'format - run code formatters' @echo 'lint - run linters' - @echo '-- TESTS --' - @echo 'test - run unit tests' - @echo 'test PYTHON= - run unit tests with the specific python version' + @echo 'spell_check - run spell check' diff --git a/aidial_client/__init__.py b/aidial_client/__init__.py new file mode 100644 index 0000000..bac7e7b --- /dev/null +++ b/aidial_client/__init__.py @@ -0,0 +1,26 @@ +from aidial_client._auth import AsyncAuthValue, AuthType, SyncAuthValue +from aidial_client._client import AsyncDial, Dial +from aidial_client._client_pool import AsyncDialClientPool, DialClientPool +from aidial_client._exception import ( + DialException, + InvalidBucketException, + InvalidDialURLException, + InvalidRequestException, + ParsingDataException, +) + +__all__ = [ + "Dial", + "AsyncDial", + "DialClientPool", + "AsyncDialClientPool", + "AuthType", + "SyncAuthValue", + "AsyncAuthValue", + # Exceptions + "DialException", + "InvalidDialURLException", + "InvalidBucketException", + "InvalidRequestException", + "ParsingDataException", +] diff --git a/aidial_client/_auth.py b/aidial_client/_auth.py new file mode 100644 index 0000000..3a704cd --- /dev/null +++ b/aidial_client/_auth.py @@ -0,0 +1,60 @@ +from enum import Enum +from inspect import isawaitable +from typing import Awaitable, Callable, Dict, TypeVar, Union + + +class AuthType(Enum): + API_KEY = "API_KEY" + BEARER = "BEARER" + + +SyncAuthValue = Union[str, Callable[[], str]] +AsyncAuthValue = Union[SyncAuthValue, Callable[[], Awaitable[str]]] + +AuthValueT = TypeVar( + "AuthValueT", + bound=Union[SyncAuthValue, AsyncAuthValue], +) + + +def get_auth_headers( + *, + auth_value: SyncAuthValue, + auth_type: AuthType, +) -> Dict[str, str]: + if auth_type == AuthType.API_KEY: + if isinstance(auth_value, str): + return {"api-key": auth_value} + elif callable(auth_value): + return {"api-key": auth_value()} + elif auth_type == AuthType.BEARER: + if isinstance(auth_value, str): + return {"Authorization": f"Bearer {auth_value}"} + elif callable(auth_value): + return {"Authorization": f"Bearer {auth_value()}"} + else: + raise NotImplementedError("Unsupported auth") + + +async def get_async_auth_headers( + auth_value: AsyncAuthValue, + auth_type: AuthType, +) -> Dict[str, str]: + if auth_type == AuthType.API_KEY: + if isinstance(auth_value, str): + return {"api-key": auth_value} + elif callable(auth_value): + result = auth_value() + if isawaitable(result): + return {"api-key": await result} + return {"api-key": result} + elif auth_type == AuthType.BEARER: + if isinstance(auth_value, str): + return {"Authorization": f"Bearer {auth_value}"} + elif callable(auth_value): + result = auth_value() + if isawaitable(result): + return {"Authorization": f"Bearer {await result}"} + return {"Authorization": f"Bearer {result}"} + else: + raise NotImplementedError("Unsupported auth") diff --git a/aidial_client/_client.py b/aidial_client/_client.py new file mode 100644 index 0000000..32ed76d --- /dev/null +++ b/aidial_client/_client.py @@ -0,0 +1,248 @@ +from abc import ABC, abstractmethod +from pathlib import PurePosixPath +from typing import Dict, Generic, Optional, Tuple, TypeVar, Union, cast +from urllib.parse import urljoin + +import openai +from httpx import Timeout + +import aidial_client.resources as resources +from aidial_client._auth import ( + AsyncAuthValue, + AuthType, + AuthValueT, + SyncAuthValue, +) +from aidial_client._constants import ( + API_PREFIX, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, + OPENAI_PREFIX, +) +from aidial_client._http_client import AsyncHTTPClient, SyncHTTPClient +from aidial_client.helpers.url import enforce_trailing_slash +from aidial_client.types.bucket import AppData + +_HttpClientT = TypeVar( + "_HttpClientT", bound=Union[AsyncHTTPClient, SyncHTTPClient] +) + + +class BaseDialClient(Generic[_HttpClientT, AuthValueT], ABC): + _auth_type: AuthType + _auth_value: AuthValueT + _base_url: str + _http_client: _HttpClientT + _auth_headers: Dict[str, str] + + @staticmethod + def process_auth( + api_key: Optional[AuthValueT] = None, + bearer_token: Optional[AuthValueT] = None, + ) -> Tuple[AuthType, AuthValueT]: + if api_key and bearer_token: + raise ValueError( + "Either api_key or bearer_token must be provided, but not both" + ) + elif api_key: + return AuthType.API_KEY, api_key + elif bearer_token: + return AuthType.BEARER, bearer_token + else: + raise ValueError("Either api_key or bearer_token must be provided") + + def __init__( + self, + *, + api_key: Optional[AuthValueT] = None, + bearer_token: Optional[AuthValueT] = None, + base_url: str, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, + api_version: Optional[str] = None, + _http_client: Optional[_HttpClientT] = None, + ): + self._auth_type, self._auth_value = self.process_auth( + api_key=api_key, bearer_token=bearer_token + ) + self._max_retries = max_retries + self._timeout = timeout + self._base_url = enforce_trailing_slash(base_url) + self._api_version = api_version + self._http_client = _http_client or self._create_http_client() + self._init_resources() + + @abstractmethod + def _init_resources(self) -> None: ... + + @abstractmethod + def _create_http_client(self) -> _HttpClientT: ... + + def is_dial_url(self, absolute_url: str) -> bool: + return enforce_trailing_slash(absolute_url).startswith(self._base_url) + + @property + def api_url(self) -> str: + return urljoin(self._base_url, API_PREFIX) + + @property + def base_url(self) -> str: + return self._base_url + + @property + def api_version(self) -> Optional[str]: + return self._api_version + + +class Dial(BaseDialClient[SyncHTTPClient, SyncAuthValue]): + def _init_resources(self) -> None: + openai_client = openai.AzureOpenAI( + api_key="-", + api_version="-", + base_url=urljoin(self._base_url, OPENAI_PREFIX), + http_client=self._http_client.internal_http_client, + ) + self.chat = resources.Chat( + http_client=self._http_client, + completions=resources.chat.ChatCompletions( + http_client=self._http_client, + default_api_version=self.api_version, + openai_client=openai_client, + ), + ) + self.bucket = resources.Bucket(http_client=self._http_client) + self.metadata = resources.Metadata(http_client=self._http_client) + self.files = resources.Files( + http_client=self._http_client, + metadata=self.metadata, + dial_api_url=self.api_url, + ) + self.deployments = resources.Deployments(http_client=self._http_client) + self.application = resources.Application(http_client=self._http_client) + + def _create_http_client(self) -> SyncHTTPClient: + return SyncHTTPClient( + self._base_url, + self._auth_value, + self._auth_type, + self._max_retries, + self._timeout, + ) + + def _get_my_bucket(self) -> str: + """Wrapper for convenience of unit tests""" + return self.bucket.get_bucket() + + def my_bucket(self) -> str: + if not hasattr(self, "_my_bucket") or not getattr(self, "_my_bucket"): + self._my_bucket = self._get_my_bucket() + return cast(str, self._my_bucket) + + def my_files_home(self) -> PurePosixPath: + return "files" / PurePosixPath(self.my_bucket()) + + def my_conversations_home(self) -> PurePosixPath: + return "conversations" / PurePosixPath(self.my_bucket()) + + def my_prompts_home(self) -> PurePosixPath: + return "prompts" / PurePosixPath(self.my_bucket()) + + def _get_my_appdata(self) -> Optional[AppData]: + return self.bucket.get_appdata() + + def my_appdata(self) -> Optional[AppData]: + if not hasattr(self, "_my_appdata"): + self._my_appdata = self._get_my_appdata() + return self._my_appdata + + def my_appdata_home(self) -> Optional[str]: + appdata = self.my_appdata() + if appdata: + return appdata.raw + return None + + def auth_headers(self) -> Dict[str, str]: + return self._http_client.auth_headers() + + +class AsyncDial(BaseDialClient[AsyncHTTPClient, AsyncAuthValue]): + _my_bucket: Optional[str] + + def _init_resources(self) -> None: + openai_client = openai.AsyncAzureOpenAI( + # set empty string, we will override + # it with our client values during request + api_key="", + api_version="", + base_url=urljoin(self._base_url, OPENAI_PREFIX), + http_client=self._http_client.internal_http_client, + timeout=self._http_client._timeout, + max_retries=self._http_client._max_retries, + ) + self.chat = resources.AsyncChat( + http_client=self._http_client, + completions=resources.chat.AsyncChatCompletions( + http_client=self._http_client, + default_api_version=self.api_version, + openai_client=openai_client, + ), + ) + self.bucket = resources.AsyncBucket(http_client=self._http_client) + self.metadata = resources.AsyncMetadata(http_client=self._http_client) + self.files = resources.AsyncFiles( + http_client=self._http_client, + metadata=self.metadata, + dial_api_url=self.api_url, + ) + self.deployments = resources.AsyncDeployments( + http_client=self._http_client + ) + self.application = resources.AsyncApplication( + http_client=self._http_client + ) + + def _create_http_client(self) -> AsyncHTTPClient: + return AsyncHTTPClient( + self._base_url, + self._auth_value, + self._auth_type, + self._max_retries, + self._timeout, + ) + + async def _get_my_bucket(self) -> str: + """Wrapper for convenience of unit tests""" + return await self.bucket.get_bucket() + + async def my_bucket(self) -> str: + if not hasattr(self, "_my_bucket") or not getattr(self, "_my_bucket"): + self._my_bucket = await self._get_my_bucket() + return self._my_bucket + else: + return cast(str, self._my_bucket) + + async def my_files_home(self) -> PurePosixPath: + return "files" / PurePosixPath(await self.my_bucket()) + + async def my_conversations_home(self) -> PurePosixPath: + return "conversations" / PurePosixPath(await self.my_bucket()) + + async def my_prompts_home(self) -> PurePosixPath: + return "prompts" / PurePosixPath(await self.my_bucket()) + + async def _get_my_appdata(self) -> Optional[AppData]: + return await self.bucket.get_appdata() + + async def my_appdata(self) -> Optional[AppData]: + if not hasattr(self, "_my_appdata"): + self._my_appdata = await self._get_my_appdata() + return self._my_appdata + + async def my_appdata_home(self) -> Optional[str]: + appdata = await self.my_appdata() + if appdata: + return appdata.raw + return None + + async def auth_headers(self) -> Dict[str, str]: + return await self._http_client.auth_headers() diff --git a/aidial_client/_client_pool.py b/aidial_client/_client_pool.py new file mode 100644 index 0000000..44d539c --- /dev/null +++ b/aidial_client/_client_pool.py @@ -0,0 +1,86 @@ +from typing import Optional, Union + +import httpx + +from aidial_client._auth import AsyncAuthValue, SyncAuthValue +from aidial_client._client import AsyncDial, Dial +from aidial_client._constants import ( + DEFAULT_CONNECTION_LIMITS, + DEFAULT_MAX_RETRIES, + DEFAULT_TIMEOUT, +) +from aidial_client._http_client import AsyncHTTPClient, SyncHTTPClient + + +class DialClientPool: + def __init__( + self, + *, + connection_limits: httpx.Limits = DEFAULT_CONNECTION_LIMITS, + **kwargs, + ): + self._internal_http_client = httpx.Client( + limits=connection_limits, **kwargs + ) + + def create_client( + self, + *, + base_url: str, + api_key: Optional[SyncAuthValue] = None, + bearer_token: Optional[SyncAuthValue] = None, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: Union[httpx.Timeout, float] = DEFAULT_TIMEOUT, + ) -> Dial: + auth_type, auth_value = Dial.process_auth(api_key, bearer_token) + return Dial( + base_url=base_url, + api_key=api_key, + bearer_token=bearer_token, + _http_client=SyncHTTPClient( + base_url=base_url, + auth_value=auth_value, + auth_type=auth_type, + max_retries=max_retries, + timeout=timeout, + _client=self._internal_http_client, + ), + ) + + +class AsyncDialClientPool: + def __init__( + self, + *, + connection_limits: httpx.Limits = DEFAULT_CONNECTION_LIMITS, + **kwargs, + ): + self._internal_http_client = httpx.AsyncClient( + limits=connection_limits, **kwargs + ) + + def create_client( + self, + *, + base_url: str, + api_key: Optional[AsyncAuthValue] = None, + bearer_token: Optional[AsyncAuthValue] = None, + max_retries: int = DEFAULT_MAX_RETRIES, + timeout: Union[httpx.Timeout, float] = DEFAULT_TIMEOUT, + ) -> AsyncDial: + auth_type, auth_value = AsyncDial.process_auth( + api_key=api_key, bearer_token=bearer_token + ) + return AsyncDial( + base_url=base_url, + api_key=api_key, + bearer_token=bearer_token, + _http_client=AsyncHTTPClient( + base_url=base_url, + auth_value=auth_value, + auth_type=auth_type, + max_retries=max_retries, + timeout=timeout, + _client=self._internal_http_client, + ), + ) diff --git a/aidial_client/_compatibility/openai.py b/aidial_client/_compatibility/openai.py new file mode 100644 index 0000000..440c909 --- /dev/null +++ b/aidial_client/_compatibility/openai.py @@ -0,0 +1,10 @@ +""" +Since we need some protected imports from openai, wrap it with this module, +for easier handling of cases, when such member will migrate to another modules +""" + +from openai._compat import PYDANTIC_V2 +from openai._models import BaseModel +from openai._types import Omit + +__all__ = ["Omit", "PYDANTIC_V2", "BaseModel"] diff --git a/aidial_client/_compatibility/pydantic_v1.py b/aidial_client/_compatibility/pydantic_v1.py new file mode 100644 index 0000000..560a5c3 --- /dev/null +++ b/aidial_client/_compatibility/pydantic_v1.py @@ -0,0 +1,4 @@ +try: + from pydantic.v1 import * # type: ignore # noqa +except ImportError: + from pydantic import * # type: ignore # noqa diff --git a/aidial_client/_constants.py b/aidial_client/_constants.py new file mode 100644 index 0000000..19bc6bc --- /dev/null +++ b/aidial_client/_constants.py @@ -0,0 +1,18 @@ +from urllib.parse import urljoin + +import httpx + +DEFAULT_MAX_RETRIES = 2 +DEFAULT_TIMEOUT = httpx.Timeout(timeout=600.0, connect=5.0) +DEFAULT_CONNECTION_LIMITS = httpx.Limits( + max_connections=1000, max_keepalive_connections=100 +) +INITIAL_RETRY_DELAY = 0.5 +MAX_RETRY_DELAY = 8.0 +API_PREFIX = "v1/" +METADATA_PREFIX = urljoin(API_PREFIX, "metadata/") +FILES_PREFIX = urljoin(API_PREFIX, "files/") + + +OPENAI_PREFIX = "openai/" +APPLICATION_PREFIX = f"{OPENAI_PREFIX}applications/" diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py new file mode 100644 index 0000000..113820f --- /dev/null +++ b/aidial_client/_exception.py @@ -0,0 +1,89 @@ +from http import HTTPStatus +from typing import Mapping, Optional + +from aidial_client._utils._dict import remove_none + + +class DialException(Exception): + def __init__( + self, + message: str, + status_code: int = 500, + type: Optional[str] = "runtime_error", + param: Optional[str] = None, + code: Optional[str] = None, + display_message: Optional[str] = None, + ) -> None: + self.message = message + self.status_code = status_code + self.type = type + self.param = param + self.code = code + self.display_message = display_message + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"message={self.message!r}," + f"status_code={self.status_code!r}," + f"type={self.type!r}," + f"param={self.param!r}," + f"code={self.code!r}," + f"display_message={self.display_message!r})" + ) + + def __str__(self) -> str: + return self.__repr__() + + def json_error(self) -> dict: + return { + "error": remove_none( + { + "message": self.message, + "type": self.type, + "param": self.param, + "code": self.code, + "display_message": self.display_message, + } + ) + } + + @classmethod + def from_error_data( + cls, message: str, status_code: int, error_data: Mapping + ) -> "DialException": + return cls( + message=message, + status_code=status_code, + type=error_data.get("type"), + param=error_data.get("param"), + code=error_data.get("code"), + display_message=error_data.get("display_message"), + ) + + +class InvalidRequestException(DialException): + def __init__(self, message: str, **kwargs) -> None: + super().__init__( + message=message, + type="invalid_request_error", + status_code=HTTPStatus.BAD_REQUEST, + **kwargs, + ) + + +class InvalidDialURLException(InvalidRequestException): + pass + + +class InvalidBucketException(InvalidRequestException): + pass + + +class ParsingDataException(DialException): + def __init__(self, message: str, **kwargs) -> None: + super().__init__( + message=message, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + **kwargs, + ) diff --git a/aidial_client/_http_client/__init__.py b/aidial_client/_http_client/__init__.py new file mode 100644 index 0000000..816248e --- /dev/null +++ b/aidial_client/_http_client/__init__.py @@ -0,0 +1,5 @@ +from ._async import AsyncHTTPClient +from ._base import BaseHTTPClient +from ._sync import SyncHTTPClient + +__all__ = ["AsyncHTTPClient", "SyncHTTPClient", "BaseHTTPClient"] diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py new file mode 100644 index 0000000..c3fd0a7 --- /dev/null +++ b/aidial_client/_http_client/_async.py @@ -0,0 +1,110 @@ +import asyncio +from http import HTTPStatus +from typing import Callable, Dict, Optional, Type + +import httpx + +from aidial_client._auth import AsyncAuthValue, get_async_auth_headers +from aidial_client._exception import DialException +from aidial_client._http_client._base import BaseHTTPClient +from aidial_client._internal_types._generic import ResponseT +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client._log import logger +from aidial_client._utils._response_processing import default_process_non_stream + + +class AsyncHTTPClient(BaseHTTPClient[httpx.AsyncClient, AsyncAuthValue]): + def _create_internal_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + timeout=self._timeout, + ) + + async def auth_headers(self) -> Dict[str, str]: + return await get_async_auth_headers( + auth_value=self._auth_value, auth_type=self._auth_type + ) + + async def _retry_request( + self, + *, + options: FinalRequestOptions, + cast_to: Type[ResponseT], + remaining_retries: int, + ) -> ResponseT: + remaining = remaining_retries - 1 + logger.debug(f"Retries left: {remaining}") + + sleep_time = self._calculate_retry_sleep_seconds(remaining, options) + logger.info(f"Making retry to {options.url} in {sleep_time} seconds") + await asyncio.sleep(sleep_time) + + return await self.request( + options=options, cast_to=cast_to, remaining_retries=remaining + ) + + async def request( + self, + *, + options: FinalRequestOptions, + cast_to: Type[ResponseT], + remaining_retries: Optional[int] = None, + error_processor: Optional[ + Callable[[httpx.HTTPStatusError], Optional[DialException]] + ] = None, + ) -> ResponseT: + retries = self._remaining_retries(remaining_retries, options) + auth_headers = await self.auth_headers() + + request = self._build_request(options, auth_headers) + try: + response = await self._client.send(request) + except httpx.TimeoutException as err: + logger.debug("Request failed by timeout") + + if retries > 0: + return await self._retry_request( + options=options, + cast_to=cast_to, + remaining_retries=retries, + ) + + raise DialException( + message="Request timed out", + status_code=HTTPStatus.REQUEST_TIMEOUT, + ) from err + except Exception as err: + logger.debug("Unknown exception") + if retries > 0: + return await self._retry_request( + options=options, + cast_to=cast_to, + remaining_retries=retries, + ) + raise DialException(message="Unknown error during request") from err + + logger.debug(f"HTTP Response received with {response.status_code}") + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + logger.debug( + f"Encountered error HTTP status: {err.response.status_code}" + f"Content: {err.response.text}" + ) + + if retries > 0 and self._should_retry(err.response): + err.response.close() + return await self._retry_request( + options=options, + cast_to=cast_to, + remaining_retries=retries, + ) + # Try to get custom error from response status_code/code/message + custom_error = error_processor(err) if error_processor else None + # or fallback to default processing + raised_error = custom_error or self._make_dial_error_from_response( + err.response + ) + raise raised_error from err + + return default_process_non_stream(cast_to=cast_to, response=response) diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py new file mode 100644 index 0000000..73cb948 --- /dev/null +++ b/aidial_client/_http_client/_base.py @@ -0,0 +1,139 @@ +from abc import ABC, abstractmethod +from http import HTTPStatus +from random import uniform +from typing import Dict, Generic, Optional, TypeVar, Union + +import httpx + +from aidial_client._auth import AuthType, AuthValueT +from aidial_client._constants import INITIAL_RETRY_DELAY, MAX_RETRY_DELAY +from aidial_client._exception import DialException +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client._utils._type_guard import is_mapping +from aidial_client.helpers.url import enforce_trailing_slash + +_HttpInternalClientT = TypeVar( + "_HttpInternalClientT", bound=Union[httpx.Client, httpx.AsyncClient] +) + + +class BaseHTTPClient(ABC, Generic[_HttpInternalClientT, AuthValueT]): + _client: _HttpInternalClientT + _auth_value: AuthValueT + _auth_type: AuthType + + def __init__( + self, + base_url: str, + auth_value: AuthValueT, + auth_type: AuthType, + max_retries: int, + timeout: Union[float, httpx.Timeout, None], + _client: Optional[_HttpInternalClientT] = None, + ): + self.base_url = httpx.URL(enforce_trailing_slash(base_url)) + self._auth_value = auth_value + self._auth_type = auth_type + self._max_retries = max_retries + self._timeout = timeout + self._client = _client or self._create_internal_client() + + @abstractmethod + def _create_internal_client( + self, + ) -> _HttpInternalClientT: ... + + def _prepare_url(self, url: str) -> httpx.URL: + parsed_url = httpx.URL(url) + if parsed_url.is_relative_url: + merge_raw_path = ( + self.base_url.raw_path + parsed_url.raw_path.lstrip(b"/") + ) + return self.base_url.copy_with(raw_path=merge_raw_path.rstrip(b"/")) + return parsed_url + + def _build_request( + self, + options: FinalRequestOptions, + auth_headers: Dict[str, str], + ) -> httpx.Request: + custom_headers = options.headers or {} + return self._client.build_request( + headers={**auth_headers, **custom_headers}, + method=options.method, + url=self._prepare_url(options.url), + params=( + httpx.QueryParams(options.params) if options.params else None + ), + json=options.json_data, + files=options.files, # type: ignore + timeout=options.get_timeout(self._timeout), + ) + + def _remaining_retries( + self, remaining_retries, options: FinalRequestOptions + ) -> int: + return ( + remaining_retries + if remaining_retries is not None + else options.get_max_retries(self._max_retries) + ) + + def _should_retry(self, response: httpx.Response) -> bool: + if response.status_code == HTTPStatus.REQUEST_TIMEOUT: + return True + + if response.status_code == HTTPStatus.CONFLICT: + return True + + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + return True + + return False + + def _calculate_retry_sleep_seconds( + self, + remaining_retries: int, + options: FinalRequestOptions, + ) -> float: + max_retries = options.get_max_retries(self._max_retries) + + nb_retries = max_retries - remaining_retries + + # Apply exponential backoff, but not more than the max. + sleep_seconds = min( + INITIAL_RETRY_DELAY * pow(2.0, nb_retries), MAX_RETRY_DELAY + ) + timeout = sleep_seconds + uniform(-0.5, 0.5) + return max(0, timeout) + + def _make_dial_error_from_response( + self, + response: httpx.Response, + ) -> DialException: + if response.is_closed and not response.is_stream_consumed: + # We can't read the response body as it has been closed + # before it was read. This can happen if an event hook + # raises a status error. + return DialException(message="", status_code=response.status_code) + + try: + message_data = response.json() + assert is_mapping(message_data) + error_data = message_data["error"] + assert is_mapping(error_data) + message = error_data["message"] + assert isinstance(message, str) + return DialException.from_error_data( + message=message, + status_code=response.status_code, + error_data=error_data, + ) + except Exception: + return DialException( + message=response.text, status_code=response.status_code + ) + + @property + def internal_http_client(self) -> _HttpInternalClientT: + return self._client diff --git a/aidial_client/_http_client/_sync.py b/aidial_client/_http_client/_sync.py new file mode 100644 index 0000000..f4a1e05 --- /dev/null +++ b/aidial_client/_http_client/_sync.py @@ -0,0 +1,110 @@ +import time +from http import HTTPStatus +from typing import Callable, Dict, Optional, Type + +import httpx + +from aidial_client._auth import SyncAuthValue, get_auth_headers +from aidial_client._exception import DialException +from aidial_client._http_client._base import BaseHTTPClient +from aidial_client._internal_types._generic import ResponseT +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client._log import logger +from aidial_client._utils._response_processing import default_process_non_stream + + +class SyncHTTPClient(BaseHTTPClient[httpx.Client, SyncAuthValue]): + def _create_internal_client(self) -> httpx.Client: + return httpx.Client() + + def _retry_request( + self, + options: FinalRequestOptions, + cast_to: Type[ResponseT], + remaining_retries: int, + ) -> ResponseT: + remaining = remaining_retries - 1 + logger.debug(f"Retries left: {remaining}") + + sleep_time = self._calculate_retry_sleep_seconds(remaining, options) + logger.info(f"Making retry to {options.url} in {sleep_time} seconds") + time.sleep(sleep_time) + + return self.request( + options=options, + cast_to=cast_to, + remaining_retries=remaining, + ) + + def auth_headers(self) -> Dict[str, str]: + return get_auth_headers( + auth_value=self._auth_value, auth_type=self._auth_type + ) + + def request( + self, + cast_to: Type[ResponseT], + options: FinalRequestOptions, + remaining_retries: Optional[int] = None, + *, + error_processor: Optional[ + Callable[[httpx.HTTPStatusError], Optional[DialException]] + ] = None, + ) -> ResponseT: + retries = self._remaining_retries(remaining_retries, options) + auth_headers = self.auth_headers() + request = self._build_request(options, auth_headers) + + try: + response = self._client.send(request) + + except httpx.TimeoutException as err: + logger.debug("Request failed by timeout") + + if retries > 0: + return self._retry_request( + options, + cast_to, + retries, + ) + + raise DialException( + message="Request timed out", + status_code=HTTPStatus.REQUEST_TIMEOUT, + ) from err + except Exception as err: + logger.debug("Unknown exception") + if retries > 0: + return self._retry_request( + options=options, + cast_to=cast_to, + remaining_retries=retries, + ) + raise DialException(message="Unknown error during request") from err + + logger.debug(f"HTTP Response received with {response.status_code}") + + try: + response.raise_for_status() + except httpx.HTTPStatusError as err: + logger.debug( + f"Encountered error HTTP status: {err.response.status_code}" + f"Content: {err.response.text}" + ) + + if retries > 0 and self._should_retry(err.response): + err.response.close() + return self._retry_request( + options=options, + cast_to=cast_to, + remaining_retries=retries, + ) + # Try to get custom error from response status_code/code/message + custom_error = error_processor(err) if error_processor else None + # or fallback to default processing + raised_error = custom_error or self._make_dial_error_from_response( + err.response + ) + raise raised_error from err + + return default_process_non_stream(cast_to=cast_to, response=response) diff --git a/aidial_client/_internal_types/__init__.py b/aidial_client/_internal_types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aidial_client/_internal_types/_defaults.py b/aidial_client/_internal_types/_defaults.py new file mode 100644 index 0000000..37ca454 --- /dev/null +++ b/aidial_client/_internal_types/_defaults.py @@ -0,0 +1,15 @@ +from typing import Literal + + +class NotGiven: + """ + A sentinel singleton class used to distinguish omitted keyword arguments + from those passed in with the value None + (which may have different behavior). + """ + + def __bool__(self) -> Literal[False]: + return False + + +NOT_GIVEN = NotGiven() diff --git a/aidial_client/_internal_types/_generic.py b/aidial_client/_internal_types/_generic.py new file mode 100644 index 0000000..d8e2893 --- /dev/null +++ b/aidial_client/_internal_types/_generic.py @@ -0,0 +1,23 @@ +from typing import TypeVar, Union + +import httpx + +from aidial_client._internal_types._model import ( + ExtraAllowModel, + ExtraForbidModel, +) +from aidial_client.types.file import FileDownloadResponse + +ResponseT = TypeVar( + "ResponseT", + bound=Union[ + ExtraAllowModel, + ExtraForbidModel, + bytes, + str, + httpx.Response, + FileDownloadResponse, + None, + ], +) +NoneType = type(None) diff --git a/aidial_client/_internal_types/_http_request.py b/aidial_client/_internal_types/_http_request.py new file mode 100644 index 0000000..f524df1 --- /dev/null +++ b/aidial_client/_internal_types/_http_request.py @@ -0,0 +1,61 @@ +from io import IOBase +from typing import ( + IO, + Any, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + Union, + final, +) + +from httpx import Timeout + +from aidial_client._compatibility.pydantic_v1 import BaseModel +from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven + +FileContent = Union[IO[bytes], bytes, str, IOBase] +FileTypes = Union[ + # file (or bytes) + FileContent, + # (filename, file (or bytes)) + Tuple[Optional[str], FileContent], + # (filename, file (or bytes), content_type) + Tuple[Optional[str], FileContent, Optional[str]], + # (filename, file (or bytes), content_type, headers) + Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]], +] + +Params = Mapping[str, Any] +Headers = Mapping[str, Any] +Data = Mapping[str, Any] +RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]] + + +@final +class FinalRequestOptions(BaseModel): + class Config: + arbitrary_types_allowed: bool = True + + method: Literal["GET", "PUT", "POST", "DELETE"] + url: str + params: Optional[Params] = None + headers: Optional[Headers] = None + max_retries: Union[int, NotGiven] = NOT_GIVEN + timeout: Union[float, Timeout, NotGiven, None] = NOT_GIVEN + files: Optional[RequestFiles] = None + json_data: Optional[Data] = None + + def get_max_retries(self, max_retries: int) -> int: + if isinstance(self.max_retries, NotGiven): + return max_retries + return self.max_retries + + def get_timeout( + self, timeout: Union[float, Timeout, None] + ) -> Union[float, Timeout, None]: + if isinstance(self.timeout, NotGiven): + return timeout + return self.timeout diff --git a/aidial_client/_internal_types/_model.py b/aidial_client/_internal_types/_model.py new file mode 100644 index 0000000..a7aeb0f --- /dev/null +++ b/aidial_client/_internal_types/_model.py @@ -0,0 +1,19 @@ +import pydantic + +from aidial_client._compatibility.openai import PYDANTIC_V2 +from aidial_client._compatibility.openai import BaseModel as OpenAIBaseModel +from aidial_client._compatibility.pydantic_v1 import BaseModel, Extra + + +class ExtraAllowModel(OpenAIBaseModel): + if PYDANTIC_V2: + model_config = {"extra": "allow"} + else: + + class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated] + extra = Extra.allow + + +class ExtraForbidModel(BaseModel): + class Config: + extra = Extra.forbid # type: ignore diff --git a/aidial_client/_internal_types/_stream.py b/aidial_client/_internal_types/_stream.py new file mode 100644 index 0000000..c78a732 --- /dev/null +++ b/aidial_client/_internal_types/_stream.py @@ -0,0 +1,52 @@ +import json +from typing import Any, Optional + +from aidial_client._exception import DialException + + +class ServerSentEvent: + def __init__( + self, + *, + event: Optional[str], + data: str, + id: Optional[str], + retry: Optional[int], + ) -> None: + self._id = id + self._data = data + self._event = event or None + self._retry = retry + + @property + def event(self) -> Optional[str]: + return self._event + + @property + def id(self) -> Optional[str]: + return self._id + + @property + def retry(self) -> Optional[int]: + return self._retry + + @property + def data(self) -> str: + return self._data + + def json(self) -> Any: + try: + return json.loads(self.data) + except json.JSONDecodeError: + raise DialException( + message=f"Could not parse server event correctly {self.data}" + ) + + def __repr__(self) -> str: + return ( + f"ServerSentEvent(" + f"event={self.event!r}," + f"data={self.data!r}," + f"id={self.id!r}," + f"retry={self.retry!r})" + ) diff --git a/aidial_client/_log.py b/aidial_client/_log.py new file mode 100644 index 0000000..e676854 --- /dev/null +++ b/aidial_client/_log.py @@ -0,0 +1,4 @@ +import logging + +logger: logging.Logger = logging.getLogger("aidial_client") +logger.addHandler(logging.NullHandler()) diff --git a/aidial_client/_utils/__init__.py b/aidial_client/_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aidial_client/_utils/_alias.py b/aidial_client/_utils/_alias.py new file mode 100644 index 0000000..60b0572 --- /dev/null +++ b/aidial_client/_utils/_alias.py @@ -0,0 +1,40 @@ +""" +Just copy of alias generators from pydantic V2, +so we can use library with pydantic < 2.0 version +""" + +import re + + +def to_pascal(snake: str) -> str: + """Convert a snake_case string to PascalCase. + + Args: + snake: The string to convert. + + Returns: + The PascalCase string. + """ + camel = snake.title() + return re.sub("([0-9A-Za-z])_(?=[0-9A-Z])", lambda m: m.group(1), camel) + + +def to_camel(snake: str) -> str: + """Convert a snake_case string to camelCase. + + Args: + snake: The string to convert. + + Returns: + The converted camelCase string. + """ + # If the string is already in camelCase + # and does not contain a digit followed + # by a lowercase letter, return it as it is + if re.match("^[a-z]+[A-Za-z0-9]*$", snake) and not re.search( + r"\d[a-z]", snake + ): + return snake + + camel = to_pascal(snake) + return re.sub("(^_*[A-Z])", lambda m: m.group(1).lower(), camel) diff --git a/aidial_client/_utils/_dict.py b/aidial_client/_utils/_dict.py new file mode 100644 index 0000000..e7c6b5f --- /dev/null +++ b/aidial_client/_utils/_dict.py @@ -0,0 +1,5 @@ +from typing import Any, Dict, Union + + +def remove_none(input: Dict[str, Union[Any, None]]) -> Dict[str, Any]: + return {key: value for key, value in input.items() if value is not None} diff --git a/aidial_client/_utils/_openai.py b/aidial_client/_utils/_openai.py new file mode 100644 index 0000000..157f44e --- /dev/null +++ b/aidial_client/_utils/_openai.py @@ -0,0 +1,79 @@ +import uuid +from time import time +from typing import AsyncIterator, Iterator, Union + +import openai +from openai.types.chat import ChatCompletion as OpenAIChatCompletion +from openai.types.chat import ChatCompletionChunk as OpenAIChatCompletionChunk + +from aidial_client._exception import DialException +from aidial_client.types.chat import ChatCompletionChunk, ChatCompletionResponse + + +def convert_openai_error( + error: Union[openai.APIError, openai.APIStatusError], +) -> DialException: + status_code = ( + error.status_code if isinstance(error, openai.APIStatusError) else 500 + ) + display_message = None + if ( + hasattr(error, "body") + and error.body is not None + and isinstance(error.body, dict) + ): + display_message = error.body.get("display_message", None) + return DialException( + message=error.message, + status_code=status_code, + type=error.type, + param=error.param, + code=error.code, + display_message=display_message, + ) + + +def convert_openai_response( + openai_response: OpenAIChatCompletion, +) -> ChatCompletionResponse: + return ChatCompletionResponse(**openai_response.model_dump()) + + +def convert_openai_stream( + openai_response: Iterator[OpenAIChatCompletionChunk], +) -> Iterator[ChatCompletionChunk]: + response_id = None + try: + for chunk in openai_response: + response_id = chunk.id + yield ChatCompletionChunk(**chunk.model_dump()) + except openai.APIError as e: + yield ChatCompletionChunk( + id=response_id or str(uuid.uuid4()), + object="chat.completion.chunk", + choices=[], + created=int(time.time()), + model=None, + usage=None, + error=convert_openai_error(e).json_error(), + ) + + +async def convert_openai_async_stream( + openai_response: AsyncIterator[OpenAIChatCompletionChunk], +) -> AsyncIterator[ChatCompletionChunk]: + response_id = None + try: + async for chunk in openai_response: + response_id = chunk.id + yield ChatCompletionChunk(**chunk.model_dump()) + except openai.APIError as e: + yield ChatCompletionChunk( + id=response_id or str(uuid.uuid4()), + object="chat.completion.chunk", + choices=[], + created=int(time.time()), + model=None, + usage=None, + error=convert_openai_error(e).json_error(), + ) diff --git a/aidial_client/_utils/_response_processing.py b/aidial_client/_utils/_response_processing.py new file mode 100644 index 0000000..f54faa4 --- /dev/null +++ b/aidial_client/_utils/_response_processing.py @@ -0,0 +1,35 @@ +from typing import Type, cast + +import httpx + +from aidial_client._exception import ParsingDataException +from aidial_client._internal_types._generic import NoneType, ResponseT +from aidial_client._internal_types._model import ( + ExtraAllowModel, + ExtraForbidModel, +) + + +def default_process_non_stream( + cast_to: Type[ResponseT], response: httpx.Response +) -> ResponseT: + if cast_to == httpx.Response: + return cast(ResponseT, response) + elif cast_to == bytes: + return cast(ResponseT, response.content) + elif cast_to == str: + return cast(ResponseT, response.text) + elif cast_to == NoneType: + return cast(ResponseT, None) + elif issubclass(cast_to, ExtraForbidModel) or issubclass( + cast_to, ExtraAllowModel + ): + try: + data = response.json() + return cast_to(**data) + except Exception as e: + raise ParsingDataException( + message=f"Error during parsing of response data: {str(e)}" + ) + else: + raise NotImplementedError("This cast_to type is not supported yet") diff --git a/aidial_client/_utils/_type_guard.py b/aidial_client/_utils/_type_guard.py new file mode 100644 index 0000000..b7e6b19 --- /dev/null +++ b/aidial_client/_utils/_type_guard.py @@ -0,0 +1,7 @@ +from typing import Mapping + +from typing_extensions import TypeGuard + + +def is_mapping(obj) -> TypeGuard[Mapping]: + return isinstance(obj, Mapping) diff --git a/aidial_client/helpers/__init__.py b/aidial_client/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py new file mode 100644 index 0000000..ce12b91 --- /dev/null +++ b/aidial_client/helpers/storage_resource.py @@ -0,0 +1,154 @@ +from pathlib import PurePosixPath +from typing import Literal, Optional, overload +from urllib.parse import urljoin, urlparse + +from aidial_client._compatibility.pydantic_v1 import BaseModel +from aidial_client._exception import InvalidDialURLException +from aidial_client.helpers.url import ( + enforce_trailing_slash, + remove_api_prefix, + remove_leading_slash, +) + + +class DialStorageResource(BaseModel): + resource_type: Literal["files", "conversations", "prompts"] + """Bucket name, like 'my-bucket'""" + bucket: str + + """Absolute url, like 'https://dial.core/v1/files/my-bucket/my-file.txt'""" + absolute_url: str + + """Relative url, like '/v1/files/my-bucket/my-file.txt'""" + relative_url: str + + """Path without api prefix, like 'files/my-bucket/my-folder/my-file.txt'""" + api_path: str + + """Path without bucket, like my-folder/'my-file.txt'""" + bucket_path: str + + """ + Filename, like 'my-file.txt' + """ + filename: Optional[str] = None + + +@overload +def parse_storage_resource( + url: str, + dial_api_url: str, + resource_type: Literal["files", "conversations", "prompts"], + ignore_non_dial_url: Literal[True], +) -> Optional[DialStorageResource]: ... + + +@overload +def parse_storage_resource( + url: str, + dial_api_url: str, + resource_type: Literal["files", "conversations", "prompts"], + ignore_non_dial_url: Literal[False], +) -> DialStorageResource: ... + + +def parse_storage_resource( + url: str, + dial_api_url: str, + resource_type: Literal["files", "conversations", "prompts"], + ignore_non_dial_url: bool, +) -> Optional[DialStorageResource]: + dial_api_url = enforce_trailing_slash(dial_api_url) + url = remove_leading_slash(url) + # If the URL starts with API_PREFIX, remove it + url = remove_api_prefix(url) + + absolute_url = urljoin(dial_api_url, remove_leading_slash(url)) + url_parsed = urlparse(absolute_url) + dial_api_parsed = urlparse(dial_api_url) + # url is not from DIAL + if url_parsed.netloc != dial_api_parsed.netloc: + if ignore_non_dial_url: + return None + raise InvalidDialURLException( + message="Provided url is not DIAL url: {url}" + ) + try: + url_path = PurePosixPath(url_parsed.path) + api_path = url_path.relative_to(dial_api_parsed.path) + except ValueError: + + raise InvalidDialURLException( + f"Provided url path {url_parsed.path} does not match with" + f" DIAL API url {dial_api_parsed.path}" + ) + + resource_path = api_path.parents[len(api_path.parents) - 2] + if str(resource_path) != resource_type: + raise InvalidDialURLException( + f"Invalid resource type for url: {url}" + f"Expected: {resource_type},got: {resource_type}" + ) + + if len(api_path.parents) < 3: + raise InvalidDialURLException(f"Missing bucket in url: {url}") + + bucket_path = api_path.parents[len(api_path.parents) - 3] + + return DialStorageResource( + resource_type=resource_type, + absolute_url=absolute_url, + api_path=str(api_path), + bucket=str(bucket_path.relative_to(resource_path)), + bucket_path=str(api_path.relative_to(bucket_path)), + relative_url=str(url_path), + filename=url_path.name, + ) + + +class DialStorageResourceMixin(BaseModel): + """ + Mixin class for resources that are using DIAL storage: + - /v1/files + - /v1/conversations + - /v1/prompts + """ + + resource_type: Literal["files", "conversations", "prompts"] + dial_api_url: str + + def get_storage_resource(self, url: str) -> DialStorageResource: + """ + Get the storage resource object from the URL + Args: + url (str): The URL to be processed. + Returns: + DialStorageResource: The storage resource object + """ + return parse_storage_resource( + url, + self.dial_api_url, + self.resource_type, + ignore_non_dial_url=False, + ) + + def get_api_path(self, url: str) -> str: + """ + Convert URL, that could relative or absolute, to relative URL + Args: + url (str): The URL to be processed. + dial_api_url (str): The DIAL API URL to validate against. + Returns: + str: relative to DIAL API URL + """ + return self.get_storage_resource(url).api_path + + def get_display_name(self, url: str) -> str: + """ + Get the display name of the resource from the URL + Args: + url (str): The URL to be processed. + Returns: + str: The display name of the resource + """ + return self.get_storage_resource(url).bucket_path diff --git a/aidial_client/helpers/url.py b/aidial_client/helpers/url.py new file mode 100644 index 0000000..d3dafd5 --- /dev/null +++ b/aidial_client/helpers/url.py @@ -0,0 +1,20 @@ +from aidial_client._constants import API_PREFIX + + +def enforce_trailing_slash(url: str) -> str: + if url.endswith("/"): + return url + return url + "/" + + +def remove_leading_slash(url: str) -> str: + if url.startswith("/"): + return url.lstrip("/") + return url + + +def remove_api_prefix(url): + if url.startswith(API_PREFIX): + api_prefix_len = len(API_PREFIX) + url = url[api_prefix_len:] + return url diff --git a/aidial_client/resources/__init__.py b/aidial_client/resources/__init__.py new file mode 100644 index 0000000..6467e8b --- /dev/null +++ b/aidial_client/resources/__init__.py @@ -0,0 +1,22 @@ +from aidial_client.resources.deployments import AsyncDeployments, Deployments +from aidial_client.resources.metadata import AsyncMetadata, Metadata + +from .application import Application, AsyncApplication +from .bucket import AsyncBucket, Bucket +from .chat import AsyncChat, Chat +from .files import AsyncFiles, Files + +__all__ = [ + "Chat", + "AsyncChat", + "Bucket", + "AsyncBucket", + "Files", + "AsyncFiles", + "AsyncDeployments", + "Deployments", + "AsyncMetadata", + "Metadata", + "Application", + "AsyncApplication", +] diff --git a/aidial_client/resources/application.py b/aidial_client/resources/application.py new file mode 100644 index 0000000..9146934 --- /dev/null +++ b/aidial_client/resources/application.py @@ -0,0 +1,47 @@ +from typing import List +from urllib.parse import urljoin + +from aidial_client._constants import APPLICATION_PREFIX +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.types.application import Application as ApplicationType +from aidial_client.types.application import ApplicationsResponse + + +class Application(Resource): + def get(self, app_id: str) -> ApplicationType: + return self.http_client.request( + cast_to=ApplicationType, + options=FinalRequestOptions( + method="GET", url=urljoin(APPLICATION_PREFIX, app_id) + ), + ) + + def _list_raw(self) -> ApplicationsResponse: + return self.http_client.request( + cast_to=ApplicationsResponse, + options=FinalRequestOptions(method="GET", url=APPLICATION_PREFIX), + ) + + def list(self) -> List[ApplicationType]: + return self._list_raw().data + + +class AsyncApplication(AsyncResource): + async def get(self, app_id: str) -> ApplicationType: + return await self.http_client.request( + cast_to=ApplicationType, + options=FinalRequestOptions( + method="GET", + url=urljoin(APPLICATION_PREFIX, app_id), + ), + ) + + async def _list_raw(self) -> ApplicationsResponse: + return await self.http_client.request( + cast_to=ApplicationsResponse, + options=FinalRequestOptions(method="GET", url=APPLICATION_PREFIX), + ) + + async def list(self) -> List[ApplicationType]: + return (await self._list_raw()).data diff --git a/aidial_client/resources/base.py b/aidial_client/resources/base.py new file mode 100644 index 0000000..fc3e2ad --- /dev/null +++ b/aidial_client/resources/base.py @@ -0,0 +1,16 @@ +from aidial_client._compatibility.pydantic_v1 import BaseModel +from aidial_client._http_client import AsyncHTTPClient, SyncHTTPClient + + +class Resource(BaseModel): + class Config: + arbitrary_types_allowed = True + + http_client: SyncHTTPClient + + +class AsyncResource(BaseModel): + class Config: + arbitrary_types_allowed = True + + http_client: AsyncHTTPClient diff --git a/aidial_client/resources/bucket.py b/aidial_client/resources/bucket.py new file mode 100644 index 0000000..c3af759 --- /dev/null +++ b/aidial_client/resources/bucket.py @@ -0,0 +1,47 @@ +from typing import Optional +from urllib.parse import urljoin + +from aidial_client._constants import API_PREFIX +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.types.bucket import AppData, BucketResponse + + +class Bucket(Resource): + def get_raw(self) -> BucketResponse: + return self.http_client.request( + cast_to=BucketResponse, + options=FinalRequestOptions( + method="GET", url=urljoin(API_PREFIX, "bucket") + ), + ) + + def get_bucket(self) -> str: + response = self.get_raw() + return response.bucket + + def get_appdata(self) -> Optional[AppData]: + response = self.get_raw() + if not response.appdata: + return None + return AppData.parse(response.appdata) + + +class AsyncBucket(AsyncResource): + async def get_raw(self) -> BucketResponse: + return await self.http_client.request( + cast_to=BucketResponse, + options=FinalRequestOptions( + method="GET", url=urljoin(API_PREFIX, "bucket") + ), + ) + + async def get_bucket(self) -> str: + response = await self.get_raw() + return response.bucket + + async def get_appdata(self) -> Optional[AppData]: + response = await self.get_raw() + if not response.appdata: + return None + return AppData.parse(response.appdata) diff --git a/aidial_client/resources/chat/__init__.py b/aidial_client/resources/chat/__init__.py new file mode 100644 index 0000000..3b784e9 --- /dev/null +++ b/aidial_client/resources/chat/__init__.py @@ -0,0 +1,13 @@ +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.resources.chat.completions import ( + AsyncChatCompletions, + ChatCompletions, +) + + +class Chat(Resource): + completions: ChatCompletions + + +class AsyncChat(AsyncResource): + completions: AsyncChatCompletions diff --git a/aidial_client/resources/chat/completions.py b/aidial_client/resources/chat/completions.py new file mode 100644 index 0000000..614c8d2 --- /dev/null +++ b/aidial_client/resources/chat/completions.py @@ -0,0 +1,398 @@ +from typing import ( + Any, + AsyncIterable, + Dict, + Iterable, + List, + Literal, + Mapping, + Optional, + Union, + cast, + overload, +) + +import openai +from openai import AsyncStream as OpenaiAsyncStream +from openai import Stream as OpenaiStream +from openai.types.chat import ChatCompletion as OpenaiChatCompletion +from openai.types.chat import ChatCompletionChunk as OpenaiChatCompletionChunk +from pydantic import StrictStr + +from aidial_client._compatibility.openai import Omit +from aidial_client._utils._dict import remove_none +from aidial_client._utils._openai import ( + convert_openai_async_stream, + convert_openai_error, + convert_openai_response, + convert_openai_stream, +) +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.types.chat import ( + Addon, + ChatCompletionChunk, + ChatCompletionResponse, + FunctionCallSpecParam, + FunctionParam, + Message, + ToolCallSpecParam, + ToolParam, +) +from aidial_client.types.chat.request import ChatCompletionRequestCustomFields + + +class ChatCompletions(Resource): + default_api_version: Optional[str] = None + openai_client: openai.AzureOpenAI + + @overload + def create( + self, + *, + deployment_name: str, + messages: List[Message], + stream: Literal[True], + api_version: Optional[str] = None, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + logprobs: Union[bool, None] = None, + top_logprobs: Union[int, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Iterable[ChatCompletionChunk]: ... + + @overload + def create( + self, + *, + deployment_name: str, + messages: List[Message], + stream: Literal[False], + api_version: Optional[str] = None, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + logprobs: Union[bool, None] = None, + top_logprobs: Union[int, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> ChatCompletionResponse: ... + + def create( + self, + *, + deployment_name: str, + messages: List[Message], + api_version: Optional[str] = None, + stream: bool = False, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + logprobs: Union[bool, None] = None, + top_logprobs: Union[int, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Union[ChatCompletionResponse, Iterable[ChatCompletionChunk]]: + + model = model or deployment_name + extra_body = extra_body or {} + extra_headers = extra_headers or {} + extra_params = extra_params or {} + + input_params = remove_none( + { + "messages": messages, + "model": model, + "frequency_penalty": frequency_penalty, + "function_call": function_call, + "functions": functions, + "logit_bias": logit_bias, + "max_tokens": max_tokens, + "n": n, + "presence_penalty": presence_penalty, + "seed": seed, + "stop": stop, + "stream": stream, + "temperature": temperature, + "tool_choice": tool_choice, + "tools": tools, + "top_p": top_p, + "user": user, + "addons": addons, + "max_prompt_tokens": max_prompt_tokens, + "custom_fields": custom_fields, + "logprobs": logprobs, + "top_logprobs": top_logprobs, + "extra_body": extra_body, + "extra_query": { + "api-version": ( + api_version or self.default_api_version or Omit() + ) + }, + "extra_headers": { + # We use Omit to override openai client auth headers + **{"Authorization": Omit(), "api-key": Omit()}, + **(self.http_client.auth_headers()), + **extra_headers, + }, + } + ) + try: + openai_response = self.openai_client.chat.completions.create( + **input_params, + ) + openai_response = cast( + Union[ + OpenaiChatCompletion, + OpenaiStream[OpenaiChatCompletionChunk], + ], + openai_response, + ) + except openai.APIError as err: + raise convert_openai_error(err) + + if isinstance(openai_response, OpenaiChatCompletion): + return convert_openai_response(openai_response) + else: + return convert_openai_stream(openai_response) + + +class AsyncChatCompletions(AsyncResource): + default_api_version: Optional[str] = None + openai_client: openai.AsyncAzureOpenAI + + @overload + async def create( + self, + *, + deployment_name: str, + messages: List[Message], + stream: Literal[True], + api_version: Optional[str] = None, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> AsyncIterable[ChatCompletionChunk]: ... + + @overload + async def create( + self, + *, + deployment_name: str, + messages: List[Message], + stream: Literal[False], + api_version: Optional[str] = None, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + logprobs: Union[bool, None] = None, + top_logprobs: Union[int, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> ChatCompletionResponse: ... + + async def create( + self, + *, + deployment_name: str, + messages: List[Message], + api_version: Optional[str] = None, + stream: bool = False, + model: Optional[str] = None, + functions: Union[List[FunctionParam], None] = None, + function_call: Union[ + Union[Literal["none", "auto"], FunctionCallSpecParam], None + ] = None, + tools: Union[List[ToolParam], None] = None, + tool_choice: Union[ + Union[Literal["none", "auto"], ToolCallSpecParam], None + ] = None, + addons: Union[Addon, None] = None, + temperature: Union[float, None] = None, + top_p: Union[float, None] = None, + n: Union[int, None] = None, + stop: Union[Union[str, List[str]], None] = None, + max_tokens: Union[int, None] = None, + max_prompt_tokens: Union[Union[Literal["infinity"], int], None] = None, + presence_penalty: Union[float, None] = None, + frequency_penalty: Union[float, None] = None, + logit_bias: Union[Dict, None] = None, + seed: Union[int, None] = None, + user: Union[str, None] = None, + custom_fields: Union[ChatCompletionRequestCustomFields, None] = None, + logprobs: Union[bool, None] = None, + top_logprobs: Union[int, None] = None, + # Extra params + extra_body: Optional[Dict[str, Any]] = None, + extra_headers: Optional[Mapping[StrictStr, StrictStr]] = None, + extra_params: Optional[Dict[str, Any]] = None, + ) -> Union[ChatCompletionResponse, AsyncIterable[ChatCompletionChunk]]: + model = model or deployment_name + extra_body = extra_body or {} + extra_headers = extra_headers or {} + extra_params = extra_params or {} + + input_params = remove_none( + { + "messages": messages, + "model": model, + "frequency_penalty": frequency_penalty, + "function_call": function_call, + "functions": functions, + "logit_bias": logit_bias, + "max_tokens": max_tokens, + "n": n, + "presence_penalty": presence_penalty, + "seed": seed, + "stop": stop, + "stream": stream, + "temperature": temperature, + "tool_choice": tool_choice, + "tools": tools, + "top_p": top_p, + "user": user, + "addons": addons, + "max_prompt_tokens": max_prompt_tokens, + "custom_fields": custom_fields, + "logprobs": logprobs, + "top_logprobs": top_logprobs, + "extra_body": extra_body, + "extra_query": { + "api-version": ( + api_version or self.default_api_version or Omit() + ) + }, + "extra_headers": { + # We use Omit to override openai client auth headers + **{"Authorization": Omit(), "api-key": Omit()}, + **(await self.http_client.auth_headers()), + **extra_headers, + }, + } + ) + try: + openai_response = await self.openai_client.chat.completions.create( + **input_params, + ) + openai_response = cast( + Union[ + OpenaiChatCompletion, + OpenaiAsyncStream[OpenaiChatCompletionChunk], + ], + openai_response, + ) + except openai.APIError as err: + raise convert_openai_error(err) + + if isinstance(openai_response, OpenaiChatCompletion): + return convert_openai_response(openai_response) + else: + return convert_openai_async_stream(openai_response) diff --git a/aidial_client/resources/deployments.py b/aidial_client/resources/deployments.py new file mode 100644 index 0000000..adf6ab9 --- /dev/null +++ b/aidial_client/resources/deployments.py @@ -0,0 +1,27 @@ +from typing import List + +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.types.deployment import Deployment, DeploymentsResponse + + +class Deployments(Resource): + def raw_get(self) -> DeploymentsResponse: + return self.http_client.request( + cast_to=DeploymentsResponse, + options=FinalRequestOptions(method="GET", url="openai/deployments"), + ) + + def get(self) -> List[Deployment]: + return self.raw_get().data + + +class AsyncDeployments(AsyncResource): + async def raw_get(self) -> DeploymentsResponse: + return await self.http_client.request( + cast_to=DeploymentsResponse, + options=FinalRequestOptions(method="GET", url="openai/deployments"), + ) + + async def get(self) -> List[Deployment]: + return (await self.raw_get()).data diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py new file mode 100644 index 0000000..4cb1447 --- /dev/null +++ b/aidial_client/resources/files.py @@ -0,0 +1,152 @@ +from pathlib import PurePosixPath +from typing import Optional, Union +from urllib.parse import urljoin + +import httpx + +from aidial_client._constants import API_PREFIX +from aidial_client._exception import DialException, InvalidBucketException +from aidial_client._internal_types._generic import NoneType +from aidial_client._internal_types._http_request import ( + FileTypes, + FinalRequestOptions, +) +from aidial_client.helpers.storage_resource import DialStorageResourceMixin +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.resources.metadata import AsyncMetadata, Metadata +from aidial_client.types.file import FileDownloadResponse +from aidial_client.types.metadata import FileMetadata + + +def _error_processor(error: httpx.HTTPStatusError) -> Optional[DialException]: + try: + response = error.response + error_message = response.text + if ( + response.status_code == 400 + # TODO: move it to response.code check, + # when adapter will return it for this particular error + and "Url has invalid bucket" in error_message + ): + return InvalidBucketException(error_message) + except Exception: + return None + else: + return None + + +class Files(Resource, DialStorageResourceMixin): + metadata: Metadata + resource_type: str = "files" + + def upload( + self, url: Union[str, PurePosixPath], file: FileTypes + ) -> FileMetadata: + return self.http_client.request( + cast_to=FileMetadata, + options=FinalRequestOptions( + method="PUT", + url=urljoin(API_PREFIX, self.get_api_path(str(url))), + files={"file": file}, + ), + error_processor=_error_processor, + ) + + def download( + self, + url: Union[str, PurePosixPath], + ) -> FileDownloadResponse: + storage_resource = self.get_storage_resource(str(url)) + response = self.http_client.request( + cast_to=httpx.Response, + options=FinalRequestOptions( + method="GET", + url=urljoin(API_PREFIX, storage_resource.api_path), + ), + error_processor=_error_processor, + ) + assert storage_resource.filename + return FileDownloadResponse( + response=response, filename=storage_resource.filename + ) + + def delete( + self, + url: Union[str, PurePosixPath], + ) -> None: + return self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="DELETE", + url=urljoin(API_PREFIX, self.get_api_path(str(url))), + ), + error_processor=_error_processor, + ) + + def get_metadata( + self, + url: Union[str, PurePosixPath], + ) -> FileMetadata: + return self.metadata.get( + resource="files", + relative_url=self.get_api_path(str(url)), + ) + + +class AsyncFiles(AsyncResource, DialStorageResourceMixin): + metadata: AsyncMetadata + resource_type: str = "files" + + async def upload( + self, url: Union[str, PurePosixPath], file: FileTypes + ) -> FileMetadata: + + return await self.http_client.request( + cast_to=FileMetadata, + options=FinalRequestOptions( + method="PUT", + url=urljoin(API_PREFIX, self.get_api_path(str(url))), + files={"file": file}, + ), + error_processor=_error_processor, + ) + + async def download( + self, + url: Union[str, PurePosixPath], + ) -> FileDownloadResponse: + storage_resource = self.get_storage_resource(str(url)) + response = await self.http_client.request( + cast_to=httpx.Response, + options=FinalRequestOptions( + method="GET", + url=urljoin(API_PREFIX, storage_resource.api_path), + ), + error_processor=_error_processor, + ) + assert storage_resource.filename + return FileDownloadResponse( + response=response, filename=storage_resource.filename + ) + + async def delete( + self, + url: Union[str, PurePosixPath], + ) -> None: + return await self.http_client.request( + cast_to=NoneType, + options=FinalRequestOptions( + method="DELETE", + url=urljoin(API_PREFIX, self.get_api_path(str(url))), + ), + error_processor=_error_processor, + ) + + async def get_metadata( + self, + url: Union[str, PurePosixPath], + ) -> FileMetadata: + return await self.metadata.get( + resource="files", + relative_url=self.get_api_path(str(url)), + ) diff --git a/aidial_client/resources/metadata.py b/aidial_client/resources/metadata.py new file mode 100644 index 0000000..be441b9 --- /dev/null +++ b/aidial_client/resources/metadata.py @@ -0,0 +1,89 @@ +from typing import Literal, Type, Union, overload +from urllib.parse import urljoin + +from aidial_client._constants import METADATA_PREFIX +from aidial_client._exception import DialException +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client.resources.base import AsyncResource, Resource +from aidial_client.types.metadata import ( + ConversationMetadata, + FileMetadata, + PromptMetadata, +) + + +def _get_cast_to( + resource: Literal["files", "conversations", "prompts"] +) -> Union[ + Type[FileMetadata], Type[ConversationMetadata], Type[PromptMetadata] +]: + if resource == "files": + return FileMetadata + elif resource == "conversations": + return ConversationMetadata + elif resource == "prompts": + return PromptMetadata + else: + raise DialException( + message="Not supported type of resource for metadata" + ) + + +class Metadata(Resource): + @overload + def get( + self, resource: Literal["files"], relative_url: str + ) -> FileMetadata: ... + + @overload + def get( + self, resource: Literal["conversations"], relative_url: str + ) -> ConversationMetadata: ... + + @overload + def get( + self, resource: Literal["prompts"], relative_url: str + ) -> PromptMetadata: ... + + def get( + self, + resource: Literal["files", "conversations", "prompts"], + relative_url: str, + ) -> Union[FileMetadata, ConversationMetadata, PromptMetadata]: + return self.http_client.request( + cast_to=_get_cast_to(resource), + options=FinalRequestOptions( + method="GET", + url=urljoin(METADATA_PREFIX, relative_url), + ), + ) + + +class AsyncMetadata(AsyncResource): + @overload + async def get( + self, resource: Literal["files"], relative_url: str + ) -> FileMetadata: ... + + @overload + async def get( + self, resource: Literal["conversations"], relative_url: str + ) -> ConversationMetadata: ... + + @overload + async def get( + self, resource: Literal["prompts"], relative_url: str + ) -> PromptMetadata: ... + + async def get( + self, + resource: Literal["files", "conversations", "prompts"], + relative_url: str, + ) -> Union[FileMetadata, ConversationMetadata, PromptMetadata]: + return await self.http_client.request( + cast_to=_get_cast_to(resource), + options=FinalRequestOptions( + method="GET", + url=urljoin(METADATA_PREFIX, relative_url), + ), + ) diff --git a/aidial_client/types/__init__.py b/aidial_client/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aidial_client/types/application.py b/aidial_client/types/application.py new file mode 100644 index 0000000..3aac5c8 --- /dev/null +++ b/aidial_client/types/application.py @@ -0,0 +1,39 @@ +from typing import Dict, List, Literal, Optional + +from aidial_client._internal_types._model import ExtraAllowModel + + +class Features(ExtraAllowModel): + rate: Optional[bool] = None + tokenize: Optional[bool] = None + truncate_prompt: Optional[bool] = None + configuration: Optional[bool] = None + system_prompt: Optional[bool] = None + tools: Optional[bool] = None + seed: Optional[bool] = None + url_attachments: Optional[bool] = None + folder_attachments: Optional[bool] = None + allow_resume: Optional[bool] = None + + +class Application(ExtraAllowModel): + object: Literal["application"] + id: str + description: Optional[str] = None + application: str + display_name: Optional[str] = None + display_version: Optional[str] = None + icon_url: Optional[str] = None + reference: Optional[str] = None + owner: Optional[str] = None + status: Optional[str] = None + created_at: int + updated_at: Optional[int] = None + features: Features + input_attachment_types: Optional[List[str]] + defaults: Dict = {} + + +class ApplicationsResponse(ExtraAllowModel): + data: List[Application] + object: Literal["list"] diff --git a/aidial_client/types/bucket.py b/aidial_client/types/bucket.py new file mode 100644 index 0000000..1ca2063 --- /dev/null +++ b/aidial_client/types/bucket.py @@ -0,0 +1,24 @@ +import re +from typing import Optional + +from aidial_client._internal_types._model import ExtraAllowModel + + +class AppData(ExtraAllowModel): + raw: str + user_bucket: str + app_name: str + + @classmethod + def parse(cls, appdata: str) -> "AppData": + match = re.match(r"^(.+)/appdata/(.+)$", appdata) + if not match: + raise ValueError("Invalid appdata format") + + user_bucket, app_name = match.groups() + return cls(raw=appdata, user_bucket=user_bucket, app_name=app_name) + + +class BucketResponse(ExtraAllowModel): + bucket: str + appdata: Optional[str] = None diff --git a/aidial_client/types/chat/__init__.py b/aidial_client/types/chat/__init__.py new file mode 100644 index 0000000..7e5b4a2 --- /dev/null +++ b/aidial_client/types/chat/__init__.py @@ -0,0 +1,30 @@ +from .addon import Addon, ExternalAddon, SystemAddon +from .function import FunctionCallSpecParam, FunctionParam +from .request import ChatCompletionRequest +from .request_param import ( + FunctionMessageParam, + Message, + SystemMessageParam, + ToolMessageParam, + UserMessageParam, +) +from .response import ChatCompletionChunk, ChatCompletionResponse +from .tool import ToolCallSpecParam, ToolParam + +__all__ = [ + "ChatCompletionRequest", + "Addon", + "SystemAddon", + "ExternalAddon", + "FunctionParam", + "FunctionCallSpecParam", + "ToolParam", + "ToolCallSpecParam", + "Message", + "ToolMessageParam", + "UserMessageParam", + "SystemMessageParam", + "FunctionMessageParam", + "ChatCompletionResponse", + "ChatCompletionChunk", +] diff --git a/aidial_client/types/chat/addon.py b/aidial_client/types/chat/addon.py new file mode 100644 index 0000000..d59334b --- /dev/null +++ b/aidial_client/types/chat/addon.py @@ -0,0 +1,17 @@ +from typing import Union + +from typing_extensions import TypedDict + + +class ExternalAddon(TypedDict): + # The URL for accessing the OpenAI Plugin Schema. + # The system object used for converting the Addon name to the Addon link. + url: str + + +class SystemAddon(TypedDict): + # The name of the system Addon. + name: str + + +Addon = Union[ExternalAddon, SystemAddon] diff --git a/aidial_client/types/chat/function.py b/aidial_client/types/chat/function.py new file mode 100644 index 0000000..41b7e35 --- /dev/null +++ b/aidial_client/types/chat/function.py @@ -0,0 +1,18 @@ +from typing import Dict, Optional + +from typing_extensions import Required, TypedDict + + +class FunctionParam(TypedDict, total=False): + name: Required[str] + description: Optional[str] + parameters: Optional[Dict] + + +class FunctionCallParam(TypedDict): + name: Required[str] + arguments: Required[str] + + +class FunctionCallSpecParam(TypedDict): + name: Required[str] diff --git a/aidial_client/types/chat/legacy/__init__.py b/aidial_client/types/chat/legacy/__init__.py new file mode 100644 index 0000000..ea55415 --- /dev/null +++ b/aidial_client/types/chat/legacy/__init__.py @@ -0,0 +1,3 @@ +from .chat_completion import ChatCompletionRequest + +__all__ = ["ChatCompletionRequest"] diff --git a/aidial_client/types/chat/legacy/application_request.py b/aidial_client/types/chat/legacy/application_request.py new file mode 100644 index 0000000..4a3c645 --- /dev/null +++ b/aidial_client/types/chat/legacy/application_request.py @@ -0,0 +1,64 @@ +from typing import Dict, Mapping, Optional + +from aidial_client._auth import AuthType, get_auth_headers +from aidial_client._compatibility.pydantic_v1 import ( + SecretStr, + StrictStr, + root_validator, +) +from aidial_client._internal_types._model import ExtraForbidModel +from aidial_client.types.chat.legacy.chat_completion import ( + ChatCompletionRequest, +) + + +class RequestParams(ExtraForbidModel): + api_key_secret: SecretStr + jwt_secret: Optional[SecretStr] = None + + deployment_id: StrictStr + api_version: Optional[StrictStr] = None + headers: Mapping[StrictStr, StrictStr] + + @root_validator(pre=True) + def create_secrets(cls, values: dict): + if "api_key" in values: + if "api_key_secret" not in values: + values["api_key_secret"] = SecretStr(values.pop("api_key")) + else: + raise ValueError( + "api_key and api_key_secret cannot be both provided" + ) + + if "jwt" in values: + if "jwt_secret" not in values: + values["jwt_secret"] = SecretStr(values.pop("jwt")) + else: + raise ValueError("jwt and jwt_secret cannot be both provided") + + return values + + @property + def api_key(self) -> str: + return self.api_key_secret.get_secret_value() + + @property + def jwt(self) -> Optional[str]: + return self.jwt_secret.get_secret_value() if self.jwt_secret else None + + @property + def auth_headers(self) -> Dict[str, str]: + if self.jwt_secret is not None: + return get_auth_headers( + auth_type=AuthType.BEARER, + auth_value=self.jwt_secret.get_secret_value(), + ) + else: + return get_auth_headers( + auth_type=AuthType.API_KEY, + auth_value=self.api_key_secret.get_secret_value(), + ) + + +class ApplicationChatCompletionRequest(ChatCompletionRequest, RequestParams): + pass diff --git a/aidial_client/types/chat/legacy/chat_completion.py b/aidial_client/types/chat/legacy/chat_completion.py new file mode 100644 index 0000000..d91b7ec --- /dev/null +++ b/aidial_client/types/chat/legacy/chat_completion.py @@ -0,0 +1,168 @@ +from enum import Enum +from typing import Any, Dict, List, Literal, Mapping, Optional, Union + +from aidial_client._compatibility.pydantic_v1 import ( + ConstrainedFloat, + ConstrainedInt, + ConstrainedList, + PositiveInt, + StrictBool, + StrictInt, + StrictStr, +) +from aidial_client._internal_types._model import ExtraForbidModel + + +class FinishReason(str, Enum): + STOP = "stop" + LENGTH = "length" + FUNCTION_CALL = "function_call" + TOOL_CALLS = "tool_calls" + CONTENT_FILTER = "content_filter" + + +class Status(str, Enum): + COMPLETED = "completed" + FAILED = "failed" + + +class Attachment(ExtraForbidModel): + type: Optional[StrictStr] = "text/markdown" + title: Optional[StrictStr] = None + data: Optional[StrictStr] = None + url: Optional[StrictStr] = None + reference_type: Optional[StrictStr] = None + reference_url: Optional[StrictStr] = None + + +class Stage(ExtraForbidModel): + name: StrictStr + status: Status + content: Optional[StrictStr] = None + attachments: Optional[List[Attachment]] = None + + +class CustomContent(ExtraForbidModel): + stages: Optional[List[Stage]] = None + attachments: Optional[List[Attachment]] = None + state: Optional[Any] = None + + +class FunctionCall(ExtraForbidModel): + name: str + arguments: str + + +class ToolCall(ExtraForbidModel): + # OpenAI API doesn't strictly specify existence of the index field + index: Optional[int] + id: StrictStr + type: Literal["function"] + function: FunctionCall + + +class Role(str, Enum): + SYSTEM = "system" + USER = "user" + ASSISTANT = "assistant" + FUNCTION = "function" + TOOL = "tool" + + +class Message(ExtraForbidModel): + role: Role + content: Optional[StrictStr] = None + custom_content: Optional[CustomContent] = None + name: Optional[StrictStr] = None + tool_calls: Optional[List[ToolCall]] = None + tool_call_id: Optional[StrictStr] = None + function_call: Optional[FunctionCall] = None + + +class Addon(ExtraForbidModel): + name: Optional[StrictStr] = None + url: Optional[StrictStr] = None + + +class Function(ExtraForbidModel): + name: StrictStr + description: Optional[StrictStr] = None + parameters: Optional[Dict] = None + + +class Temperature(ConstrainedFloat): + ge = 0 + le = 2 + + +class TopP(ConstrainedFloat): + ge = 0 + le = 1 + + +class N(ConstrainedInt): + ge = 1 + le = 128 + + +class Stop(ConstrainedList): + max_items: int = 4 + __args__ = tuple([StrictStr]) + + +class Penalty(ConstrainedFloat): + ge = -2 + le = 2 + + +class Tool(ExtraForbidModel): + type: Literal["function"] + function: Function + + +class FunctionChoice(ExtraForbidModel): + name: StrictStr + + +class ToolChoice(ExtraForbidModel): + type: Literal["function"] + function: FunctionChoice + + +class ResponseFormat(ExtraForbidModel): + type: Literal["text", "json_object"] + + +class AzureChatCompletionRequest(ExtraForbidModel): + model: Optional[StrictStr] = None + messages: List[Message] + functions: Optional[List[Function]] = None + function_call: Optional[Union[Literal["auto", "none"], FunctionChoice]] = ( + None + ) + tools: Optional[List[Tool]] = None + tool_choice: Optional[Union[Literal["auto", "none"], ToolChoice]] = None + stream: bool = False + temperature: Optional[Temperature] = None + top_p: Optional[TopP] = None + n: Optional[N] = None + stop: Optional[Union[StrictStr, Stop]] = None + max_tokens: Optional[PositiveInt] = None + presence_penalty: Optional[Penalty] = None + frequency_penalty: Optional[Penalty] = None + logit_bias: Optional[Mapping[int, float]] = None + user: Optional[StrictStr] = None + seed: Optional[StrictInt] = None + logprobs: Optional[StrictBool] = None + top_logprobs: Optional[StrictInt] = None + response_format: Optional[ResponseFormat] = None + + +class ChatCompletionRequestCustomFields(ExtraForbidModel): + configuration: Optional[Dict[str, Any]] = None + + +class ChatCompletionRequest(AzureChatCompletionRequest): + addons: Optional[List[Addon]] = None + max_prompt_tokens: Optional[PositiveInt] = None + custom_fields: Optional[ChatCompletionRequestCustomFields] = None diff --git a/aidial_client/types/chat/request.py b/aidial_client/types/chat/request.py new file mode 100644 index 0000000..4820f88 --- /dev/null +++ b/aidial_client/types/chat/request.py @@ -0,0 +1,44 @@ +from typing import Any, Dict, List, Literal, Optional, Union + +from typing_extensions import TypedDict + +from aidial_client.types.chat.addon import Addon +from aidial_client.types.chat.function import ( + FunctionCallSpecParam, + FunctionParam, +) +from aidial_client.types.chat.request_param import Message, ResponseFormat +from aidial_client.types.chat.tool import ToolCallSpecParam, ToolParam + + +class ChatCompletionRequestCustomFields(TypedDict, total=False): + configuration: Optional[Dict[str, Any]] + + +class ChatCompletionRequest(TypedDict, total=False): + model: str + temperature: Optional[float] + top_p: Optional[float] + stream: Optional[bool] + stop: Optional[Union[str, List[str]]] + max_tokens: Optional[int] + presence_penalty: Optional[float] + frequency_penalty: Optional[float] + logit_bias: Optional[Dict] + user: Optional[str] + messages: List[Message] + data_sources: List[Any] + n: Optional[int] + seed: Optional[int] + logprobs: Optional[bool] + top_logprobs: Optional[float] + response_format: Optional[ResponseFormat] + tools: Optional[List[ToolParam]] + tool_choice: Optional[Union[Literal["none", "auto"], ToolCallSpecParam]] + functions: Optional[List[FunctionParam]] + function_call: Optional[ + Union[Literal["none", "auto"], FunctionCallSpecParam] + ] + addons: Optional[Addon] + max_prompt_tokens: Optional[Union[Literal["infinity"], int]] + custom_fields: Optional[ChatCompletionRequestCustomFields] diff --git a/aidial_client/types/chat/request_param.py b/aidial_client/types/chat/request_param.py new file mode 100644 index 0000000..11bbf0f --- /dev/null +++ b/aidial_client/types/chat/request_param.py @@ -0,0 +1,69 @@ +from typing import Dict, List, Literal, Optional, Union + +from typing_extensions import Required, TypedDict + +from aidial_client.types.chat.function import FunctionCallParam +from aidial_client.types.chat.tool import ToolCallParam + + +class ResponseFormat(TypedDict, total=False): + type: Literal["json_object", "text"] + + +class AttachmentParam(TypedDict, total=False): + type: str + title: str + data: str + url: str + reference_type: str + reference_url: str + + +class CustomContentParam(TypedDict, total=False): + attachments: Optional[List[AttachmentParam]] + state: Optional[Dict] + + +class SystemMessageParam(TypedDict, total=False): + role: Required[Literal["system"]] + content: Required[str] + custom_content: Optional[CustomContentParam] + name: Optional[str] + + +class UserMessageParam(TypedDict, total=False): + role: Required[Literal["user"]] + content: Required[str] + custom_content: Optional[CustomContentParam] + name: Optional[str] + + +class AssistantMessageParam(TypedDict, total=False): + role: Required[Literal["assistant"]] + content: Optional[str] + custom_content: Optional[CustomContentParam] + function_call: Optional[FunctionCallParam] + tool_calls: List[ToolCallParam] + name: Optional[str] + + +class ToolMessageParam(TypedDict, total=False): + role: Required[Literal["tool"]] + content: Required[str] + tool_call_id: Required[str] + + +class FunctionMessageParam(TypedDict, total=False): + role: Required[Literal["function"]] + content: Required[str] + """Name of function call""" + name: Required[str] + + +Message = Union[ + SystemMessageParam, + UserMessageParam, + AssistantMessageParam, + ToolMessageParam, + FunctionMessageParam, +] diff --git a/aidial_client/types/chat/response.py b/aidial_client/types/chat/response.py new file mode 100644 index 0000000..6192b00 --- /dev/null +++ b/aidial_client/types/chat/response.py @@ -0,0 +1,91 @@ +from typing import Dict, List, Literal, Optional, Union + +from aidial_client._compatibility.pydantic_v1 import root_validator +from aidial_client._internal_types._model import ExtraAllowModel + + +class Attachment(ExtraAllowModel): + type: Optional[str] = None + title: Optional[str] = None + data: Optional[str] = None + url: Optional[str] = None + reference_type: Optional[str] = None + reference_url: Optional[str] = None + + @root_validator(pre=True) + def validate_data_or_url(cls, values): + if "data" not in values and "url" not in values: + raise ValueError("Either data or url must be provided") + return values + + +class CustomContent(ExtraAllowModel): + attachments: Optional[List[Attachment]] = None + state: Optional[Dict] = None + + +class CompletionUsage(ExtraAllowModel): + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +class FunctionCall(ExtraAllowModel): + arguments: str + name: str + + +class ChatCompletionMessageToolCall(ExtraAllowModel): + id: str + function: FunctionCall + type: Literal["function"] + + +class ChatCompletionMessage(ExtraAllowModel): + role: Literal["assistant"] + content: Optional[str] = None + custom_content: Optional[CustomContent] = None + function_call: Optional[FunctionCall] = None + tool_calls: Optional[List[ChatCompletionMessageToolCall]] = None + + +class Choice(ExtraAllowModel): + index: int + message: ChatCompletionMessage + finish_reason: Optional[str] + + +class ChatCompletionResponse(ExtraAllowModel): + id: str + object: Literal["chat.completion"] + choices: List[Choice] + created: int + model: Optional[str] = None + usage: Optional[CompletionUsage] = None + + +class ChunkEmptyDelta(ExtraAllowModel): + """ + Sometimes delta could be just empty, or have just content + """ + + content: Optional[str] = None + object: Literal[None] = None + tool_calls: Literal[None] = None + role: Literal[None] = None + + +class ChoiceDelta(ExtraAllowModel): + index: int + delta: Union[ChatCompletionMessage, ChunkEmptyDelta] + finish_reason: Optional[str] = None + + +class ChatCompletionChunk(ExtraAllowModel): + id: str + object: Literal["chat.completion.chunk"] + choices: List[ChoiceDelta] + created: int + model: Optional[str] = None + usage: Optional[CompletionUsage] = None + error: Optional[Dict] = None diff --git a/aidial_client/types/chat/tool.py b/aidial_client/types/chat/tool.py new file mode 100644 index 0000000..f04a66f --- /dev/null +++ b/aidial_client/types/chat/tool.py @@ -0,0 +1,25 @@ +from typing import Literal + +from typing_extensions import Required, TypedDict + +from aidial_client.types.chat.function import ( + FunctionCallParam, + FunctionCallSpecParam, + FunctionParam, +) + + +class ToolParam(TypedDict): + type: Literal["function"] + function: FunctionParam + + +class ToolCallParam(TypedDict): + id: Required[str] + type: Required[Literal["function"]] + function: FunctionCallParam + + +class ToolCallSpecParam(TypedDict, total=False): + type: Required[Literal["function"]] + function: FunctionCallSpecParam diff --git a/aidial_client/types/deployment.py b/aidial_client/types/deployment.py new file mode 100644 index 0000000..b625a76 --- /dev/null +++ b/aidial_client/types/deployment.py @@ -0,0 +1,23 @@ +from typing import List, Literal, Optional + +from aidial_client._internal_types._model import ExtraAllowModel + + +class ScaleSettings(ExtraAllowModel): + scale_type: Literal["standard"] + + +class Deployment(ExtraAllowModel): + id: str + model: str + owner: str + object: Literal["deployment"] + status: Literal["succeeded"] + created_at: int + updated_at: int + scale_settings: Optional[ScaleSettings] = None + + +class DeploymentsResponse(ExtraAllowModel): + data: List[Deployment] + object: Literal["list"] diff --git a/aidial_client/types/file.py b/aidial_client/types/file.py new file mode 100644 index 0000000..ad7941c --- /dev/null +++ b/aidial_client/types/file.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Union + +import aiofiles +import httpx + + +class FileDownloadResponse: + + def __init__(self, response: httpx.Response, filename: str): + self._response = response + self._filename = filename + + def write_to(self, file: Union[str, Path]) -> None: + """ + Write the content to a file + """ + with open(file, "wb") as f: + for chunk in self._response.iter_bytes(): + f.write(chunk) + + async def awrite_to(self, file: Union[str, Path]) -> None: + """ + Async write content to a file + """ + + async with aiofiles.open(file, "wb") as f: + async for chunk in self._response.aiter_bytes(): + await f.write(chunk) + + def __aiter__(self): + return self._response.aiter_bytes() + + def __iter__(self): + return self._response.iter_bytes() + + def get_content(self) -> bytes: + return self._response.read() + + async def aget_content(self) -> bytes: + return await self._response.aread() + + @property + def filename(self) -> str: + return self._filename diff --git a/aidial_client/types/metadata.py b/aidial_client/types/metadata.py new file mode 100644 index 0000000..4a14820 --- /dev/null +++ b/aidial_client/types/metadata.py @@ -0,0 +1,56 @@ +from typing import List, Literal, Optional + +from aidial_client._internal_types._model import ExtraAllowModel +from aidial_client._utils._alias import to_camel + + +class BaseMetadata(ExtraAllowModel): + class Config: + alias_generator = to_camel + allow_population_by_field_name = True + + name: str + parent_path: Optional[str] = None + bucket: str + url: str + node_type: Literal["FOLDER", "ITEM"] + resource_type: Literal["FILE", "CONVERSATION", "PROMPT"] + + +class FileItem(BaseMetadata): + node_type: Literal["FOLDER", "ITEM"] + resource_type: Literal["FILE"] + content_length: Optional[int] = None + content_type: Optional[str] = None + + +class FileMetadata(BaseMetadata): + node_type: Literal["FOLDER", "ITEM"] + resource_type: Literal["FILE"] + content_length: Optional[int] = None + content_type: Optional[str] = None + items: Optional[List[FileItem]] = None + + +class ConversationItem(BaseMetadata): + updated_at: int + resource_type: Literal["CONVERSATION"] + + +class ConversationMetadata(BaseMetadata): + content_length: Optional[int] = None + next_token: Optional[str] = None + items: Optional[List[ConversationItem]] + resource_type: Literal["CONVERSATION"] + + +class PromptItem(BaseMetadata): + updated_at: int + resource_type: Literal["PROMPT"] + + +class PromptMetadata(BaseMetadata): + content_length: Optional[int] = None + next_token: Optional[str] = None + items: Optional[List[PromptItem]] + resource_type: Literal["PROMPT"] diff --git a/noxfile.py b/noxfile.py index fa090b5..c237b08 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,19 +15,53 @@ def format_with_args(session: nox.Session, *args): def lint(session: nox.Session): """Runs linters and fixers""" try: - session.run("poetry", "install", "--all-extras", external=True) + session.run("poetry", "install", external=True) session.run("poetry", "check", "--lock", external=True) session.run("pyright", SRC) session.run("flake8", SRC) + session.run("codespell", SRC) format_with_args(session, SRC, "--check") except Exception: session.error( - "linting has failed. Run 'make format' to fix formatting and fix other errors manually" + "linting has failed. Run 'make format' " + "to fix formatting and fix other errors manually" ) +@nox.session +def coverage(session: nox.Session) -> None: + """Run tests and generate coverage report""" + session.run("poetry", "install", external=True) + session.run("pytest", "--cov=.", "--cov-report=xml", "--cov-report=term") + session.run("coverage", "html") + + @nox.session def format(session: nox.Session): """Runs linters and fixers""" session.run("poetry", "install", external=True) format_with_args(session, SRC) + + +@nox.session(python=["3.8", "3.9", "3.10", "3.11", "3.12"]) +@nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) +@nox.parametrize("httpx", ["0.25.0", "0.27.0"]) +@nox.parametrize("openai", ["1.0.0", "1.51.0"]) +def test(session: nox.Session, pydantic: str, httpx: str, openai: str) -> None: + """Runs tests""" + session.run("poetry", "install", external=True) + session.install(f"pydantic=={pydantic}") + session.install(f"httpx=={httpx}") + session.install(f"openai=={openai}") + session.run("pytest", "tests", "--ignore=tests/integration") + + +@nox.session(python=["3.11"]) +@nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) +@nox.parametrize("openai", ["1.0.0", "1.51.0"]) +def integration_test(session: nox.Session, pydantic: str, openai: str) -> None: + """Run integration tests""" + session.run("poetry", "install", external=True) + session.install(f"pydantic=={pydantic}") + session.install(f"openai=={openai}") + session.run("pytest", "tests/integration") diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..bb2f8f9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,932 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} + +[[package]] +name = "anyio" +version = "4.5.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.5.0-py3-none-any.whl", hash = "sha256:fdeb095b7cc5a5563175eedd926ec4ae55413bb4be5770c424af0ba46ccb4a78"}, + {file = "anyio-4.5.0.tar.gz", hash = "sha256:c5a275fe5ca0afd788001f58fca1e69e29ce706d746e317d660e21f70c530ef9"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] +trio = ["trio (>=0.26.1)"] + +[[package]] +name = "argcomplete" +version = "3.5.1" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +files = [ + {file = "argcomplete-3.5.1-py3-none-any.whl", hash = "sha256:1a1d148bdaa3e3b93454900163403df41448a248af01b6e849edc5ac08e6c363"}, + {file = "argcomplete-3.5.1.tar.gz", hash = "sha256:eb1ee355aa2557bd3d0145de7b06b2a45b0ce461e1e7813f5d066039ab4177b4"}, +] + +[package.extras] +test = ["coverage", "mypy", "pexpect", "ruff", "wheel"] + +[[package]] +name = "autoflake" +version = "2.3.1" +description = "Removes unused imports and unused variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "autoflake-2.3.1-py3-none-any.whl", hash = "sha256:3ae7495db9084b7b32818b4140e6dc4fc280b712fb414f5b8fe57b0a8e85a840"}, + {file = "autoflake-2.3.1.tar.gz", hash = "sha256:c98b75dc5b0a86459c4f01a1d32ac7eb4338ec4317a4469515ff1e687ecd909e"}, +] + +[package.dependencies] +pyflakes = ">=3.0.0" +tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} + +[[package]] +name = "black" +version = "24.8.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, + {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, + {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, + {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, + {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, + {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, + {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, + {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, + {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, + {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, + {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, + {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, + {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, + {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, + {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, + {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, + {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, + {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, + {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, + {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, + {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, + {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "codespell" +version = "2.3.0" +description = "Codespell" +optional = false +python-versions = ">=3.8" +files = [ + {file = "codespell-2.3.0-py3-none-any.whl", hash = "sha256:a9c7cef2501c9cfede2110fd6d4e5e62296920efe9abfb84648df866e47f58d1"}, + {file = "codespell-2.3.0.tar.gz", hash = "sha256:360c7d10f75e65f67bad720af7007e1060a5d395670ec11a7ed1fed9dd17471f"}, +] + +[package.extras] +dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"] +hard-encoding-detection = ["chardet"] +toml = ["tomli"] +types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "colorlog" +version = "6.8.2" +description = "Add colours to the output of Python's logging module." +optional = false +python-versions = ">=3.6" +files = [ + {file = "colorlog-6.8.2-py3-none-any.whl", hash = "sha256:4dcbb62368e2800cb3c5abd348da7e53f6c362dda502ec27c560b2e58a66bd33"}, + {file = "colorlog-6.8.2.tar.gz", hash = "sha256:3e3e079a41feb5a1b64f978b5ea4f46040a94f11f0e8bbb8261e3dbbeca64d44"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +development = ["black", "flake8", "mypy", "pytest", "types-colorama"] + +[[package]] +name = "coverage" +version = "7.6.1" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16"}, + {file = "coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc"}, + {file = "coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c"}, + {file = "coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959"}, + {file = "coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232"}, + {file = "coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93"}, + {file = "coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d"}, + {file = "coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234"}, + {file = "coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133"}, + {file = "coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c"}, + {file = "coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778"}, + {file = "coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d"}, + {file = "coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a"}, + {file = "coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d"}, + {file = "coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5"}, + {file = "coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106"}, + {file = "coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a"}, + {file = "coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388"}, + {file = "coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155"}, + {file = "coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a"}, + {file = "coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e"}, + {file = "coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704"}, + {file = "coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223"}, + {file = "coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3"}, + {file = "coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f"}, + {file = "coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0"}, + {file = "coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3"}, + {file = "coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569"}, + {file = "coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989"}, + {file = "coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7"}, + {file = "coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255"}, + {file = "coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a"}, + {file = "coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb"}, + {file = "coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36"}, + {file = "coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c"}, + {file = "coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca"}, + {file = "coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df"}, + {file = "coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "distro" +version = "1.9.0" +description = "Distro - an OS platform information API" +optional = false +python-versions = ">=3.6" +files = [ + {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, + {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.2.2" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, + {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.16.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] + +[[package]] +name = "flake8" +version = "6.1.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-6.1.0-py2.py3-none-any.whl", hash = "sha256:ffdfce58ea94c6580c77888a86506937f9a1a227dfcd15f245d694ae20a6b6e5"}, + {file = "flake8-6.1.0.tar.gz", hash = "sha256:d5b3857f07c030bdb5bf41c7f53799571d75c4491748a3adcd47de929e34cd23"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.1.0,<3.2.0" + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, + {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "jiter" +version = "0.6.1" +description = "Fast iterable JSON parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "jiter-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d08510593cb57296851080018006dfc394070178d238b767b1879dc1013b106c"}, + {file = "jiter-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adef59d5e2394ebbad13b7ed5e0306cceb1df92e2de688824232a91588e77aa7"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3e02f7a27f2bcc15b7d455c9df05df8ffffcc596a2a541eeda9a3110326e7a3"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed69a7971d67b08f152c17c638f0e8c2aa207e9dd3a5fcd3cba294d39b5a8d2d"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2019d966e98f7c6df24b3b8363998575f47d26471bfb14aade37630fae836a1"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36c0b51a285b68311e207a76c385650322734c8717d16c2eb8af75c9d69506e7"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:220e0963b4fb507c525c8f58cde3da6b1be0bfddb7ffd6798fb8f2531226cdb1"}, + {file = "jiter-0.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa25c7a9bf7875a141182b9c95aed487add635da01942ef7ca726e42a0c09058"}, + {file = "jiter-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e90552109ca8ccd07f47ca99c8a1509ced93920d271bb81780a973279974c5ab"}, + {file = "jiter-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:67723a011964971864e0b484b0ecfee6a14de1533cff7ffd71189e92103b38a8"}, + {file = "jiter-0.6.1-cp310-none-win32.whl", hash = "sha256:33af2b7d2bf310fdfec2da0177eab2fedab8679d1538d5b86a633ebfbbac4edd"}, + {file = "jiter-0.6.1-cp310-none-win_amd64.whl", hash = "sha256:7cea41c4c673353799906d940eee8f2d8fd1d9561d734aa921ae0f75cb9732f4"}, + {file = "jiter-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b03c24e7da7e75b170c7b2b172d9c5e463aa4b5c95696a368d52c295b3f6847f"}, + {file = "jiter-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47fee1be677b25d0ef79d687e238dc6ac91a8e553e1a68d0839f38c69e0ee491"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f0d2f6e01a8a0fb0eab6d0e469058dab2be46ff3139ed2d1543475b5a1d8e7"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b809e39e342c346df454b29bfcc7bca3d957f5d7b60e33dae42b0e5ec13e027"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e9ac7c2f092f231f5620bef23ce2e530bd218fc046098747cc390b21b8738a7a"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e51a2d80d5fe0ffb10ed2c82b6004458be4a3f2b9c7d09ed85baa2fbf033f54b"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3343d4706a2b7140e8bd49b6c8b0a82abf9194b3f0f5925a78fc69359f8fc33c"}, + {file = "jiter-0.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82521000d18c71e41c96960cb36e915a357bc83d63a8bed63154b89d95d05ad1"}, + {file = "jiter-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c843e7c1633470708a3987e8ce617ee2979ee18542d6eb25ae92861af3f1d62"}, + {file = "jiter-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a2e861658c3fe849efc39b06ebb98d042e4a4c51a8d7d1c3ddc3b1ea091d0784"}, + {file = "jiter-0.6.1-cp311-none-win32.whl", hash = "sha256:7d72fc86474862c9c6d1f87b921b70c362f2b7e8b2e3c798bb7d58e419a6bc0f"}, + {file = "jiter-0.6.1-cp311-none-win_amd64.whl", hash = "sha256:3e36a320634f33a07794bb15b8da995dccb94f944d298c8cfe2bd99b1b8a574a"}, + {file = "jiter-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1fad93654d5a7dcce0809aff66e883c98e2618b86656aeb2129db2cd6f26f867"}, + {file = "jiter-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4e6e340e8cd92edab7f6a3a904dbbc8137e7f4b347c49a27da9814015cc0420c"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:691352e5653af84ed71763c3c427cff05e4d658c508172e01e9c956dfe004aba"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:defee3949313c1f5b55e18be45089970cdb936eb2a0063f5020c4185db1b63c9"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26d2bdd5da097e624081c6b5d416d3ee73e5b13f1703bcdadbb1881f0caa1933"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18aa9d1626b61c0734b973ed7088f8a3d690d0b7f5384a5270cd04f4d9f26c86"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a3567c8228afa5ddcce950631c6b17397ed178003dc9ee7e567c4c4dcae9fa0"}, + {file = "jiter-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5c0507131c922defe3f04c527d6838932fcdfd69facebafd7d3574fa3395314"}, + {file = "jiter-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:540fcb224d7dc1bcf82f90f2ffb652df96f2851c031adca3c8741cb91877143b"}, + {file = "jiter-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e7b75436d4fa2032b2530ad989e4cb0ca74c655975e3ff49f91a1a3d7f4e1df2"}, + {file = "jiter-0.6.1-cp312-none-win32.whl", hash = "sha256:883d2ced7c21bf06874fdeecab15014c1c6d82216765ca6deef08e335fa719e0"}, + {file = "jiter-0.6.1-cp312-none-win_amd64.whl", hash = "sha256:91e63273563401aadc6c52cca64a7921c50b29372441adc104127b910e98a5b6"}, + {file = "jiter-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:852508a54fe3228432e56019da8b69208ea622a3069458252f725d634e955b31"}, + {file = "jiter-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f491cc69ff44e5a1e8bc6bf2b94c1f98d179e1aaf4a554493c171a5b2316b701"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc56c8f0b2a28ad4d8047f3ae62d25d0e9ae01b99940ec0283263a04724de1f3"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:51b58f7a0d9e084a43b28b23da2b09fc5e8df6aa2b6a27de43f991293cab85fd"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f79ce15099154c90ef900d69c6b4c686b64dfe23b0114e0971f2fecd306ec6c"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03a025b52009f47e53ea619175d17e4ded7c035c6fbd44935cb3ada11e1fd592"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74a8d93718137c021d9295248a87c2f9fdc0dcafead12d2930bc459ad40f885"}, + {file = "jiter-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40b03b75f903975f68199fc4ec73d546150919cb7e534f3b51e727c4d6ccca5a"}, + {file = "jiter-0.6.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:825651a3f04cf92a661d22cad61fc913400e33aa89b3e3ad9a6aa9dc8a1f5a71"}, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "nox" +version = "2024.4.15" +description = "Flexible test automation." +optional = false +python-versions = ">=3.7" +files = [ + {file = "nox-2024.4.15-py3-none-any.whl", hash = "sha256:6492236efa15a460ecb98e7b67562a28b70da006ab0be164e8821177577c0565"}, + {file = "nox-2024.4.15.tar.gz", hash = "sha256:ecf6700199cdfa9e5ea0a41ff5e6ef4641d09508eda6edb89d9987864115817f"}, +] + +[package.dependencies] +argcomplete = ">=1.9.4,<4.0" +colorlog = ">=2.6.1,<7.0.0" +packaging = ">=20.9" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} +virtualenv = ">=20.14.1" + +[package.extras] +tox-to-nox = ["jinja2", "tox"] +uv = ["uv (>=0.1.6)"] + +[[package]] +name = "openai" +version = "1.51.1" +description = "The official Python library for the openai API" +optional = false +python-versions = ">=3.7.1" +files = [ + {file = "openai-1.51.1-py3-none-any.whl", hash = "sha256:035ba637bef7523282b5b8d9f2f5fdc0bb5bc18d52af2bfc7f64e4a7b0a169fb"}, + {file = "openai-1.51.1.tar.gz", hash = "sha256:a4908d68e0a1f4bcb45cbaf273c5fbdc3a4fa6239bb75128b58b94f7d5411563"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +jiter = ">=0.4.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +tqdm = ">4" +typing-extensions = ">=4.11,<5" + +[package.extras] +datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] + +[[package]] +name = "packaging" +version = "24.1" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, + {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.23.4" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata"] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, + {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, + {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, + {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, + {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, + {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, + {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, + {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, + {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, + {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, + {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, + {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, + {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, + {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, + {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, + {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, + {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, + {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, + {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, + {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, + {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, + {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, + {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, + {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, + {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, + {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, + {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, + {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, + {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, + {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, + {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, + {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, + {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, + {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pyflakes" +version = "3.1.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.1.0-py2.py3-none-any.whl", hash = "sha256:4132f6d49cb4dae6819e5379898f2b8cce3c5f23994194c24b77d5da2e36f774"}, + {file = "pyflakes-3.1.0.tar.gz", hash = "sha256:a0aae034c444db0071aa077972ba4768d40c830d9539fd45bf4cd3f8f6992efc"}, +] + +[[package]] +name = "pyright" +version = "1.1.383" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.383-py3-none-any.whl", hash = "sha256:d864d1182a313f45aaf99e9bfc7d2668eeabc99b29a556b5344894fd73cb1959"}, + {file = "pyright-1.1.383.tar.gz", hash = "sha256:1df7f12407f3710c9c6df938d98ec53f70053e6c6bbf71ce7bcb038d42f10070"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + +[[package]] +name = "pytest" +version = "7.4.4" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b"}, + {file = "pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45"}, +] + +[package.dependencies] +pytest = ">=7.0.0" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] + +[[package]] +name = "pytest-cov" +version = "5.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"}, + {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"}, +] + +[package.dependencies] +coverage = {version = ">=5.2.1", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "tomli" +version = "2.0.2" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, + {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, +] + +[[package]] +name = "tqdm" +version = "4.66.5" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, + {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "virtualenv" +version = "20.26.6" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, + {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8.1,<4.0" +content-hash = "260fa6f956b76b58e9270a5837b1d032cfba70a2c2db61964811062c645f3092" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ce023e6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,92 @@ +[tool.poetry] +name = "aidial-client" +version = "0.0.1" +description = "A Python client library for the AI DIAL API" +authors = ["EPAM RAIL "] +homepage = "https://epam-rail.com" +license = "Apache-2.0" +readme = "README.md" +repository = "https://github.com/epam/ai-dial-client" +packages = [{ include = "aidial_client" }] + +[tool.poetry.dependencies] +openai = ">=1.0.0,<2.0.0" +python = ">=3.8.1,<4.0" +httpx = ">=0.25.0,<1.0" +pydantic = ">=1.10,<3" +aiofiles = "24.1.0" + +[tool.setuptools] +packages = ["aidial_client"] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry.group.test.dependencies] +pytest = "^7.4.2" +pytest-asyncio = "^0.21.0" +nox = "^2024.4.15" +pytest-cov = "^5.0" +coverage = "^7.6" + +[tool.poetry.group.lint.dependencies] +flake8 = "^6.0.0" +black = ">=23.3,<25.0" +isort = "^5.12.0" +pyright = "^1.1.324" +autoflake = "^2.2.0" +codespell = "^2.3.0" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.pyright] +typeCheckingMode = "basic" +reportUnusedVariable = "error" +reportIncompatibleMethodOverride = "error" +exclude = [ + ".git", + "**/.venv", + ".nox", + ".pytest_cache", + "**/__pycache__", + "build", + 'examples', +] + +[tool.black] +line-length = 80 +exclude = ''' +/( + \.git + | \.venv + | \.nox + | \.pytest_cache + | \.__pycache__ + | examples +)/ +''' + +[tool.isort] +line_length = 80 +profile = "black" + +[tool.autoflake] +ignore_init_module_imports = true +remove_all_unused_imports = true +in_place = true +recursive = true +quiet = true +exclude = [".nox", ".pytest_cache", "\\.venv", 'examples'] + +[tool.coverage.run] +source = ["."] +omit = [ + "tests/*", + "*/__init__.py", + "noxfile.py", + 'aidial_client/_compatibility/pydantic_v1.py', + 'htmlcov/*', + 'examples/*', +] diff --git a/tests/client_mock.py b/tests/client_mock.py new file mode 100644 index 0000000..65ffb09 --- /dev/null +++ b/tests/client_mock.py @@ -0,0 +1,96 @@ +from typing import Any, AsyncIterator, Dict, Iterator, List, Optional + +import httpx +from httpx._content import AsyncIteratorByteStream, IteratorByteStream + +from aidial_client import Dial +from aidial_client._client import AsyncDial + + +class MockStreamIterator(IteratorByteStream, AsyncIteratorByteStream): + def __init__(self, mock_chunks: List[bytes]): + class Stream: + def __iter__(self) -> Iterator[bytes]: + for chunk in mock_chunks: + yield chunk + + async def __aiter__(self) -> AsyncIterator[bytes]: + for chunk in mock_chunks: + yield chunk + + AsyncIteratorByteStream.__init__(self, stream=Stream()) + IteratorByteStream.__init__(self, stream=Stream()) + + +def get_client_mock( + status_code: Optional[int], + json_mock: Optional[Dict[str, Any]] = None, + stream_chunks_mock: Optional[List[bytes]] = None, + exception_mock: Optional[Exception] = None, +) -> Dial: + client_mock = Dial( + api_key="dummy", + base_url="http://dial.core", + ) + + def send_mock(request: httpx.Request, **kwargs): + if json_mock is not None: + assert status_code + mock_response = httpx.Response( + status_code=status_code, request=request, json=json_mock + ) + mock_response.request = request + return mock_response + elif stream_chunks_mock is not None: + assert status_code + mock_response = httpx.Response( + status_code=status_code, + request=request, + stream=MockStreamIterator(mock_chunks=stream_chunks_mock), + ) + mock_response.request = request + return mock_response + elif exception_mock is not None: + raise exception_mock + else: + raise NotImplementedError() + + client_mock._http_client._client.send = send_mock + return client_mock + + +def get_async_client_mock( + status_code: Optional[int], + json_mock: Optional[Dict[str, Any]] = None, + stream_chunks_mock: Optional[List[bytes]] = None, + exception_mock: Optional[Exception] = None, +) -> AsyncDial: + client_mock = AsyncDial( + api_key="dummy", + base_url="http://dial.core", + ) + + async def send_mock(request: httpx.Request, **kwargs): + if json_mock is not None: + assert status_code + mock_response = httpx.Response( + status_code=status_code, request=request, json=json_mock + ) + mock_response.request = request + return mock_response + elif stream_chunks_mock is not None: + assert status_code + mock_response = httpx.Response( + status_code=status_code, + request=request, + stream=MockStreamIterator(mock_chunks=stream_chunks_mock), + ) + mock_response.request = request + return mock_response + elif exception_mock is not None: + raise exception_mock + else: + raise NotImplementedError() + + client_mock._http_client._client.send = send_mock + return client_mock diff --git a/tests/helpers/test_storage_resource_mixin.py b/tests/helpers/test_storage_resource_mixin.py new file mode 100644 index 0000000..d0fcfdc --- /dev/null +++ b/tests/helpers/test_storage_resource_mixin.py @@ -0,0 +1,176 @@ +import pytest + +from aidial_client._exception import InvalidDialURLException +from aidial_client.helpers.storage_resource import DialStorageResourceMixin + +DIAL_API_URL = "https://dial.core/v1/" +RESOURCE_TYPES = ["files", "conversations", "prompts"] + + +@pytest.mark.parametrize( + "resource_type, url, expected", + [ + ("files", "files/bucket/file.txt", "files/bucket/file.txt"), + ( + "conversations", + "conversations/bucket/conv.json", + "conversations/bucket/conv.json", + ), + ("prompts", "prompts/bucket/prompt.txt", "prompts/bucket/prompt.txt"), + ( + "files", + "files/bucket/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6%E5%A4%B9/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6%20%281%29.pdf", # noqa # noqa + "files/bucket/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6%E5%A4%B9/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6%20%281%29.pdf", # noqa + ), + ], +) +def test_get_api_path_relative_url(resource_type, url, expected): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + result = mixin.get_api_path(url) + assert result == expected + + +@pytest.mark.parametrize( + "resource_type, url, expected", + [ + ( + "files", + f"{DIAL_API_URL}files/bucket/file.txt", + "files/bucket/file.txt", + ), + ( + "conversations", + f"{DIAL_API_URL}conversations/bucket/conv.json", + "conversations/bucket/conv.json", + ), + ( + "prompts", + f"{DIAL_API_URL}prompts/bucket/prompt.txt", + "prompts/bucket/prompt.txt", + ), + ], +) +def test_get_api_path_absolute_url(resource_type, url, expected): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + result = mixin.get_api_path(url) + assert result == expected + + +@pytest.mark.parametrize( + "resource_type, url, expected", + [ + ("files", "/v1/files/bucket/file.txt", "files/bucket/file.txt"), + ( + "conversations", + "/v1/conversations/bucket/conv.json", + "conversations/bucket/conv.json", + ), + ( + "prompts", + "/v1/prompts/bucket/prompt.txt", + "prompts/bucket/prompt.txt", + ), + ], +) +def test_get_api_path_with_api_prefix(resource_type, url, expected): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + result = mixin.get_api_path(url) + assert result == expected + + +@pytest.mark.parametrize("resource_type", RESOURCE_TYPES) +def test_get_api_path_invalid_dial_url(resource_type): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + url = "https://other-dial.core/v1/files/bucket/file.txt" + with pytest.raises( + InvalidDialURLException, match="Provided url is not DIAL url" + ): + mixin.get_api_path(url) + + +@pytest.mark.parametrize( + "resource_type, url", + [ + ("files", "https://dial.core/v1/conversations/bucket/conv.json"), + ( + "conversations", + "https://dial.core/v1/prompts/bucket/prompt.txt", + ), + ("prompts", "https://dial.core/v1/files/bucket/file.txt"), + ], +) +def test_get_api_path_invalid_resource_type(resource_type, url): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + with pytest.raises( + InvalidDialURLException, match="Invalid resource type for url" + ): + mixin.get_api_path(url) + + +@pytest.mark.parametrize( + "resource_type, url", + [ + ("files", "https://dial.core/v1/files/file.txt"), + ("conversations", "https://dial.core/v1/conversations/conv.json"), + ("prompts", "https://dial.core/v1/prompts/prompt.txt"), + ], +) +def test_get_api_path_missing_bucket(resource_type, url): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + with pytest.raises(InvalidDialURLException, match="Missing bucket in url"): + mixin.get_api_path(url) + + +@pytest.mark.parametrize("resource_type", RESOURCE_TYPES) +def test_get_api_path_invalid_path(resource_type): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + url = "https://dial.core/v2/files/bucket/file.txt" + with pytest.raises( + InvalidDialURLException, + match="Provided url path .* does not match with DIAL API url", + ): + mixin.get_api_path( + url, + ) + + +@pytest.mark.parametrize( + "resource_type, url, expected", + [ + ( + "files", + f"{DIAL_API_URL}files/bucket/file.txt?param=value", + "files/bucket/file.txt", + ), + ( + "conversations", + f"{DIAL_API_URL}conversations/bucket/conv.json?param=value", + "conversations/bucket/conv.json", + ), + ( + "prompts", + f"{DIAL_API_URL}prompts/bucket/prompt.txt?param=value", + "prompts/bucket/prompt.txt", + ), + ], +) +def test_get_api_path_with_query_params(resource_type, url, expected): + mixin = DialStorageResourceMixin( + resource_type=resource_type, dial_api_url=DIAL_API_URL + ) + result = mixin.get_api_path(url) + assert result == expected diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py new file mode 100644 index 0000000..7e28e7d --- /dev/null +++ b/tests/helpers/test_storage_resource_parser.py @@ -0,0 +1,111 @@ +import pytest + +from aidial_client._exception import InvalidDialURLException +from aidial_client.helpers.storage_resource import parse_storage_resource + + +@pytest.mark.parametrize( + "url, dial_api_url, resource_type, expected", + [ + ( + "files/my-bucket/my-folder/my-file.txt", + "https://dial.core/v1/", + "files", + { + "resource_type": "files", + "bucket": "my-bucket", + "absolute_url": "https://dial.core/v1/files/my-bucket/my-folder/my-file.txt", # noqa: E501 + "relative_url": "/v1/files/my-bucket/my-folder/my-file.txt", + "api_path": "files/my-bucket/my-folder/my-file.txt", + "bucket_path": "my-folder/my-file.txt", + "filename": "my-file.txt", + }, + ), + ( + "/v1/conversations/my-bucket/conversation-123", + "https://dial.core/v1/", + "conversations", + { + "resource_type": "conversations", + "bucket": "my-bucket", + "absolute_url": "https://dial.core/v1/conversations/my-bucket/conversation-123", # noqa: E501 + "relative_url": "/v1/conversations/my-bucket/conversation-123", + "api_path": "conversations/my-bucket/conversation-123", + "bucket_path": "conversation-123", + "filename": "conversation-123", + }, + ), + ( + "prompts/my-bucket/prompt-456.txt", + "https://dial.core/v1/", + "prompts", + { + "resource_type": "prompts", + "bucket": "my-bucket", + "absolute_url": "https://dial.core/v1/prompts/my-bucket/prompt-456.txt", # noqa: E501 + "relative_url": "/v1/prompts/my-bucket/prompt-456.txt", + "api_path": "prompts/my-bucket/prompt-456.txt", + "bucket_path": "prompt-456.txt", + "filename": "prompt-456.txt", + }, + ), + ], +) +def test_parse_storage_resource_valid( + url, dial_api_url, resource_type, expected +): + result = parse_storage_resource( + url, dial_api_url, resource_type, ignore_non_dial_url=False + ) + assert result.dict() == expected + + +@pytest.mark.parametrize( + "url, dial_api_url, resource_type", + [ + ( + "https://example.com/files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ), + ( + "files/my-bucket/file.txt", + "https://dial.core/v1/", + "conversations", + ), + ( + "files/file.txt", + "https://dial.core/v1/", + "files", + ), + ( + "v2/files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ), + ], +) +def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): + with pytest.raises(InvalidDialURLException): + parse_storage_resource( + url, dial_api_url, resource_type, ignore_non_dial_url=False + ) + + +def test_parse_storage_resource_non_dial_ignore(): + with pytest.raises(InvalidDialURLException): + parse_storage_resource( + "https://example.com/files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ignore_non_dial_url=False, + ) + assert ( + parse_storage_resource( + "https://example.com/files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ignore_non_dial_url=True, + ) + is None + ) diff --git a/tests/helpers/test_url_helpers.py b/tests/helpers/test_url_helpers.py new file mode 100644 index 0000000..3abbc39 --- /dev/null +++ b/tests/helpers/test_url_helpers.py @@ -0,0 +1,33 @@ +import pytest + +from aidial_client.helpers.url import ( + enforce_trailing_slash, + remove_leading_slash, +) + + +@pytest.mark.parametrize( + "input_url, expected_output", + [ + ("https://dial.core", "https://dial.core/"), + ("https://dial.core/", "https://dial.core/"), + ("https://dial.core/path", "https://dial.core/path/"), + ("https://dial.core/path/", "https://dial.core/path/"), + ], +) +def test_enforce_trailing_slash(input_url, expected_output): + assert enforce_trailing_slash(input_url) == expected_output + + +@pytest.mark.parametrize( + "input_url, expected_output", + [ + ("///path/to/resource", "path/to/resource"), + ("path/to/resource", "path/to/resource"), + ("//path", "path"), + ("https://dial.core/path", "https://dial.core/path"), + ("/https://dial.core/path", "https://dial.core/path"), + ], +) +def test_remove_leading_slash(input_url, expected_output): + assert remove_leading_slash(input_url) == expected_output diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/fixtures.py b/tests/integration/fixtures.py new file mode 100644 index 0000000..d39c319 --- /dev/null +++ b/tests/integration/fixtures.py @@ -0,0 +1,38 @@ +import os + +import pytest + +from aidial_client import AsyncDial, Dial + + +@pytest.fixture +def dial_url() -> str: + url = os.getenv("DIAL_URL") + assert url + return url + + +@pytest.fixture +def dial_api_key() -> str: + api_key = os.getenv("DIAL_API_KEY") + assert api_key + return api_key + + +@pytest.fixture +def sync_client(dial_url, dial_api_key): + return Dial(base_url=dial_url, api_key=dial_api_key) + + +@pytest.fixture +def async_client(dial_url, dial_api_key): + return AsyncDial(base_url=dial_url, api_key=dial_api_key) + + +@pytest.fixture +def test_deployment(sync_client: Dial) -> str: + deployments = sync_client.deployments.get() + assert len(deployments) + deployment = next((d for d in deployments if d.id.startswith("gpt-"))) + assert deployment + return deployment.id diff --git a/tests/integration/test_async_completions.py b/tests/integration/test_async_completions.py new file mode 100644 index 0000000..82f87fa --- /dev/null +++ b/tests/integration/test_async_completions.py @@ -0,0 +1,128 @@ +import pytest + +from aidial_client import AsyncDial +from aidial_client._exception import DialException +from tests.integration.fixtures import * # noqa + + +@pytest.mark.asyncio +async def test_async_default_api_version( + async_client: AsyncDial, + dial_url: str, + dial_api_key: str, + test_deployment: str, +): + with pytest.raises(DialException): + await async_client.chat.completions.create( + deployment_name="gpt-35-turbo", + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + ) + client_with_default_api_version = AsyncDial( + base_url=dial_url, + api_key=dial_api_key, + api_version="2024-02-15-preview", + ) + await client_with_default_api_version.chat.completions.create( + deployment_name=test_deployment, + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + ) + + +@pytest.mark.asyncio +async def test_completions_without_streaming( + async_client: AsyncDial, test_deployment: str +): + completion = await async_client.chat.completions.create( + deployment_name=test_deployment, + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + api_version="2024-02-15-preview", + ) + assert completion.choices + assert completion.choices[0].message + assert completion.choices[0].message.content + assert "5" in completion.choices[0].message.content + assert completion.usage + assert completion.usage.completion_tokens + assert completion.usage.prompt_tokens + assert completion.usage.total_tokens + assert ( + completion.usage.completion_tokens + completion.usage.prompt_tokens + == completion.usage.total_tokens + ) + + +@pytest.mark.asyncio +async def test_completions_with_streaming(async_client: AsyncDial): + deployments = await async_client.deployments.get() + assert len(deployments) + deployment = next((d for d in deployments if d.id.startswith("gpt-"))) + assert deployment + completion = await async_client.chat.completions.create( + deployment_name=deployment.id, + stream=True, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + api_version="2024-02-15-preview", + ) + total_content = "" + async for chunk in completion: + assert chunk.choices + assert chunk.choices[0].delta + if chunk.choices[0].delta.content: + total_content += chunk.choices[0].delta.content + + assert "5" in total_content + + last_chunk = chunk + assert last_chunk.choices[0].finish_reason + assert last_chunk.usage + assert last_chunk.usage.total_tokens + assert last_chunk.usage.prompt_tokens + assert last_chunk.usage.completion_tokens + assert ( + last_chunk.usage.completion_tokens + last_chunk.usage.prompt_tokens + == last_chunk.usage.total_tokens + ) + + +@pytest.mark.asyncio +async def test_error_during_streaming( + async_client: AsyncDial, test_deployment: str +): + completion = await async_client.chat.completions.create( + deployment_name=test_deployment, + stream=True, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + max_tokens=20, + api_version="2024-02-15-preview", + ) + + async for chunk in completion: + print(chunk) diff --git a/tests/integration/test_async_files.py b/tests/integration/test_async_files.py new file mode 100644 index 0000000..eaa4190 --- /dev/null +++ b/tests/integration/test_async_files.py @@ -0,0 +1,63 @@ +import os + +import pytest + +from aidial_client import AsyncDial, DialException +from aidial_client.types.metadata import FileMetadata +from tests.integration.fixtures import * # type: ignore # noqa + +current_file_path = os.path.abspath(__file__) +file_name = "test-file-async" +file_path = f"test-folder-artifacts/{file_name}" + + +@pytest.mark.asyncio +async def test_upload(async_client: AsyncDial): + # Upload file + with open(current_file_path, "rb") as file: + file_content = file.read() + upload_result = await async_client.files.upload( + url=await async_client.my_files_home() / file_path, file=file + ) + assert isinstance(upload_result, FileMetadata) + assert upload_result.bucket == await async_client.my_bucket() + assert upload_result.node_type == "ITEM" + assert upload_result.name == file_name + assert upload_result.parent_path == file_path.split("/")[0] + + # Download file, and compare it's content + download_result = await async_client.files.download( + url=await async_client.my_files_home() / file_path + ) + + assert b"".join([chunk async for chunk in download_result]) == file_content + assert await download_result.aget_content() == file_content + + +@pytest.mark.asyncio +async def test_delete(async_client: AsyncDial): + # Upload file + with open(current_file_path, "rb") as file: + await async_client.files.upload( + url=await async_client.my_files_home() / file_path, file=file + ) + + metadata = await async_client.files.get_metadata( + url=await async_client.my_files_home() / file_path + ) + assert metadata.node_type == "ITEM" + # Delete file + await async_client.files.delete( + url=await async_client.my_files_home() / file_path + ) + + # Try to access metadata + with pytest.raises(DialException): + await async_client.files.get_metadata( + url=await async_client.my_files_home() / file_path + ) + # Try to access content + with pytest.raises(DialException): + await async_client.files.download( + url=await async_client.my_files_home() / file_path + ) diff --git a/tests/integration/test_sync_completions.py b/tests/integration/test_sync_completions.py new file mode 100644 index 0000000..67bfac6 --- /dev/null +++ b/tests/integration/test_sync_completions.py @@ -0,0 +1,99 @@ +import pytest + +from aidial_client import Dial +from aidial_client._exception import DialException +from tests.integration.fixtures import * # type: ignore # noqa + + +def test_completions_without_streaming(sync_client: Dial, test_deployment: str): + completion = sync_client.chat.completions.create( + deployment_name=test_deployment, + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + api_version="2024-02-15-preview", + ) + assert completion.choices + assert completion.choices[0].message + assert completion.choices[0].message.content + assert "5" in completion.choices[0].message.content + assert completion.usage + assert completion.usage.completion_tokens + assert completion.usage.prompt_tokens + assert completion.usage.total_tokens + assert ( + completion.usage.completion_tokens + completion.usage.prompt_tokens + == completion.usage.total_tokens + ) + + +def test_default_api_version( + sync_client: Dial, dial_url: str, dial_api_key: str +): + with pytest.raises(DialException): + sync_client.chat.completions.create( + deployment_name="gpt-35-turbo", + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + ) + client_with_default_api_version = Dial( + base_url=dial_url, + api_key=dial_api_key, + api_version="2024-02-15-preview", + ) + client_with_default_api_version.chat.completions.create( + deployment_name="gpt-35-turbo", + stream=False, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + ) + + +def test_completions_with_streaming(sync_client: Dial): + deployments = sync_client.deployments.get() + assert len(deployments) + deployment = next((d for d in deployments if d.id.startswith("gpt-"))) + assert deployment + completion = sync_client.chat.completions.create( + deployment_name=deployment.id, + stream=True, + messages=[ + { + "role": "system", + "content": "2+3=", + } + ], + api_version="2024-02-15-preview", + ) + total_content = "" + for chunk in completion: + assert chunk.choices + assert chunk.choices[0].delta + if chunk.choices[0].delta.content: + total_content += chunk.choices[0].delta.content + + assert "5" in total_content + + last_chunk = chunk + assert last_chunk.choices[0].finish_reason + assert last_chunk.usage + assert last_chunk.usage.total_tokens + assert last_chunk.usage.prompt_tokens + assert last_chunk.usage.completion_tokens + assert ( + last_chunk.usage.completion_tokens + last_chunk.usage.prompt_tokens + == last_chunk.usage.total_tokens + ) diff --git a/tests/integration/test_sync_files.py b/tests/integration/test_sync_files.py new file mode 100644 index 0000000..c8f6e88 --- /dev/null +++ b/tests/integration/test_sync_files.py @@ -0,0 +1,58 @@ +import os + +import pytest + +from aidial_client import Dial, DialException +from aidial_client.types.metadata import FileMetadata +from tests.integration.fixtures import * # type: ignore # noqa + +current_file_path = os.path.abspath(__file__) +file_name = "test-file" +file_path = f"test-folder-artifacts/{file_name}" + + +def test_upload(sync_client: Dial): + file_content = b"" + + # Upload file + with open(current_file_path, "rb") as file: + file_content = file.read() + upload_result = sync_client.files.upload( + url=sync_client.my_files_home() / file_path, file=file + ) + assert isinstance(upload_result, FileMetadata) + assert upload_result.bucket == sync_client.my_bucket() + assert upload_result.node_type == "ITEM" + assert upload_result.name == file_name + assert upload_result.parent_path == file_path.split("/")[0] + + # Download file, and compare it's content + download_result = sync_client.files.download( + url=sync_client.my_files_home() / file_path + ) + assert b"".join([chunk for chunk in download_result]) == file_content + assert download_result.get_content() == file_content + + +def test_delete(sync_client): + # Upload file + with open(current_file_path, "rb") as file: + sync_client.files.upload( + url=sync_client.my_files_home() / file_path, file=file + ) + + metadata = sync_client.files.get_metadata( + url=sync_client.my_files_home() / file_path + ) + assert metadata.node_type == "ITEM" + # Delete file + sync_client.files.delete(url=sync_client.my_files_home() / file_path) + + # Try to access metadata + with pytest.raises(DialException): + sync_client.files.get_metadata( + url=sync_client.my_files_home() / file_path + ) + # Try to access content + with pytest.raises(DialException): + sync_client.files.download(url=sync_client.my_files_home() / file_path) diff --git a/tests/resources/completions/__init__.py b/tests/resources/completions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/resources/completions/test_completions_streaming.py b/tests/resources/completions/test_completions_streaming.py new file mode 100644 index 0000000..cf8fda8 --- /dev/null +++ b/tests/resources/completions/test_completions_streaming.py @@ -0,0 +1,69 @@ +import inspect +from typing import Iterable, List + +import pytest + +from aidial_client.types.chat import ChatCompletionChunk +from tests.client_mock import get_async_client_mock, get_client_mock + +STREAM_CHUNKS_MOCK: List[bytes] = [ + b'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1723806872,"model":"gpt-35-turbo","object":"chat.completion.chunk","system_fingerprint":null}\n\n', # noqa: E501 + b'data: {"id":"chatcmpl-test","choices":[{"delta":{"content":"5"},"finish_reason":null,"index":0,"logprobs":null}],"created":1723806872,"model":"gpt-35-turbo","object":"chat.completion.chunk","system_fingerprint":null}\n\n' # noqa: E501 + b'data: {"id":"chatcmpl-test","choices":[{"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1723806872,"model":"gpt-35-turbo","object":"chat.completion.chunk","system_fingerprint":null,"usage":{"completion_tokens":1,"prompt_tokens":11,"total_tokens":12}}\n\n', # noqa: E501 +] + + +def _validate_chunks(chunks): + assert all(len(chunk.choices) for chunk in chunks) + assert all(chunk.choices[0].delta for chunk in chunks) + # All except last chunk has some content + assert all( + chunk.choices[0].delta.content is not None for chunk in chunks[:-1] + ) + total_content = "".join( + chunk.choices[0].delta.content or "" for chunk in chunks + ) + assert total_content == "5" + + # Last chunk has no content, but usage and stop_reason + assert chunks[-1].choices[0].delta.content is None + assert chunks[-1].choices[0].finish_reason == "stop" + assert chunks[-1].usage + assert chunks[-1].usage.total_tokens == 12 + assert chunks[-1].usage.prompt_tokens == 11 + assert chunks[-1].usage.completion_tokens == 1 + + +def test_sync_streaming(): + client = get_client_mock( + status_code=200, + stream_chunks_mock=STREAM_CHUNKS_MOCK, + ) + + response = client.chat.completions.create( + deployment_name="gpt-35-turbo", + messages=[{"role": "user", "content": "2+3="}], + stream=True, + ) + assert isinstance(response, Iterable) + chunks = [chunk for chunk in response] + assert all(isinstance(chunk, ChatCompletionChunk) for chunk in chunks) + _validate_chunks(chunks) + + +@pytest.mark.asyncio +async def test_async_streaming(): + async_client = get_async_client_mock( + status_code=200, + stream_chunks_mock=STREAM_CHUNKS_MOCK, + ) + response = await async_client.chat.completions.create( + deployment_name="gpt-35-turbo", + messages=[{"role": "user", "content": "2+3="}], + stream=True, + ) + + assert inspect.isasyncgen(response) + chunks = [chunk async for chunk in response] + assert all(isinstance(chunk, ChatCompletionChunk) for chunk in chunks) + _validate_chunks(chunks) diff --git a/tests/resources/files/test_metadata.py b/tests/resources/files/test_metadata.py new file mode 100644 index 0000000..ff0c49f --- /dev/null +++ b/tests/resources/files/test_metadata.py @@ -0,0 +1,68 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from aidial_client.types.metadata import FileMetadata +from tests.client_mock import get_async_client_mock, get_client_mock + +METADATA_RESPONSE_MOCK = { + "name": "folder2", + "parentPath": "folder1", + "bucket": "test-bucket", + "url": "files/test/folder1/folder2/", + "nodeType": "FOLDER", + "resourceType": "FILE", + "items": [ + { + "name": "file.png", + "parentPath": "folder1/folder2", + "bucket": "test", + "url": "files/test/folder1/folder2/file.png", + "nodeType": "ITEM", + "resourceType": "FILE", + "contentLength": 128630, + "contentType": "image/png", + } + ], +} + + +def test_get_metadata(): + client = get_client_mock(status_code=200, json_mock=METADATA_RESPONSE_MOCK) + client._get_my_bucket = Mock(return_value="test-bucket") + + valid_response = client.files.get_metadata( + url="files/test-bucket/folder1/folder2/" + ) + valid_response_with_default_bucket = client.files.get_metadata( + url=client.my_files_home() / "folder1/folder2/" + ) + + for r in [valid_response, valid_response_with_default_bucket]: + assert isinstance(r, FileMetadata) + assert r.node_type == "FOLDER" + assert r.bucket == "test-bucket" + assert r.items and len(r.items) == 1 + assert r.items[0].node_type == "ITEM" + + +@pytest.mark.asyncio +async def test_get_metadata_async(): + client = get_async_client_mock( + status_code=200, json_mock=METADATA_RESPONSE_MOCK + ) + client._get_my_bucket = AsyncMock(return_value="test-bucket") + + valid_response = await client.files.get_metadata( + url="files/test-bucket/folder1/folder2/" + ) + valid_response_with_default_bucket = await client.files.get_metadata( + url=await client.my_files_home() / "folder1/folder2/" + ) + + for r in [valid_response, valid_response_with_default_bucket]: + assert isinstance(r, FileMetadata) + assert r.node_type == "FOLDER" + assert r.bucket == "test-bucket" + assert r.items and len(r.items) == 1 + assert r.items[0].node_type == "ITEM" diff --git a/tests/resources/files/test_upload.py b/tests/resources/files/test_upload.py new file mode 100644 index 0000000..73f8302 --- /dev/null +++ b/tests/resources/files/test_upload.py @@ -0,0 +1,72 @@ +import os +from unittest.mock import AsyncMock, Mock + +import pytest + +from aidial_client._exception import InvalidDialURLException +from aidial_client.types.metadata import FileMetadata +from tests.client_mock import get_async_client_mock, get_client_mock + +UPLOAD_RESPONSE_MOCK = { + "name": "file.png", + "parentPath": "folder1/folder2", + "bucket": "test-bucket", + "url": "files/test/folder1/folder2/file.png", + "nodeType": "ITEM", + "resourceType": "FILE", + "contentLength": 128630, + "contentType": "image/png", +} +current_file_path = os.path.abspath(__file__) + + +def test_upload_file_object(): + client = get_client_mock(status_code=200, json_mock=UPLOAD_RESPONSE_MOCK) + client._get_my_bucket = Mock(return_value="test-bucket") + + with open(current_file_path, "rb") as file: + valid_response = client.files.upload( + url="files/test-bucket/folder1/folder2/file.png", + file=file, + ) + + valid_response_using_default_bucket = client.files.upload( + url=client.my_files_home() / "folder1/folder2/file.png", + file=file, + ) + for r in [valid_response, valid_response_using_default_bucket]: + assert isinstance(r, FileMetadata) + assert r.bucket == "test-bucket" + assert r.name == "file.png" + assert r.parent_path == "folder1/folder2" + + +@pytest.mark.asyncio +async def test_upload_file_object_async(): + client = get_async_client_mock( + status_code=200, json_mock=UPLOAD_RESPONSE_MOCK + ) + client._get_my_bucket = AsyncMock(return_value="test-bucket") + + with open(current_file_path, "rb") as file: + with pytest.raises( + InvalidDialURLException, match="Invalid resource type for url" + ): + await client.files.upload( + url="prompts/test-bucket/folder1/folder2/file.png", file=file + ) + + valid_response = await client.files.upload( + url="files/test-bucket/folder1/folder2/file.png", + file=file, + ) + + valid_response_with_files_home = await client.files.upload( + url=await client.my_files_home() / "folder1/folder2/file.png", + file=file, + ) + for r in [valid_response, valid_response_with_files_home]: + assert isinstance(r, FileMetadata) + assert r.bucket == "test-bucket" + assert r.name == "file.png" + assert r.parent_path == "folder1/folder2" diff --git a/tests/resources/test_bucket.py b/tests/resources/test_bucket.py new file mode 100644 index 0000000..04ed135 --- /dev/null +++ b/tests/resources/test_bucket.py @@ -0,0 +1,59 @@ +import pytest + +from aidial_client.types.bucket import BucketResponse +from tests.client_mock import get_async_client_mock, get_client_mock + + +def test_get_bucket(): + client = get_client_mock(status_code=200, json_mock={"bucket": "test"}) + + raw_response = client.bucket.get_raw() + assert isinstance(raw_response, BucketResponse) + assert client.bucket.get_bucket() == raw_response.bucket == "test" + + +@pytest.mark.asyncio +async def test_async_get_bucket(): + async_client = get_async_client_mock( + status_code=200, json_mock={"bucket": "test"} + ) + raw_response = await async_client.bucket.get_raw() + assert isinstance(raw_response, BucketResponse) + assert ( + await async_client.bucket.get_bucket() == raw_response.bucket == "test" + ) + + +def test_get_appdata(): + client = get_client_mock(status_code=200, json_mock={"bucket": "test"}) + assert not client.bucket.get_appdata() + client = get_client_mock( + status_code=200, + json_mock={ + "bucket": "test", + "appdata": "test-bucket/appdata/dall-e-3", + }, + ) + appdata = client.bucket.get_appdata() + assert appdata + assert appdata.app_name == "dall-e-3" + assert appdata.user_bucket == "test-bucket" + + +@pytest.mark.asyncio +async def test_async_get_appdata(): + client = get_async_client_mock( + status_code=200, json_mock={"bucket": "test"} + ) + assert not await client.bucket.get_appdata() + client = get_async_client_mock( + status_code=200, + json_mock={ + "bucket": "test", + "appdata": "test-bucket/appdata/dall-e-3", + }, + ) + appdata = await client.bucket.get_appdata() + assert appdata + assert appdata.app_name == "dall-e-3" + assert appdata.user_bucket == "test-bucket" diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..1a22a20 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,89 @@ +import pytest + +from aidial_client import Dial +from aidial_client._client import AsyncDial + + +def _test_getter() -> str: + return "test-value" + + +async def _test_async_getter() -> str: + return "test-value" + + +@pytest.mark.parametrize( + "api_key_value, expected_headers", + [ + ("dummy", {"api-key": "dummy"}), + ( + _test_getter, + {"api-key": "test-value"}, + ), + ( + _test_async_getter, + {"api-key": "test-value"}, + ), + ], +) +async def test_api_key(api_key_value, expected_headers): + client = Dial(api_key=api_key_value, base_url="http://dial.core") + assert client.auth_headers() == expected_headers + + +@pytest.mark.parametrize( + "api_key_value, expected_headers", + [ + ("dummy", {"api-key": "dummy"}), + ( + _test_getter, + {"api-key": "test-value"}, + ), + ( + _test_async_getter, + {"api-key": "test-value"}, + ), + ], +) +@pytest.mark.asyncio +async def test_api_key_async(api_key_value, expected_headers): + client = AsyncDial(api_key=api_key_value, base_url="http://dial.core") + assert await client.auth_headers() == expected_headers + + +@pytest.mark.parametrize( + "bearer_token_value, expected_headers", + [ + ("dummy-token", {"Authorization": "Bearer dummy-token"}), + ( + _test_getter, + {"Authorization": "Bearer test-value"}, + ), + ], +) +@pytest.mark.asyncio +async def test_bearer_token(bearer_token_value, expected_headers): + client = Dial(bearer_token=bearer_token_value, base_url="http://dial.core") + assert client.auth_headers() == expected_headers + + +@pytest.mark.parametrize( + "bearer_token_value, expected_headers", + [ + ("dummy-token", {"Authorization": "Bearer dummy-token"}), + ( + _test_getter, + {"Authorization": "Bearer test-value"}, + ), + ( + _test_async_getter, + {"Authorization": "Bearer test-value"}, + ), + ], +) +@pytest.mark.asyncio +async def test_bearer_token_async(bearer_token_value, expected_headers): + client = AsyncDial( + bearer_token=bearer_token_value, base_url="http://dial.core" + ) + assert await client.auth_headers() == expected_headers diff --git a/tests/test_client_pool.py b/tests/test_client_pool.py new file mode 100644 index 0000000..90380ec --- /dev/null +++ b/tests/test_client_pool.py @@ -0,0 +1,48 @@ +import pytest + +from aidial_client import AsyncDialClientPool, DialClientPool + + +def _test_getter() -> str: + return "test-value" + + +async def _test_async_getter() -> str: + return "test-value" + + +@pytest.mark.parametrize( + "pool, api_key", + [ + (DialClientPool(), "dummy"), + (DialClientPool(), _test_getter), + (AsyncDialClientPool(), "dummy"), + (AsyncDialClientPool(), _test_getter), + (AsyncDialClientPool(), _test_async_getter), + ], +) +def test_pools(pool, api_key): + client_1 = pool.create_client(base_url="http://dial.core", api_key=api_key) + client_2 = pool.create_client( + base_url="http://another_dial.core", api_key=api_key + ) + assert client_1.base_url != client_2.base_url + + assert client_1.api_url == "http://dial.core/v1/" + assert client_2.api_url == "http://another_dial.core/v1/" + + client_1_url = "http://dial.core/v1/bucket" + client_2_url = "http://another_dial.core/v1/bucket" + + assert client_1.is_dial_url(client_1_url) + assert not client_1.is_dial_url(client_2_url) + + assert client_2.is_dial_url(client_2_url) + assert not client_2.is_dial_url(client_1_url) + + assert id(client_1) != id(client_2) + assert id(client_1._http_client) != id(client_2._http_client) + # Clients are different, but internal httpx client is the same + assert id(client_1._http_client._client) == id( + client_2._http_client._client + ) diff --git a/tests/test_client_retry.py b/tests/test_client_retry.py new file mode 100644 index 0000000..745ea20 --- /dev/null +++ b/tests/test_client_retry.py @@ -0,0 +1,125 @@ +from http import HTTPStatus +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest + +from aidial_client._exception import DialException +from tests.client_mock import get_async_client_mock, get_client_mock + + +@pytest.mark.parametrize( + "exception", + [ + httpx.TimeoutException("Request timed out"), + Exception("Some unknown error"), + ], +) +def test_exception_retry_sync(exception): + client = get_client_mock( + status_code=None, + exception_mock=exception, + ) + retry_request_mock = Mock() + remaining_retries_mock = Mock() + client._http_client._remaining_retries = remaining_retries_mock + client._http_client._retry_request = retry_request_mock + + # If retries are not exhausted, the request should be retried + remaining_retries_mock.return_value = 1 + client.bucket.get_bucket() + assert retry_request_mock.called + # After retries are exhausted, the exception should be raised + remaining_retries_mock.return_value = 0 + with pytest.raises(Exception) as e: + client.bucket.get_bucket() + assert isinstance(e, exception.__class__) + assert not retry_request_mock.called + + +@pytest.mark.parametrize( + "exception", + [ + httpx.TimeoutException("Request timed out"), + Exception("Some unknown error"), + ], +) +@pytest.mark.asyncio +async def test_exception_retry_async(exception): + client = get_async_client_mock( + status_code=None, + exception_mock=exception, + ) + retry_request_mock = AsyncMock() + remaining_retries_mock = Mock() + client._http_client._remaining_retries = remaining_retries_mock + client._http_client._retry_request = retry_request_mock + + # If retries are not exhausted, the request should be retried + remaining_retries_mock.return_value = 1 + await client.bucket.get_bucket() + assert retry_request_mock.called + # After retries are exhausted, the exception should be raised + remaining_retries_mock.return_value = 0 + with pytest.raises(Exception) as e: + await client.bucket.get_bucket() + assert isinstance(e, exception.__class__) + assert not retry_request_mock.called + + +@pytest.mark.parametrize( + "status_code, is_retry_called", + [ + (HTTPStatus.REQUEST_TIMEOUT, True), + (HTTPStatus.TOO_MANY_REQUESTS, True), + (HTTPStatus.CONFLICT, True), + (HTTPStatus.INTERNAL_SERVER_ERROR, False), + (HTTPStatus.BAD_REQUEST, False), + (HTTPStatus.NOT_FOUND, False), + ], +) +def test_status_codes_retries(status_code, is_retry_called): + client = get_client_mock(status_code=status_code, json_mock={}) + remaining_retries_mock = Mock() + remaining_retries_mock.return_value = 1 + client._http_client._remaining_retries = remaining_retries_mock + retry_request_mock = Mock() + client._http_client._retry_request = retry_request_mock + + if is_retry_called: + client.bucket.get_bucket() + assert retry_request_mock.called == is_retry_called + else: + with pytest.raises(DialException) as e: + client.bucket.get_bucket() + assert e.status_code == status_code # type: ignore + assert not retry_request_mock.called + + +@pytest.mark.parametrize( + "status_code, is_retry_called", + [ + (HTTPStatus.REQUEST_TIMEOUT, True), + (HTTPStatus.TOO_MANY_REQUESTS, True), + (HTTPStatus.CONFLICT, True), + (HTTPStatus.INTERNAL_SERVER_ERROR, False), + (HTTPStatus.BAD_REQUEST, False), + (HTTPStatus.NOT_FOUND, False), + ], +) +@pytest.mark.asyncio +async def test_status_codes_retries_async(status_code, is_retry_called): + client = get_async_client_mock(status_code=status_code, json_mock={}) + remaining_retries_mock = Mock() + remaining_retries_mock.return_value = 1 + client._http_client._remaining_retries = remaining_retries_mock + retry_request_mock = AsyncMock() + client._http_client._retry_request = retry_request_mock + + if is_retry_called: + await client.bucket.get_bucket() + assert retry_request_mock.called == is_retry_called + else: + with pytest.raises(DialException) as e: + await client.bucket.get_bucket() + assert e.status_code == status_code # type: ignore From 5a24d97b077857589689b8fd335b996098e668e8 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 12:52:22 +0300 Subject: [PATCH 02/64] update lockfile, try to fix pipeline --- poetry.lock | 449 +++++++++++++++++++++++++++------------------------- 1 file changed, 229 insertions(+), 220 deletions(-) diff --git a/poetry.lock b/poetry.lock index bb2f8f9..58c3878 100644 --- a/poetry.lock +++ b/poetry.lock @@ -27,13 +27,13 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} [[package]] name = "anyio" -version = "4.5.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.5.0-py3-none-any.whl", hash = "sha256:fdeb095b7cc5a5563175eedd926ec4ae55413bb4be5770c424af0ba46ccb4a78"}, - {file = "anyio-4.5.0.tar.gz", hash = "sha256:c5a275fe5ca0afd788001f58fca1e69e29ce706d746e317d660e21f70c530ef9"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -43,19 +43,19 @@ sniffio = ">=1.1" typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.21.0b1)"] -trio = ["trio (>=0.26.1)"] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] [[package]] name = "argcomplete" -version = "3.5.1" +version = "3.4.0" description = "Bash tab completion for argparse" optional = false python-versions = ">=3.8" files = [ - {file = "argcomplete-3.5.1-py3-none-any.whl", hash = "sha256:1a1d148bdaa3e3b93454900163403df41448a248af01b6e849edc5ac08e6c363"}, - {file = "argcomplete-3.5.1.tar.gz", hash = "sha256:eb1ee355aa2557bd3d0145de7b06b2a45b0ce461e1e7813f5d066039ab4177b4"}, + {file = "argcomplete-3.4.0-py3-none-any.whl", hash = "sha256:69a79e083a716173e5532e0fa3bef45f793f4e61096cf52b5a42c0211c8b8aa5"}, + {file = "argcomplete-3.4.0.tar.gz", hash = "sha256:c2abcdfe1be8ace47ba777d4fce319eb13bf8ad9dace8d085dcad6eded88057f"}, ] [package.extras] @@ -78,33 +78,33 @@ tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [[package]] name = "black" -version = "24.8.0" +version = "24.4.2" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6"}, - {file = "black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb"}, - {file = "black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42"}, - {file = "black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a"}, - {file = "black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1"}, - {file = "black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af"}, - {file = "black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4"}, - {file = "black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af"}, - {file = "black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368"}, - {file = "black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed"}, - {file = "black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018"}, - {file = "black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2"}, - {file = "black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd"}, - {file = "black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2"}, - {file = "black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e"}, - {file = "black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920"}, - {file = "black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c"}, - {file = "black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e"}, - {file = "black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47"}, - {file = "black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb"}, - {file = "black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed"}, - {file = "black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f"}, + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, ] [package.dependencies] @@ -124,13 +124,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2024.8.30" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -317,19 +317,19 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.16.1" +version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, - {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] -typing = ["typing-extensions (>=4.12.2)"] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] +typing = ["typing-extensions (>=4.8)"] [[package]] name = "flake8" @@ -360,13 +360,13 @@ files = [ [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.5" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f"}, - {file = "httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f"}, + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, ] [package.dependencies] @@ -377,17 +377,17 @@ h11 = ">=0.13,<0.15" asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<1.0)"] +trio = ["trio (>=0.22.0,<0.26.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.25.2" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"}, + {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"}, ] [package.dependencies] @@ -402,22 +402,18 @@ brotli = ["brotli", "brotlicffi"] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -zstd = ["zstandard (>=0.18.0)"] [[package]] name = "idna" -version = "3.10" +version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" +python-versions = ">=3.5" files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -445,56 +441,72 @@ colors = ["colorama (>=0.4.6)"] [[package]] name = "jiter" -version = "0.6.1" +version = "0.5.0" description = "Fast iterable JSON parser." optional = false python-versions = ">=3.8" files = [ - {file = "jiter-0.6.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:d08510593cb57296851080018006dfc394070178d238b767b1879dc1013b106c"}, - {file = "jiter-0.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adef59d5e2394ebbad13b7ed5e0306cceb1df92e2de688824232a91588e77aa7"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3e02f7a27f2bcc15b7d455c9df05df8ffffcc596a2a541eeda9a3110326e7a3"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed69a7971d67b08f152c17c638f0e8c2aa207e9dd3a5fcd3cba294d39b5a8d2d"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2019d966e98f7c6df24b3b8363998575f47d26471bfb14aade37630fae836a1"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36c0b51a285b68311e207a76c385650322734c8717d16c2eb8af75c9d69506e7"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:220e0963b4fb507c525c8f58cde3da6b1be0bfddb7ffd6798fb8f2531226cdb1"}, - {file = "jiter-0.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:aa25c7a9bf7875a141182b9c95aed487add635da01942ef7ca726e42a0c09058"}, - {file = "jiter-0.6.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e90552109ca8ccd07f47ca99c8a1509ced93920d271bb81780a973279974c5ab"}, - {file = "jiter-0.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:67723a011964971864e0b484b0ecfee6a14de1533cff7ffd71189e92103b38a8"}, - {file = "jiter-0.6.1-cp310-none-win32.whl", hash = "sha256:33af2b7d2bf310fdfec2da0177eab2fedab8679d1538d5b86a633ebfbbac4edd"}, - {file = "jiter-0.6.1-cp310-none-win_amd64.whl", hash = "sha256:7cea41c4c673353799906d940eee8f2d8fd1d9561d734aa921ae0f75cb9732f4"}, - {file = "jiter-0.6.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b03c24e7da7e75b170c7b2b172d9c5e463aa4b5c95696a368d52c295b3f6847f"}, - {file = "jiter-0.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:47fee1be677b25d0ef79d687e238dc6ac91a8e553e1a68d0839f38c69e0ee491"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25f0d2f6e01a8a0fb0eab6d0e469058dab2be46ff3139ed2d1543475b5a1d8e7"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b809e39e342c346df454b29bfcc7bca3d957f5d7b60e33dae42b0e5ec13e027"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e9ac7c2f092f231f5620bef23ce2e530bd218fc046098747cc390b21b8738a7a"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e51a2d80d5fe0ffb10ed2c82b6004458be4a3f2b9c7d09ed85baa2fbf033f54b"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3343d4706a2b7140e8bd49b6c8b0a82abf9194b3f0f5925a78fc69359f8fc33c"}, - {file = "jiter-0.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82521000d18c71e41c96960cb36e915a357bc83d63a8bed63154b89d95d05ad1"}, - {file = "jiter-0.6.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c843e7c1633470708a3987e8ce617ee2979ee18542d6eb25ae92861af3f1d62"}, - {file = "jiter-0.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a2e861658c3fe849efc39b06ebb98d042e4a4c51a8d7d1c3ddc3b1ea091d0784"}, - {file = "jiter-0.6.1-cp311-none-win32.whl", hash = "sha256:7d72fc86474862c9c6d1f87b921b70c362f2b7e8b2e3c798bb7d58e419a6bc0f"}, - {file = "jiter-0.6.1-cp311-none-win_amd64.whl", hash = "sha256:3e36a320634f33a07794bb15b8da995dccb94f944d298c8cfe2bd99b1b8a574a"}, - {file = "jiter-0.6.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1fad93654d5a7dcce0809aff66e883c98e2618b86656aeb2129db2cd6f26f867"}, - {file = "jiter-0.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4e6e340e8cd92edab7f6a3a904dbbc8137e7f4b347c49a27da9814015cc0420c"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:691352e5653af84ed71763c3c427cff05e4d658c508172e01e9c956dfe004aba"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:defee3949313c1f5b55e18be45089970cdb936eb2a0063f5020c4185db1b63c9"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26d2bdd5da097e624081c6b5d416d3ee73e5b13f1703bcdadbb1881f0caa1933"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18aa9d1626b61c0734b973ed7088f8a3d690d0b7f5384a5270cd04f4d9f26c86"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a3567c8228afa5ddcce950631c6b17397ed178003dc9ee7e567c4c4dcae9fa0"}, - {file = "jiter-0.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5c0507131c922defe3f04c527d6838932fcdfd69facebafd7d3574fa3395314"}, - {file = "jiter-0.6.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:540fcb224d7dc1bcf82f90f2ffb652df96f2851c031adca3c8741cb91877143b"}, - {file = "jiter-0.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e7b75436d4fa2032b2530ad989e4cb0ca74c655975e3ff49f91a1a3d7f4e1df2"}, - {file = "jiter-0.6.1-cp312-none-win32.whl", hash = "sha256:883d2ced7c21bf06874fdeecab15014c1c6d82216765ca6deef08e335fa719e0"}, - {file = "jiter-0.6.1-cp312-none-win_amd64.whl", hash = "sha256:91e63273563401aadc6c52cca64a7921c50b29372441adc104127b910e98a5b6"}, - {file = "jiter-0.6.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:852508a54fe3228432e56019da8b69208ea622a3069458252f725d634e955b31"}, - {file = "jiter-0.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f491cc69ff44e5a1e8bc6bf2b94c1f98d179e1aaf4a554493c171a5b2316b701"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc56c8f0b2a28ad4d8047f3ae62d25d0e9ae01b99940ec0283263a04724de1f3"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:51b58f7a0d9e084a43b28b23da2b09fc5e8df6aa2b6a27de43f991293cab85fd"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f79ce15099154c90ef900d69c6b4c686b64dfe23b0114e0971f2fecd306ec6c"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03a025b52009f47e53ea619175d17e4ded7c035c6fbd44935cb3ada11e1fd592"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74a8d93718137c021d9295248a87c2f9fdc0dcafead12d2930bc459ad40f885"}, - {file = "jiter-0.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40b03b75f903975f68199fc4ec73d546150919cb7e534f3b51e727c4d6ccca5a"}, - {file = "jiter-0.6.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:825651a3f04cf92a661d22cad61fc913400e33aa89b3e3ad9a6aa9dc8a1f5a71"}, + {file = "jiter-0.5.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b599f4e89b3def9a94091e6ee52e1d7ad7bc33e238ebb9c4c63f211d74822c3f"}, + {file = "jiter-0.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a063f71c4b06225543dddadbe09d203dc0c95ba352d8b85f1221173480a71d5"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc0d5b8b3dd12e91dd184b87273f864b363dfabc90ef29a1092d269f18c7e28"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c22541f0b672f4d741382a97c65609332a783501551445ab2df137ada01e019e"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63314832e302cc10d8dfbda0333a384bf4bcfce80d65fe99b0f3c0da8945a91a"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a25fbd8a5a58061e433d6fae6d5298777c0814a8bcefa1e5ecfff20c594bd749"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503b2c27d87dfff5ab717a8200fbbcf4714516c9d85558048b1fc14d2de7d8dc"}, + {file = "jiter-0.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d1f3d27cce923713933a844872d213d244e09b53ec99b7a7fdf73d543529d6d"}, + {file = "jiter-0.5.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c95980207b3998f2c3b3098f357994d3fd7661121f30669ca7cb945f09510a87"}, + {file = "jiter-0.5.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:afa66939d834b0ce063f57d9895e8036ffc41c4bd90e4a99631e5f261d9b518e"}, + {file = "jiter-0.5.0-cp310-none-win32.whl", hash = "sha256:f16ca8f10e62f25fd81d5310e852df6649af17824146ca74647a018424ddeccf"}, + {file = "jiter-0.5.0-cp310-none-win_amd64.whl", hash = "sha256:b2950e4798e82dd9176935ef6a55cf6a448b5c71515a556da3f6b811a7844f1e"}, + {file = "jiter-0.5.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d4c8e1ed0ef31ad29cae5ea16b9e41529eb50a7fba70600008e9f8de6376d553"}, + {file = "jiter-0.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6f16e21276074a12d8421692515b3fd6d2ea9c94fd0734c39a12960a20e85f3"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5280e68e7740c8c128d3ae5ab63335ce6d1fb6603d3b809637b11713487af9e6"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:583c57fc30cc1fec360e66323aadd7fc3edeec01289bfafc35d3b9dcb29495e4"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26351cc14507bdf466b5f99aba3df3143a59da75799bf64a53a3ad3155ecded9"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4829df14d656b3fb87e50ae8b48253a8851c707da9f30d45aacab2aa2ba2d614"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42a4bdcf7307b86cb863b2fb9bb55029b422d8f86276a50487982d99eed7c6e"}, + {file = "jiter-0.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04d461ad0aebf696f8da13c99bc1b3e06f66ecf6cfd56254cc402f6385231c06"}, + {file = "jiter-0.5.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e6375923c5f19888c9226582a124b77b622f8fd0018b843c45eeb19d9701c403"}, + {file = "jiter-0.5.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2cec323a853c24fd0472517113768c92ae0be8f8c384ef4441d3632da8baa646"}, + {file = "jiter-0.5.0-cp311-none-win32.whl", hash = "sha256:aa1db0967130b5cab63dfe4d6ff547c88b2a394c3410db64744d491df7f069bb"}, + {file = "jiter-0.5.0-cp311-none-win_amd64.whl", hash = "sha256:aa9d2b85b2ed7dc7697597dcfaac66e63c1b3028652f751c81c65a9f220899ae"}, + {file = "jiter-0.5.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9f664e7351604f91dcdd557603c57fc0d551bc65cc0a732fdacbf73ad335049a"}, + {file = "jiter-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:044f2f1148b5248ad2c8c3afb43430dccf676c5a5834d2f5089a4e6c5bbd64df"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:702e3520384c88b6e270c55c772d4bd6d7b150608dcc94dea87ceba1b6391248"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:528d742dcde73fad9d63e8242c036ab4a84389a56e04efd854062b660f559544"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf80e5fe6ab582c82f0c3331df27a7e1565e2dcf06265afd5173d809cdbf9ba"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:44dfc9ddfb9b51a5626568ef4e55ada462b7328996294fe4d36de02fce42721f"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c451f7922992751a936b96c5f5b9bb9312243d9b754c34b33d0cb72c84669f4e"}, + {file = "jiter-0.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:308fce789a2f093dca1ff91ac391f11a9f99c35369117ad5a5c6c4903e1b3e3a"}, + {file = "jiter-0.5.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7f5ad4a7c6b0d90776fdefa294f662e8a86871e601309643de30bf94bb93a64e"}, + {file = "jiter-0.5.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ea189db75f8eca08807d02ae27929e890c7d47599ce3d0a6a5d41f2419ecf338"}, + {file = "jiter-0.5.0-cp312-none-win32.whl", hash = "sha256:e3bbe3910c724b877846186c25fe3c802e105a2c1fc2b57d6688b9f8772026e4"}, + {file = "jiter-0.5.0-cp312-none-win_amd64.whl", hash = "sha256:a586832f70c3f1481732919215f36d41c59ca080fa27a65cf23d9490e75b2ef5"}, + {file = "jiter-0.5.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:f04bc2fc50dc77be9d10f73fcc4e39346402ffe21726ff41028f36e179b587e6"}, + {file = "jiter-0.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6f433a4169ad22fcb550b11179bb2b4fd405de9b982601914ef448390b2954f3"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad4a6398c85d3a20067e6c69890ca01f68659da94d74c800298581724e426c7e"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6baa88334e7af3f4d7a5c66c3a63808e5efbc3698a1c57626541ddd22f8e4fbf"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ece0a115c05efca597c6d938f88c9357c843f8c245dbbb53361a1c01afd7148"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:335942557162ad372cc367ffaf93217117401bf930483b4b3ebdb1223dbddfa7"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:649b0ee97a6e6da174bffcb3c8c051a5935d7d4f2f52ea1583b5b3e7822fbf14"}, + {file = "jiter-0.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f4be354c5de82157886ca7f5925dbda369b77344b4b4adf2723079715f823989"}, + {file = "jiter-0.5.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5206144578831a6de278a38896864ded4ed96af66e1e63ec5dd7f4a1fce38a3a"}, + {file = "jiter-0.5.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8120c60f8121ac3d6f072b97ef0e71770cc72b3c23084c72c4189428b1b1d3b6"}, + {file = "jiter-0.5.0-cp38-none-win32.whl", hash = "sha256:6f1223f88b6d76b519cb033a4d3687ca157c272ec5d6015c322fc5b3074d8a5e"}, + {file = "jiter-0.5.0-cp38-none-win_amd64.whl", hash = "sha256:c59614b225d9f434ea8fc0d0bec51ef5fa8c83679afedc0433905994fb36d631"}, + {file = "jiter-0.5.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:0af3838cfb7e6afee3f00dc66fa24695199e20ba87df26e942820345b0afc566"}, + {file = "jiter-0.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:550b11d669600dbc342364fd4adbe987f14d0bbedaf06feb1b983383dcc4b961"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:489875bf1a0ffb3cb38a727b01e6673f0f2e395b2aad3c9387f94187cb214bbf"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b250ca2594f5599ca82ba7e68785a669b352156260c5362ea1b4e04a0f3e2389"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ea18e01f785c6667ca15407cd6dabbe029d77474d53595a189bdc813347218e"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:462a52be85b53cd9bffd94e2d788a09984274fe6cebb893d6287e1c296d50653"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92cc68b48d50fa472c79c93965e19bd48f40f207cb557a8346daa020d6ba973b"}, + {file = "jiter-0.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c834133e59a8521bc87ebcad773608c6fa6ab5c7a022df24a45030826cf10bc"}, + {file = "jiter-0.5.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab3a71ff31cf2d45cb216dc37af522d335211f3a972d2fe14ea99073de6cb104"}, + {file = "jiter-0.5.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cccd3af9c48ac500c95e1bcbc498020c87e1781ff0345dd371462d67b76643eb"}, + {file = "jiter-0.5.0-cp39-none-win32.whl", hash = "sha256:368084d8d5c4fc40ff7c3cc513c4f73e02c85f6009217922d0823a48ee7adf61"}, + {file = "jiter-0.5.0-cp39-none-win_amd64.whl", hash = "sha256:ce03f7b4129eb72f1687fa11300fbf677b02990618428934662406d2a76742a1"}, + {file = "jiter-0.5.0.tar.gz", hash = "sha256:1d916ba875bcab5c5f7d927df998c4cb694d27dceddf3392e58beaf10563368a"}, ] [[package]] @@ -554,13 +566,13 @@ uv = ["uv (>=0.1.6)"] [[package]] name = "openai" -version = "1.51.1" +version = "1.51.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.7.1" files = [ - {file = "openai-1.51.1-py3-none-any.whl", hash = "sha256:035ba637bef7523282b5b8d9f2f5fdc0bb5bc18d52af2bfc7f64e4a7b0a169fb"}, - {file = "openai-1.51.1.tar.gz", hash = "sha256:a4908d68e0a1f4bcb45cbaf273c5fbdc3a4fa6239bb75128b58b94f7d5411563"}, + {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"}, + {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"}, ] [package.dependencies] @@ -600,19 +612,19 @@ files = [ [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] [[package]] name = "pluggy" @@ -642,18 +654,18 @@ files = [ [[package]] name = "pydantic" -version = "2.9.2" +version = "2.8.2" description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, - {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, ] [package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.23.4" +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" typing-extensions = [ {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, @@ -661,104 +673,103 @@ typing-extensions = [ [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] [[package]] name = "pydantic-core" -version = "2.23.4" +version = "2.20.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" files = [ - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, - {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2"}, - {file = "pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3"}, - {file = "pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071"}, - {file = "pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119"}, - {file = "pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8"}, - {file = "pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e"}, - {file = "pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0"}, - {file = "pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64"}, - {file = "pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f"}, - {file = "pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231"}, - {file = "pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36"}, - {file = "pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e"}, - {file = "pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24"}, - {file = "pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84"}, - {file = "pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc"}, - {file = "pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b"}, - {file = "pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6"}, - {file = "pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f"}, - {file = "pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769"}, - {file = "pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d4488a93b071c04dc20f5cecc3631fc78b9789dd72483ba15d423b5b3689b555"}, - {file = "pydantic_core-2.23.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:81965a16b675b35e1d09dd14df53f190f9129c0202356ed44ab2728b1c905658"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffa2ebd4c8530079140dd2d7f794a9d9a73cbb8e9d59ffe24c63436efa8f271"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:61817945f2fe7d166e75fbfb28004034b48e44878177fc54d81688e7b85a3665"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29d2c342c4bc01b88402d60189f3df065fb0dda3654744d5a165a5288a657368"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5e11661ce0fd30a6790e8bcdf263b9ec5988e95e63cf901972107efc49218b13"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d18368b137c6295db49ce7218b1a9ba15c5bc254c96d7c9f9e924a9bc7825ad"}, - {file = "pydantic_core-2.23.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec4e55f79b1c4ffb2eecd8a0cfba9955a2588497d96851f4c8f99aa4a1d39b12"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:374a5e5049eda9e0a44c696c7ade3ff355f06b1fe0bb945ea3cac2bc336478a2"}, - {file = "pydantic_core-2.23.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5c364564d17da23db1106787675fc7af45f2f7b58b4173bfdd105564e132e6fb"}, - {file = "pydantic_core-2.23.4-cp38-none-win32.whl", hash = "sha256:d7a80d21d613eec45e3d41eb22f8f94ddc758a6c4720842dc74c0581f54993d6"}, - {file = "pydantic_core-2.23.4-cp38-none-win_amd64.whl", hash = "sha256:5f5ff8d839f4566a474a969508fe1c5e59c31c80d9e140566f9a37bba7b8d556"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a"}, - {file = "pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c"}, - {file = "pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040"}, - {file = "pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605"}, - {file = "pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6"}, - {file = "pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433"}, - {file = "pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8"}, - {file = "pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e"}, - {file = "pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, ] [package.dependencies] @@ -777,23 +788,21 @@ files = [ [[package]] name = "pyright" -version = "1.1.383" +version = "1.1.372" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" files = [ - {file = "pyright-1.1.383-py3-none-any.whl", hash = "sha256:d864d1182a313f45aaf99e9bfc7d2668eeabc99b29a556b5344894fd73cb1959"}, - {file = "pyright-1.1.383.tar.gz", hash = "sha256:1df7f12407f3710c9c6df938d98ec53f70053e6c6bbf71ce7bcb038d42f10070"}, + {file = "pyright-1.1.372-py3-none-any.whl", hash = "sha256:25b15fb8967740f0949fd35b963777187f0a0404c0bd753cc966ec139f3eaa0b"}, + {file = "pyright-1.1.372.tar.gz", hash = "sha256:a9f5e0daa955daaa17e3d1ef76d3623e75f8afd5e37b437d3ff84d5b38c15420"}, ] [package.dependencies] nodeenv = ">=1.6.0" -typing-extensions = ">=4.1" [package.extras] -all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +all = ["twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] -nodejs = ["nodejs-wheel-binaries"] [[package]] name = "pytest" @@ -866,13 +875,13 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.0.1" description = "A lil' TOML parser" optional = false -python-versions = ">=3.8" +python-versions = ">=3.7" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, ] [[package]] @@ -908,13 +917,13 @@ files = [ [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.26.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, ] [package.dependencies] From 67bb3ae59850103b4914e4cc69cbb660df2f7c6d Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 12:55:32 +0300 Subject: [PATCH 03/64] fix makefile, one more attempt to fix pipeline --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 92a955b..279381b 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ all: build +build: install + poetry build + install: poetry install From c67a4192e33a9d84b6d408d1f75247082d7e4c84 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 17:02:37 +0300 Subject: [PATCH 04/64] fix ort pipeline --- .ort.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ort.yml b/.ort.yml index a81c96d..23d0e54 100644 --- a/.ort.yml +++ b/.ort.yml @@ -12,9 +12,9 @@ excludes: comment: "Packages for testing only." resolutions: rule_violations: - - message: ".*PyPI::httpcore:0\\.18\\.0.*" + - message: ".*PyPI::httpcore:1\\.0\\.05*" reason: "CANT_FIX_EXCEPTION" comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpcore/blob/0.18.0/LICENSE.md" - - message: ".*PyPI::httpx:0\\.25\\.0.*" + - message: ".*PyPI::httpx:0\\.25\\.2.*" reason: "CANT_FIX_EXCEPTION" comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpx/blob/0.25.0/LICENSE.md" \ No newline at end of file From 37586e531d388d2579405387522daea83b41674b Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 17:05:06 +0300 Subject: [PATCH 05/64] add new account to code owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7a8f93f..ba55c18 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ * @adubovik -/.github/ @nepalevov @alexey-ban \ No newline at end of file +/.github/ @nepalevov @alexey-ban @roman-romanov-o \ No newline at end of file From c646f813af7eccd59e40136223d1b4d804ed2caa Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 17:18:51 +0300 Subject: [PATCH 06/64] fix ort one more time --- .ort.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.ort.yml b/.ort.yml index 23d0e54..7475073 100644 --- a/.ort.yml +++ b/.ort.yml @@ -12,7 +12,7 @@ excludes: comment: "Packages for testing only." resolutions: rule_violations: - - message: ".*PyPI::httpcore:1\\.0\\.05*" + - message: ".*PyPI::httpcore:1\\.0\\.5*" reason: "CANT_FIX_EXCEPTION" comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpcore/blob/0.18.0/LICENSE.md" - message: ".*PyPI::httpx:0\\.25\\.2.*" From 1fcc22bca539eba2d33302b6b682d905a8fb4b01 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 18:12:54 +0300 Subject: [PATCH 07/64] fixes due to pr comments --- .github/CODEOWNERS | 4 ++-- noxfile.py | 22 ++++++++++++++++------ pyproject.toml | 6 ++---- tests/test_auth.py | 6 +----- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ba55c18..0ef014d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -* @adubovik -/.github/ @nepalevov @alexey-ban @roman-romanov-o \ No newline at end of file +* @adubovik @roman-romanov-o +/.github/ @nepalevov @alexey-ban \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index c237b08..0502cf3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -32,7 +32,13 @@ def lint(session: nox.Session): def coverage(session: nox.Session) -> None: """Run tests and generate coverage report""" session.run("poetry", "install", external=True) - session.run("pytest", "--cov=.", "--cov-report=xml", "--cov-report=term") + session.run( + "pytest", + f"--cov={SRC}", + "--cov-report=xml", + "--cov-report=term", + "--ignore=tests/integration", + ) session.run("coverage", "html") @@ -50,9 +56,11 @@ def format(session: nox.Session): def test(session: nox.Session, pydantic: str, httpx: str, openai: str) -> None: """Runs tests""" session.run("poetry", "install", external=True) - session.install(f"pydantic=={pydantic}") - session.install(f"httpx=={httpx}") - session.install(f"openai=={openai}") + session.install( + f"pydantic=={pydantic}", + f"httpx=={httpx}", + f"openai=={openai}", + ) session.run("pytest", "tests", "--ignore=tests/integration") @@ -62,6 +70,8 @@ def test(session: nox.Session, pydantic: str, httpx: str, openai: str) -> None: def integration_test(session: nox.Session, pydantic: str, openai: str) -> None: """Run integration tests""" session.run("poetry", "install", external=True) - session.install(f"pydantic=={pydantic}") - session.install(f"openai=={openai}") + session.install( + f"pydantic=={pydantic}", + f"openai=={openai}", + ) session.run("pytest", "tests/integration") diff --git a/pyproject.toml b/pyproject.toml index ce023e6..befbc01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ codespell = "^2.3.0" [tool.pytest.ini_options] testpaths = ["tests"] +filterwarnings = ["error::pytest.PytestUnhandledCoroutineWarning"] [tool.pyright] typeCheckingMode = "basic" @@ -52,7 +53,6 @@ exclude = [ ".pytest_cache", "**/__pycache__", "build", - 'examples', ] [tool.black] @@ -64,7 +64,6 @@ exclude = ''' | \.nox | \.pytest_cache | \.__pycache__ - | examples )/ ''' @@ -78,7 +77,7 @@ remove_all_unused_imports = true in_place = true recursive = true quiet = true -exclude = [".nox", ".pytest_cache", "\\.venv", 'examples'] +exclude = [".nox", ".pytest_cache", "\\.venv"] [tool.coverage.run] source = ["."] @@ -88,5 +87,4 @@ omit = [ "noxfile.py", 'aidial_client/_compatibility/pydantic_v1.py', 'htmlcov/*', - 'examples/*', ] diff --git a/tests/test_auth.py b/tests/test_auth.py index 1a22a20..56bb4cf 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -20,13 +20,9 @@ async def _test_async_getter() -> str: _test_getter, {"api-key": "test-value"}, ), - ( - _test_async_getter, - {"api-key": "test-value"}, - ), ], ) -async def test_api_key(api_key_value, expected_headers): +def test_api_key(api_key_value, expected_headers): client = Dial(api_key=api_key_value, base_url="http://dial.core") assert client.auth_headers() == expected_headers From 863adb3d94a51b74782eb6fd0a145e180c3ee4e9 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Tue, 8 Oct 2024 18:15:41 +0300 Subject: [PATCH 08/64] remove examples from ort --- .ort.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.ort.yml b/.ort.yml index 7475073..df29451 100644 --- a/.ort.yml +++ b/.ort.yml @@ -1,8 +1,5 @@ --- excludes: - paths: - - pattern: "examples/**" - reason: "EXAMPLE_OF" scopes: - pattern: "lint" reason: "DEV_DEPENDENCY_OF" From 4e6d38dc7a40caeddb3fc344d6834b93329ac309 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 11:48:49 +0300 Subject: [PATCH 09/64] Fix typing of FileContent --- aidial_client/_internal_types/_http_request.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/aidial_client/_internal_types/_http_request.py b/aidial_client/_internal_types/_http_request.py index f524df1..da93638 100644 --- a/aidial_client/_internal_types/_http_request.py +++ b/aidial_client/_internal_types/_http_request.py @@ -1,4 +1,4 @@ -from io import IOBase +from io import BufferedReader from typing import ( IO, Any, @@ -16,7 +16,14 @@ from aidial_client._compatibility.pydantic_v1 import BaseModel from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven -FileContent = Union[IO[bytes], bytes, str, IOBase] +FileContent = Union[ + IO[bytes], + bytes, + str, + # Somehow, pydantic doesn't recognize result of open('...', 'rb') as IO[bytes] + # even though BufferedReader is a subclass of IO[bytes] + BufferedReader, +] FileTypes = Union[ # file (or bytes) FileContent, From 398caf24eeee55d6fb0d52bf41caf30e19c60123 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 11:49:35 +0300 Subject: [PATCH 10/64] remove typing ignore for files --- aidial_client/_http_client/_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index 73cb948..19689b3 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -66,7 +66,7 @@ def _build_request( httpx.QueryParams(options.params) if options.params else None ), json=options.json_data, - files=options.files, # type: ignore + files=options.files, timeout=options.get_timeout(self._timeout), ) From 8a26c63f8de9770bee262c174881fccd72189514 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 11:50:15 +0300 Subject: [PATCH 11/64] add aiofiles parametrization for tests --- noxfile.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/noxfile.py b/noxfile.py index 0502cf3..23ef63d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -53,13 +53,17 @@ def format(session: nox.Session): @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("httpx", ["0.25.0", "0.27.0"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -def test(session: nox.Session, pydantic: str, httpx: str, openai: str) -> None: +@nox.parametrize("aiofiles", ["24.1.0", "22.1.0"]) +def test( + session: nox.Session, pydantic: str, httpx: str, openai: str, aiofiles: str +) -> None: """Runs tests""" session.run("poetry", "install", external=True) session.install( f"pydantic=={pydantic}", f"httpx=={httpx}", f"openai=={openai}", + f"aiofiles=={aiofiles}", ) session.run("pytest", "tests", "--ignore=tests/integration") @@ -67,11 +71,15 @@ def test(session: nox.Session, pydantic: str, httpx: str, openai: str) -> None: @nox.session(python=["3.11"]) @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -def integration_test(session: nox.Session, pydantic: str, openai: str) -> None: +@nox.parametrize("aiofiles", ["24.1.0", "22.1.0"]) +def integration_test( + session: nox.Session, pydantic: str, openai: str, aiofiles: str +) -> None: """Run integration tests""" session.run("poetry", "install", external=True) session.install( f"pydantic=={pydantic}", f"openai=={openai}", + f"aiofiles=={aiofiles}", ) session.run("pytest", "tests/integration") From 7d503142104fa6c4b86d2eaa009172dbbf49c107 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 11:52:33 +0300 Subject: [PATCH 12/64] refactor auth function/naming, use assert never for unreachable --- aidial_client/_auth.py | 72 +++++++++++++++------------- aidial_client/_http_client/_async.py | 4 +- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/aidial_client/_auth.py b/aidial_client/_auth.py index 3a704cd..ba0a1b7 100644 --- a/aidial_client/_auth.py +++ b/aidial_client/_auth.py @@ -1,6 +1,8 @@ from enum import Enum from inspect import isawaitable -from typing import Awaitable, Callable, Dict, TypeVar, Union +from typing import Awaitable, Callable, Dict, TypeVar, Union, overload + +from typing_extensions import assert_never class AuthType(Enum): @@ -17,44 +19,50 @@ class AuthType(Enum): ) +@overload +def get_auth_value(auth_value: SyncAuthValue) -> str: ... + + +@overload +def get_auth_value( + auth_value: AsyncAuthValue, +) -> Union[str, Awaitable[str]]: ... + + +def get_auth_value( + auth_value: Union[SyncAuthValue, AsyncAuthValue] +) -> Union[str, Awaitable[str]]: + if isinstance(auth_value, str): + return auth_value + elif callable(auth_value): + return auth_value() + else: + assert_never(auth_value) + + +def _get_auth_headers(auth_type: AuthType, auth_value: str) -> Dict[str, str]: + if auth_type == AuthType.API_KEY: + return {"api-key": auth_value} + elif auth_type == AuthType.BEARER: + return {"Authorization": f"Bearer {auth_value}"} + else: + assert_never(auth_type) + + def get_auth_headers( *, auth_value: SyncAuthValue, auth_type: AuthType, ) -> Dict[str, str]: - if auth_type == AuthType.API_KEY: - if isinstance(auth_value, str): - return {"api-key": auth_value} - elif callable(auth_value): - return {"api-key": auth_value()} - elif auth_type == AuthType.BEARER: - if isinstance(auth_value, str): - return {"Authorization": f"Bearer {auth_value}"} - elif callable(auth_value): - return {"Authorization": f"Bearer {auth_value()}"} - else: - raise NotImplementedError("Unsupported auth") + processed_auth_value = get_auth_value(auth_value) + return _get_auth_headers(auth_type, processed_auth_value) -async def get_async_auth_headers( +async def aget_auth_headers( auth_value: AsyncAuthValue, auth_type: AuthType, ) -> Dict[str, str]: - if auth_type == AuthType.API_KEY: - if isinstance(auth_value, str): - return {"api-key": auth_value} - elif callable(auth_value): - result = auth_value() - if isawaitable(result): - return {"api-key": await result} - return {"api-key": result} - elif auth_type == AuthType.BEARER: - if isinstance(auth_value, str): - return {"Authorization": f"Bearer {auth_value}"} - elif callable(auth_value): - result = auth_value() - if isawaitable(result): - return {"Authorization": f"Bearer {await result}"} - return {"Authorization": f"Bearer {result}"} - else: - raise NotImplementedError("Unsupported auth") + processed_auth_value = get_auth_value(auth_value) + if isawaitable(processed_auth_value): + processed_auth_value = await processed_auth_value + return _get_auth_headers(auth_type, processed_auth_value) diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py index c3fd0a7..b963ba2 100644 --- a/aidial_client/_http_client/_async.py +++ b/aidial_client/_http_client/_async.py @@ -4,7 +4,7 @@ import httpx -from aidial_client._auth import AsyncAuthValue, get_async_auth_headers +from aidial_client._auth import AsyncAuthValue, aget_auth_headers from aidial_client._exception import DialException from aidial_client._http_client._base import BaseHTTPClient from aidial_client._internal_types._generic import ResponseT @@ -20,7 +20,7 @@ def _create_internal_client(self) -> httpx.AsyncClient: ) async def auth_headers(self) -> Dict[str, str]: - return await get_async_auth_headers( + return await aget_auth_headers( auth_value=self._auth_value, auth_type=self._auth_type ) From ecb5f4cfeccbb165d4a186ad7ffb15b3fe8ea8a1 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 11:55:47 +0300 Subject: [PATCH 13/64] One more minor refactor of auth --- aidial_client/_auth.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/aidial_client/_auth.py b/aidial_client/_auth.py index ba0a1b7..e3c5f7f 100644 --- a/aidial_client/_auth.py +++ b/aidial_client/_auth.py @@ -40,6 +40,13 @@ def get_auth_value( assert_never(auth_value) +async def aget_auth_value(auth_value: AsyncAuthValue) -> str: + processed_auth_value = get_auth_value(auth_value) + if isawaitable(processed_auth_value): + return await processed_auth_value + return processed_auth_value + + def _get_auth_headers(auth_type: AuthType, auth_value: str) -> Dict[str, str]: if auth_type == AuthType.API_KEY: return {"api-key": auth_value} @@ -62,7 +69,5 @@ async def aget_auth_headers( auth_value: AsyncAuthValue, auth_type: AuthType, ) -> Dict[str, str]: - processed_auth_value = get_auth_value(auth_value) - if isawaitable(processed_auth_value): - processed_auth_value = await processed_auth_value + processed_auth_value = await aget_auth_value(auth_value) return _get_auth_headers(auth_type, processed_auth_value) From d5c2bd0901027bf35a6eaaf3f3952aa28eaeb4ca Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 12:03:44 +0300 Subject: [PATCH 14/64] refactor auth one more time, refactor client interface --- aidial_client/_auth.py | 28 +++++++++++++++++++++++++++- aidial_client/_client.py | 27 ++++++--------------------- aidial_client/_client_pool.py | 16 +++++++++------- aidial_client/_constants.py | 2 +- aidial_client/_http_client/_base.py | 4 ++-- 5 files changed, 45 insertions(+), 32 deletions(-) diff --git a/aidial_client/_auth.py b/aidial_client/_auth.py index e3c5f7f..28033cd 100644 --- a/aidial_client/_auth.py +++ b/aidial_client/_auth.py @@ -1,6 +1,15 @@ from enum import Enum from inspect import isawaitable -from typing import Awaitable, Callable, Dict, TypeVar, Union, overload +from typing import ( + Awaitable, + Callable, + Dict, + Optional, + Tuple, + TypeVar, + Union, + overload, +) from typing_extensions import assert_never @@ -71,3 +80,20 @@ async def aget_auth_headers( ) -> Dict[str, str]: processed_auth_value = await aget_auth_value(auth_value) return _get_auth_headers(auth_type, processed_auth_value) + + +def process_auth( + *, + api_key: Optional[AuthValueT] = None, + bearer_token: Optional[AuthValueT] = None, +) -> Tuple[AuthType, AuthValueT]: + if api_key and bearer_token: + raise ValueError( + "Either api_key or bearer_token must be provided, but not both" + ) + elif api_key: + return AuthType.API_KEY, api_key + elif bearer_token: + return AuthType.BEARER, bearer_token + else: + raise ValueError("Either api_key or bearer_token must be provided") diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 32ed76d..b2630d1 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from pathlib import PurePosixPath -from typing import Dict, Generic, Optional, Tuple, TypeVar, Union, cast +from typing import Dict, Generic, Optional, TypeVar, Union, cast from urllib.parse import urljoin import openai @@ -12,6 +12,7 @@ AuthType, AuthValueT, SyncAuthValue, + process_auth, ) from aidial_client._constants import ( API_PREFIX, @@ -35,41 +36,25 @@ class BaseDialClient(Generic[_HttpClientT, AuthValueT], ABC): _http_client: _HttpClientT _auth_headers: Dict[str, str] - @staticmethod - def process_auth( - api_key: Optional[AuthValueT] = None, - bearer_token: Optional[AuthValueT] = None, - ) -> Tuple[AuthType, AuthValueT]: - if api_key and bearer_token: - raise ValueError( - "Either api_key or bearer_token must be provided, but not both" - ) - elif api_key: - return AuthType.API_KEY, api_key - elif bearer_token: - return AuthType.BEARER, bearer_token - else: - raise ValueError("Either api_key or bearer_token must be provided") - def __init__( self, *, + base_url: str, api_key: Optional[AuthValueT] = None, bearer_token: Optional[AuthValueT] = None, - base_url: str, max_retries: int = DEFAULT_MAX_RETRIES, timeout: Union[float, Timeout, None] = DEFAULT_TIMEOUT, api_version: Optional[str] = None, - _http_client: Optional[_HttpClientT] = None, + http_client: Optional[_HttpClientT] = None, ): - self._auth_type, self._auth_value = self.process_auth( + self._auth_type, self._auth_value = process_auth( api_key=api_key, bearer_token=bearer_token ) self._max_retries = max_retries self._timeout = timeout self._base_url = enforce_trailing_slash(base_url) self._api_version = api_version - self._http_client = _http_client or self._create_http_client() + self._http_client = http_client or self._create_http_client() self._init_resources() @abstractmethod diff --git a/aidial_client/_client_pool.py b/aidial_client/_client_pool.py index 44d539c..05f308a 100644 --- a/aidial_client/_client_pool.py +++ b/aidial_client/_client_pool.py @@ -2,7 +2,7 @@ import httpx -from aidial_client._auth import AsyncAuthValue, SyncAuthValue +from aidial_client._auth import AsyncAuthValue, SyncAuthValue, process_auth from aidial_client._client import AsyncDial, Dial from aidial_client._constants import ( DEFAULT_CONNECTION_LIMITS, @@ -32,18 +32,20 @@ def create_client( max_retries: int = DEFAULT_MAX_RETRIES, timeout: Union[httpx.Timeout, float] = DEFAULT_TIMEOUT, ) -> Dial: - auth_type, auth_value = Dial.process_auth(api_key, bearer_token) + auth_type, auth_value = process_auth( + api_key=api_key, bearer_token=bearer_token + ) return Dial( base_url=base_url, api_key=api_key, bearer_token=bearer_token, - _http_client=SyncHTTPClient( + http_client=SyncHTTPClient( base_url=base_url, auth_value=auth_value, auth_type=auth_type, max_retries=max_retries, timeout=timeout, - _client=self._internal_http_client, + internal_client=self._internal_http_client, ), ) @@ -68,19 +70,19 @@ def create_client( max_retries: int = DEFAULT_MAX_RETRIES, timeout: Union[httpx.Timeout, float] = DEFAULT_TIMEOUT, ) -> AsyncDial: - auth_type, auth_value = AsyncDial.process_auth( + auth_type, auth_value = process_auth( api_key=api_key, bearer_token=bearer_token ) return AsyncDial( base_url=base_url, api_key=api_key, bearer_token=bearer_token, - _http_client=AsyncHTTPClient( + http_client=AsyncHTTPClient( base_url=base_url, auth_value=auth_value, auth_type=auth_type, max_retries=max_retries, timeout=timeout, - _client=self._internal_http_client, + internal_client=self._internal_http_client, ), ) diff --git a/aidial_client/_constants.py b/aidial_client/_constants.py index 19bc6bc..bdfc9c6 100644 --- a/aidial_client/_constants.py +++ b/aidial_client/_constants.py @@ -15,4 +15,4 @@ OPENAI_PREFIX = "openai/" -APPLICATION_PREFIX = f"{OPENAI_PREFIX}applications/" +APPLICATION_PREFIX = urljoin(OPENAI_PREFIX, "applications/") diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index 19689b3..d63e9e5 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -29,14 +29,14 @@ def __init__( auth_type: AuthType, max_retries: int, timeout: Union[float, httpx.Timeout, None], - _client: Optional[_HttpInternalClientT] = None, + internal_client: Optional[_HttpInternalClientT] = None, ): self.base_url = httpx.URL(enforce_trailing_slash(base_url)) self._auth_value = auth_value self._auth_type = auth_type self._max_retries = max_retries self._timeout = timeout - self._client = _client or self._create_internal_client() + self._client = internal_client or self._create_internal_client() @abstractmethod def _create_internal_client( From 82d83ebf766a50b8ea37ad1cff7d6c00f291f012 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 12:10:06 +0300 Subject: [PATCH 15/64] refactor my_bucket for clients --- aidial_client/_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index b2630d1..7f240bc 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -35,6 +35,7 @@ class BaseDialClient(Generic[_HttpClientT, AuthValueT], ABC): _base_url: str _http_client: _HttpClientT _auth_headers: Dict[str, str] + _my_bucket: Optional[str] def __init__( self, @@ -55,6 +56,7 @@ def __init__( self._base_url = enforce_trailing_slash(base_url) self._api_version = api_version self._http_client = http_client or self._create_http_client() + self._my_bucket = None self._init_resources() @abstractmethod @@ -119,7 +121,7 @@ def _get_my_bucket(self) -> str: return self.bucket.get_bucket() def my_bucket(self) -> str: - if not hasattr(self, "_my_bucket") or not getattr(self, "_my_bucket"): + if self._my_bucket is None: self._my_bucket = self._get_my_bucket() return cast(str, self._my_bucket) @@ -200,11 +202,9 @@ async def _get_my_bucket(self) -> str: return await self.bucket.get_bucket() async def my_bucket(self) -> str: - if not hasattr(self, "_my_bucket") or not getattr(self, "_my_bucket"): + if self._my_bucket is None: self._my_bucket = await self._get_my_bucket() - return self._my_bucket - else: - return cast(str, self._my_bucket) + return cast(str, self._my_bucket) async def my_files_home(self) -> PurePosixPath: return "files" / PurePosixPath(await self.my_bucket()) From 293c1167078b680906b2a2670848d32811839e9f Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 12:12:36 +0300 Subject: [PATCH 16/64] Remove redundant cast --- aidial_client/_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 7f240bc..f292d8c 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from pathlib import PurePosixPath -from typing import Dict, Generic, Optional, TypeVar, Union, cast +from typing import Dict, Generic, Optional, TypeVar, Union from urllib.parse import urljoin import openai @@ -123,7 +123,7 @@ def _get_my_bucket(self) -> str: def my_bucket(self) -> str: if self._my_bucket is None: self._my_bucket = self._get_my_bucket() - return cast(str, self._my_bucket) + return self._my_bucket def my_files_home(self) -> PurePosixPath: return "files" / PurePosixPath(self.my_bucket()) @@ -204,7 +204,7 @@ async def _get_my_bucket(self) -> str: async def my_bucket(self) -> str: if self._my_bucket is None: self._my_bucket = await self._get_my_bucket() - return cast(str, self._my_bucket) + return self._my_bucket async def my_files_home(self) -> PurePosixPath: return "files" / PurePosixPath(await self.my_bucket()) From cedac60ccb0b29160e2cffc0be4074f8911dee04 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 12:14:58 +0300 Subject: [PATCH 17/64] fix for appdata return type --- aidial_client/_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index f292d8c..2cac588 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -142,10 +142,10 @@ def my_appdata(self) -> Optional[AppData]: self._my_appdata = self._get_my_appdata() return self._my_appdata - def my_appdata_home(self) -> Optional[str]: + def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = self.my_appdata() if appdata: - return appdata.raw + return PurePosixPath(appdata.raw) return None def auth_headers(self) -> Dict[str, str]: @@ -223,10 +223,10 @@ async def my_appdata(self) -> Optional[AppData]: self._my_appdata = await self._get_my_appdata() return self._my_appdata - async def my_appdata_home(self) -> Optional[str]: + async def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = await self.my_appdata() if appdata: - return appdata.raw + return PurePosixPath(appdata.raw) return None async def auth_headers(self) -> Dict[str, str]: From 374ebb54112ec884d84ef98ecec4cb78a3554bde Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 12:36:59 +0300 Subject: [PATCH 18/64] Another bunch of refactor for helpers --- aidial_client/helpers/_url.py | 14 ++++++++++++++ aidial_client/helpers/storage_resource.py | 11 ++++++----- aidial_client/helpers/url.py | 20 -------------------- 3 files changed, 20 insertions(+), 25 deletions(-) create mode 100644 aidial_client/helpers/_url.py delete mode 100644 aidial_client/helpers/url.py diff --git a/aidial_client/helpers/_url.py b/aidial_client/helpers/_url.py new file mode 100644 index 0000000..9249bcb --- /dev/null +++ b/aidial_client/helpers/_url.py @@ -0,0 +1,14 @@ +def enforce_trailing_slash(url: str) -> str: + if url.endswith("/"): + return url + return url + "/" + + +def remove_prefix(s: str, prefix: str) -> str: + if s.startswith(prefix): + return s[len(prefix) :] + return s + + +def remove_leading_slash(url: str) -> str: + return remove_prefix(url, "/") diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index ce12b91..3d41acc 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -3,11 +3,12 @@ from urllib.parse import urljoin, urlparse from aidial_client._compatibility.pydantic_v1 import BaseModel +from aidial_client._constants import API_PREFIX from aidial_client._exception import InvalidDialURLException -from aidial_client.helpers.url import ( +from aidial_client.helpers._url import ( enforce_trailing_slash, - remove_api_prefix, remove_leading_slash, + remove_prefix, ) @@ -61,7 +62,7 @@ def parse_storage_resource( dial_api_url = enforce_trailing_slash(dial_api_url) url = remove_leading_slash(url) # If the URL starts with API_PREFIX, remove it - url = remove_api_prefix(url) + url = remove_prefix(url, API_PREFIX) absolute_url = urljoin(dial_api_url, remove_leading_slash(url)) url_parsed = urlparse(absolute_url) @@ -86,8 +87,8 @@ def parse_storage_resource( resource_path = api_path.parents[len(api_path.parents) - 2] if str(resource_path) != resource_type: raise InvalidDialURLException( - f"Invalid resource type for url: {url}" - f"Expected: {resource_type},got: {resource_type}" + f"Invalid resource type for url: {url}\n" + f"Expected: {resource_type}, got: {resource_path}" ) if len(api_path.parents) < 3: diff --git a/aidial_client/helpers/url.py b/aidial_client/helpers/url.py deleted file mode 100644 index d3dafd5..0000000 --- a/aidial_client/helpers/url.py +++ /dev/null @@ -1,20 +0,0 @@ -from aidial_client._constants import API_PREFIX - - -def enforce_trailing_slash(url: str) -> str: - if url.endswith("/"): - return url - return url + "/" - - -def remove_leading_slash(url: str) -> str: - if url.startswith("/"): - return url.lstrip("/") - return url - - -def remove_api_prefix(url): - if url.startswith(API_PREFIX): - api_prefix_len = len(API_PREFIX) - url = url[api_prefix_len:] - return url From 7de45fe06793bf80abf343334babf8a856b41dbf Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:03:48 +0300 Subject: [PATCH 19/64] Bunch of refactoring, fix unit tests --- aidial_client/_client.py | 2 +- aidial_client/_http_client/_base.py | 2 +- aidial_client/helpers/_url.py | 2 +- aidial_client/helpers/storage_resource.py | 22 +++++--------- aidial_client/resources/metadata.py | 7 +++-- tests/helpers/test_url_helpers.py | 2 +- tests/test_client_retry.py | 37 ++++++++++++----------- 7 files changed, 36 insertions(+), 38 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 2cac588..3e97084 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -21,7 +21,7 @@ OPENAI_PREFIX, ) from aidial_client._http_client import AsyncHTTPClient, SyncHTTPClient -from aidial_client.helpers.url import enforce_trailing_slash +from aidial_client.helpers._url import enforce_trailing_slash from aidial_client.types.bucket import AppData _HttpClientT = TypeVar( diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index d63e9e5..86e057b 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -10,7 +10,7 @@ from aidial_client._exception import DialException from aidial_client._internal_types._http_request import FinalRequestOptions from aidial_client._utils._type_guard import is_mapping -from aidial_client.helpers.url import enforce_trailing_slash +from aidial_client.helpers._url import enforce_trailing_slash _HttpInternalClientT = TypeVar( "_HttpInternalClientT", bound=Union[httpx.Client, httpx.AsyncClient] diff --git a/aidial_client/helpers/_url.py b/aidial_client/helpers/_url.py index 9249bcb..51c4ceb 100644 --- a/aidial_client/helpers/_url.py +++ b/aidial_client/helpers/_url.py @@ -6,7 +6,7 @@ def enforce_trailing_slash(url: str) -> str: def remove_prefix(s: str, prefix: str) -> str: if s.startswith(prefix): - return s[len(prefix) :] + s = s.lstrip(prefix) return s diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 3d41acc..8a79179 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -11,9 +11,12 @@ remove_prefix, ) +StorageResourceType = Literal["files", "conversations", "prompts"] + class DialStorageResource(BaseModel): - resource_type: Literal["files", "conversations", "prompts"] + resource_type: StorageResourceType + """Bucket name, like 'my-bucket'""" bucket: str @@ -39,7 +42,7 @@ class DialStorageResource(BaseModel): def parse_storage_resource( url: str, dial_api_url: str, - resource_type: Literal["files", "conversations", "prompts"], + resource_type: StorageResourceType, ignore_non_dial_url: Literal[True], ) -> Optional[DialStorageResource]: ... @@ -48,7 +51,7 @@ def parse_storage_resource( def parse_storage_resource( url: str, dial_api_url: str, - resource_type: Literal["files", "conversations", "prompts"], + resource_type: StorageResourceType, ignore_non_dial_url: Literal[False], ) -> DialStorageResource: ... @@ -56,7 +59,7 @@ def parse_storage_resource( def parse_storage_resource( url: str, dial_api_url: str, - resource_type: Literal["files", "conversations", "prompts"], + resource_type: StorageResourceType, ignore_non_dial_url: bool, ) -> Optional[DialStorageResource]: dial_api_url = enforce_trailing_slash(dial_api_url) @@ -115,7 +118,7 @@ class DialStorageResourceMixin(BaseModel): - /v1/prompts """ - resource_type: Literal["files", "conversations", "prompts"] + resource_type: StorageResourceType dial_api_url: str def get_storage_resource(self, url: str) -> DialStorageResource: @@ -136,20 +139,11 @@ def get_storage_resource(self, url: str) -> DialStorageResource: def get_api_path(self, url: str) -> str: """ Convert URL, that could relative or absolute, to relative URL - Args: - url (str): The URL to be processed. - dial_api_url (str): The DIAL API URL to validate against. - Returns: - str: relative to DIAL API URL """ return self.get_storage_resource(url).api_path def get_display_name(self, url: str) -> str: """ Get the display name of the resource from the URL - Args: - url (str): The URL to be processed. - Returns: - str: The display name of the resource """ return self.get_storage_resource(url).bucket_path diff --git a/aidial_client/resources/metadata.py b/aidial_client/resources/metadata.py index be441b9..a9c3e89 100644 --- a/aidial_client/resources/metadata.py +++ b/aidial_client/resources/metadata.py @@ -4,6 +4,7 @@ from aidial_client._constants import METADATA_PREFIX from aidial_client._exception import DialException from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client.helpers.storage_resource import StorageResourceType from aidial_client.resources.base import AsyncResource, Resource from aidial_client.types.metadata import ( ConversationMetadata, @@ -13,7 +14,7 @@ def _get_cast_to( - resource: Literal["files", "conversations", "prompts"] + resource: StorageResourceType, ) -> Union[ Type[FileMetadata], Type[ConversationMetadata], Type[PromptMetadata] ]: @@ -47,7 +48,7 @@ def get( def get( self, - resource: Literal["files", "conversations", "prompts"], + resource: StorageResourceType, relative_url: str, ) -> Union[FileMetadata, ConversationMetadata, PromptMetadata]: return self.http_client.request( @@ -77,7 +78,7 @@ async def get( async def get( self, - resource: Literal["files", "conversations", "prompts"], + resource: StorageResourceType, relative_url: str, ) -> Union[FileMetadata, ConversationMetadata, PromptMetadata]: return await self.http_client.request( diff --git a/tests/helpers/test_url_helpers.py b/tests/helpers/test_url_helpers.py index 3abbc39..103b574 100644 --- a/tests/helpers/test_url_helpers.py +++ b/tests/helpers/test_url_helpers.py @@ -1,6 +1,6 @@ import pytest -from aidial_client.helpers.url import ( +from aidial_client.helpers._url import ( enforce_trailing_slash, remove_leading_slash, ) diff --git a/tests/test_client_retry.py b/tests/test_client_retry.py index 745ea20..16a0900 100644 --- a/tests/test_client_retry.py +++ b/tests/test_client_retry.py @@ -9,13 +9,13 @@ @pytest.mark.parametrize( - "exception", + "exception, expected_message", [ - httpx.TimeoutException("Request timed out"), - Exception("Some unknown error"), + (httpx.TimeoutException("Request timed out"), "Request timed out"), + (Exception("Unknown"), "Unknown error during request"), ], ) -def test_exception_retry_sync(exception): +def test_exception_retry_sync(exception, expected_message): client = get_client_mock( status_code=None, exception_mock=exception, @@ -28,24 +28,26 @@ def test_exception_retry_sync(exception): # If retries are not exhausted, the request should be retried remaining_retries_mock.return_value = 1 client.bucket.get_bucket() - assert retry_request_mock.called + assert retry_request_mock.call_count == 1 # After retries are exhausted, the exception should be raised remaining_retries_mock.return_value = 0 with pytest.raises(Exception) as e: client.bucket.get_bucket() - assert isinstance(e, exception.__class__) - assert not retry_request_mock.called + assert isinstance(e.value, DialException) + assert e.value.message == expected_message + # Retry request should not be called again + assert retry_request_mock.call_count == 1 @pytest.mark.parametrize( - "exception", + "exception, expected_message", [ - httpx.TimeoutException("Request timed out"), - Exception("Some unknown error"), + (httpx.TimeoutException("Request timed out"), "Request timed out"), + (Exception("Unknown"), "Unknown error during request"), ], ) @pytest.mark.asyncio -async def test_exception_retry_async(exception): +async def test_exception_retry_async(exception, expected_message): client = get_async_client_mock( status_code=None, exception_mock=exception, @@ -58,13 +60,14 @@ async def test_exception_retry_async(exception): # If retries are not exhausted, the request should be retried remaining_retries_mock.return_value = 1 await client.bucket.get_bucket() - assert retry_request_mock.called + assert retry_request_mock.call_count == 1 # After retries are exhausted, the exception should be raised remaining_retries_mock.return_value = 0 with pytest.raises(Exception) as e: await client.bucket.get_bucket() - assert isinstance(e, exception.__class__) - assert not retry_request_mock.called + assert isinstance(e.value, DialException) + assert e.value.message == expected_message + assert retry_request_mock.call_count == 1 @pytest.mark.parametrize( @@ -92,8 +95,8 @@ def test_status_codes_retries(status_code, is_retry_called): else: with pytest.raises(DialException) as e: client.bucket.get_bucket() - assert e.status_code == status_code # type: ignore - assert not retry_request_mock.called + assert e.value.status_code == status_code # type: ignore + assert not retry_request_mock.called @pytest.mark.parametrize( @@ -122,4 +125,4 @@ async def test_status_codes_retries_async(status_code, is_retry_called): else: with pytest.raises(DialException) as e: await client.bucket.get_bucket() - assert e.status_code == status_code # type: ignore + assert e.value.status_code == status_code # type: ignore From 1a7edd7e3db4d6a684e4ec3ec935c8754ca73e7a Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:13:10 +0300 Subject: [PATCH 20/64] More refactoring --- aidial_client/_exception.py | 4 +++- aidial_client/_http_client/_base.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py index 113820f..e16abf4 100644 --- a/aidial_client/_exception.py +++ b/aidial_client/_exception.py @@ -50,8 +50,10 @@ def json_error(self) -> dict: @classmethod def from_error_data( - cls, message: str, status_code: int, error_data: Mapping + cls, status_code: int, error_data: Mapping ) -> "DialException": + message = error_data["message"] + assert isinstance(message, str) return cls( message=message, status_code=status_code, diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index 86e057b..21aa10e 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -115,17 +115,17 @@ def _make_dial_error_from_response( # We can't read the response body as it has been closed # before it was read. This can happen if an event hook # raises a status error. - return DialException(message="", status_code=response.status_code) + return DialException( + message="Stream was interrupted", + status_code=response.status_code, + ) try: message_data = response.json() assert is_mapping(message_data) error_data = message_data["error"] assert is_mapping(error_data) - message = error_data["message"] - assert isinstance(message, str) return DialException.from_error_data( - message=message, status_code=response.status_code, error_data=error_data, ) From 199b8ad13ac3c78f02f40a14314c5e4e48e00023 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:17:57 +0300 Subject: [PATCH 21/64] remove dead code --- aidial_client/_internal_types/_stream.py | 52 ------------------------ 1 file changed, 52 deletions(-) delete mode 100644 aidial_client/_internal_types/_stream.py diff --git a/aidial_client/_internal_types/_stream.py b/aidial_client/_internal_types/_stream.py deleted file mode 100644 index c78a732..0000000 --- a/aidial_client/_internal_types/_stream.py +++ /dev/null @@ -1,52 +0,0 @@ -import json -from typing import Any, Optional - -from aidial_client._exception import DialException - - -class ServerSentEvent: - def __init__( - self, - *, - event: Optional[str], - data: str, - id: Optional[str], - retry: Optional[int], - ) -> None: - self._id = id - self._data = data - self._event = event or None - self._retry = retry - - @property - def event(self) -> Optional[str]: - return self._event - - @property - def id(self) -> Optional[str]: - return self._id - - @property - def retry(self) -> Optional[int]: - return self._retry - - @property - def data(self) -> str: - return self._data - - def json(self) -> Any: - try: - return json.loads(self.data) - except json.JSONDecodeError: - raise DialException( - message=f"Could not parse server event correctly {self.data}" - ) - - def __repr__(self) -> str: - return ( - f"ServerSentEvent(" - f"event={self.event!r}," - f"data={self.data!r}," - f"id={self.id!r}," - f"retry={self.retry!r})" - ) From 5e261c29a8ffa3667a318af524a3afda487cb114 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:20:44 +0300 Subject: [PATCH 22/64] another naming refactor --- aidial_client/_http_client/_async.py | 4 ++-- aidial_client/_http_client/_sync.py | 4 ++-- aidial_client/_utils/_response_processing.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py index b963ba2..3ae14ed 100644 --- a/aidial_client/_http_client/_async.py +++ b/aidial_client/_http_client/_async.py @@ -10,7 +10,7 @@ from aidial_client._internal_types._generic import ResponseT from aidial_client._internal_types._http_request import FinalRequestOptions from aidial_client._log import logger -from aidial_client._utils._response_processing import default_process_non_stream +from aidial_client._utils._response_processing import process_block_response class AsyncHTTPClient(BaseHTTPClient[httpx.AsyncClient, AsyncAuthValue]): @@ -107,4 +107,4 @@ async def request( ) raise raised_error from err - return default_process_non_stream(cast_to=cast_to, response=response) + return process_block_response(cast_to=cast_to, response=response) diff --git a/aidial_client/_http_client/_sync.py b/aidial_client/_http_client/_sync.py index f4a1e05..0928e6d 100644 --- a/aidial_client/_http_client/_sync.py +++ b/aidial_client/_http_client/_sync.py @@ -10,7 +10,7 @@ from aidial_client._internal_types._generic import ResponseT from aidial_client._internal_types._http_request import FinalRequestOptions from aidial_client._log import logger -from aidial_client._utils._response_processing import default_process_non_stream +from aidial_client._utils._response_processing import process_block_response class SyncHTTPClient(BaseHTTPClient[httpx.Client, SyncAuthValue]): @@ -107,4 +107,4 @@ def request( ) raise raised_error from err - return default_process_non_stream(cast_to=cast_to, response=response) + return process_block_response(cast_to=cast_to, response=response) diff --git a/aidial_client/_utils/_response_processing.py b/aidial_client/_utils/_response_processing.py index f54faa4..b78ae0b 100644 --- a/aidial_client/_utils/_response_processing.py +++ b/aidial_client/_utils/_response_processing.py @@ -10,7 +10,7 @@ ) -def default_process_non_stream( +def process_block_response( cast_to: Type[ResponseT], response: httpx.Response ) -> ResponseT: if cast_to == httpx.Response: From 6b146bc1fe141ee68087e53a0edd8d8d55995960 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:23:24 +0300 Subject: [PATCH 23/64] Remove formatting error as chunk during streaming --- aidial_client/_utils/_openai.py | 12 +----------- aidial_client/types/chat/response.py | 1 - 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/aidial_client/_utils/_openai.py b/aidial_client/_utils/_openai.py index 157f44e..8b92a55 100644 --- a/aidial_client/_utils/_openai.py +++ b/aidial_client/_utils/_openai.py @@ -42,21 +42,11 @@ def convert_openai_response( def convert_openai_stream( openai_response: Iterator[OpenAIChatCompletionChunk], ) -> Iterator[ChatCompletionChunk]: - response_id = None try: for chunk in openai_response: - response_id = chunk.id yield ChatCompletionChunk(**chunk.model_dump()) except openai.APIError as e: - yield ChatCompletionChunk( - id=response_id or str(uuid.uuid4()), - object="chat.completion.chunk", - choices=[], - created=int(time.time()), - model=None, - usage=None, - error=convert_openai_error(e).json_error(), - ) + raise convert_openai_error(e) from e async def convert_openai_async_stream( diff --git a/aidial_client/types/chat/response.py b/aidial_client/types/chat/response.py index 6192b00..e163a1b 100644 --- a/aidial_client/types/chat/response.py +++ b/aidial_client/types/chat/response.py @@ -88,4 +88,3 @@ class ChatCompletionChunk(ExtraAllowModel): created: int model: Optional[str] = None usage: Optional[CompletionUsage] = None - error: Optional[Dict] = None From 878184e5c65d574c049e3c8a503500530fa283e5 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:25:43 +0300 Subject: [PATCH 24/64] Remove formatting error as chunk during streaming (forgotten part) --- aidial_client/_exception.py | 15 --------------- aidial_client/_utils/_openai.py | 14 +------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py index e16abf4..dd14512 100644 --- a/aidial_client/_exception.py +++ b/aidial_client/_exception.py @@ -1,8 +1,6 @@ from http import HTTPStatus from typing import Mapping, Optional -from aidial_client._utils._dict import remove_none - class DialException(Exception): def __init__( @@ -35,19 +33,6 @@ def __repr__(self) -> str: def __str__(self) -> str: return self.__repr__() - def json_error(self) -> dict: - return { - "error": remove_none( - { - "message": self.message, - "type": self.type, - "param": self.param, - "code": self.code, - "display_message": self.display_message, - } - ) - } - @classmethod def from_error_data( cls, status_code: int, error_data: Mapping diff --git a/aidial_client/_utils/_openai.py b/aidial_client/_utils/_openai.py index 8b92a55..28dc51b 100644 --- a/aidial_client/_utils/_openai.py +++ b/aidial_client/_utils/_openai.py @@ -1,5 +1,3 @@ -import uuid -from time import time from typing import AsyncIterator, Iterator, Union import openai @@ -52,18 +50,8 @@ def convert_openai_stream( async def convert_openai_async_stream( openai_response: AsyncIterator[OpenAIChatCompletionChunk], ) -> AsyncIterator[ChatCompletionChunk]: - response_id = None try: async for chunk in openai_response: - response_id = chunk.id yield ChatCompletionChunk(**chunk.model_dump()) except openai.APIError as e: - yield ChatCompletionChunk( - id=response_id or str(uuid.uuid4()), - object="chat.completion.chunk", - choices=[], - created=int(time.time()), - model=None, - usage=None, - error=convert_openai_error(e).json_error(), - ) + raise convert_openai_error(e) from e From 1f18bb83b0f4b03a54c75a7497c2e95cec3802f8 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:28:09 +0300 Subject: [PATCH 25/64] Minor refactor --- aidial_client/_utils/_openai.py | 4 +--- aidial_client/_utils/_response_processing.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/aidial_client/_utils/_openai.py b/aidial_client/_utils/_openai.py index 28dc51b..f129e6b 100644 --- a/aidial_client/_utils/_openai.py +++ b/aidial_client/_utils/_openai.py @@ -8,9 +8,7 @@ from aidial_client.types.chat import ChatCompletionChunk, ChatCompletionResponse -def convert_openai_error( - error: Union[openai.APIError, openai.APIStatusError], -) -> DialException: +def convert_openai_error(error: openai.APIError) -> DialException: status_code = ( error.status_code if isinstance(error, openai.APIStatusError) else 500 ) diff --git a/aidial_client/_utils/_response_processing.py b/aidial_client/_utils/_response_processing.py index b78ae0b..a55b981 100644 --- a/aidial_client/_utils/_response_processing.py +++ b/aidial_client/_utils/_response_processing.py @@ -32,4 +32,4 @@ def process_block_response( message=f"Error during parsing of response data: {str(e)}" ) else: - raise NotImplementedError("This cast_to type is not supported yet") + raise NotImplementedError("This cast_to type is not supported.") From 8f7f911003df3ee07bc5c6d288281d5d4e143e57 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:32:23 +0300 Subject: [PATCH 26/64] Refactor, rename exceptions with `error` postfix --- aidial_client/__init__.py | 16 ++++++++-------- aidial_client/_exception.py | 8 ++++---- aidial_client/_utils/_response_processing.py | 8 +++----- aidial_client/helpers/storage_resource.py | 12 +++++------- aidial_client/resources/files.py | 4 ++-- tests/helpers/test_storage_resource_mixin.py | 10 +++++----- tests/helpers/test_storage_resource_parser.py | 6 +++--- 7 files changed, 30 insertions(+), 34 deletions(-) diff --git a/aidial_client/__init__.py b/aidial_client/__init__.py index bac7e7b..41bdca5 100644 --- a/aidial_client/__init__.py +++ b/aidial_client/__init__.py @@ -3,10 +3,10 @@ from aidial_client._client_pool import AsyncDialClientPool, DialClientPool from aidial_client._exception import ( DialException, - InvalidBucketException, - InvalidDialURLException, - InvalidRequestException, - ParsingDataException, + InvalidBucketError, + InvalidDialURLError, + InvalidRequestError, + ParsingDataError, ) __all__ = [ @@ -19,8 +19,8 @@ "AsyncAuthValue", # Exceptions "DialException", - "InvalidDialURLException", - "InvalidBucketException", - "InvalidRequestException", - "ParsingDataException", + "InvalidDialURLError", + "InvalidBucketError", + "InvalidRequestError", + "ParsingDataError", ] diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py index dd14512..f8936ea 100644 --- a/aidial_client/_exception.py +++ b/aidial_client/_exception.py @@ -49,7 +49,7 @@ def from_error_data( ) -class InvalidRequestException(DialException): +class InvalidRequestError(DialException): def __init__(self, message: str, **kwargs) -> None: super().__init__( message=message, @@ -59,15 +59,15 @@ def __init__(self, message: str, **kwargs) -> None: ) -class InvalidDialURLException(InvalidRequestException): +class InvalidDialURLError(InvalidRequestError): pass -class InvalidBucketException(InvalidRequestException): +class InvalidBucketError(InvalidRequestError): pass -class ParsingDataException(DialException): +class ParsingDataError(DialException): def __init__(self, message: str, **kwargs) -> None: super().__init__( message=message, diff --git a/aidial_client/_utils/_response_processing.py b/aidial_client/_utils/_response_processing.py index a55b981..d3e4e51 100644 --- a/aidial_client/_utils/_response_processing.py +++ b/aidial_client/_utils/_response_processing.py @@ -2,7 +2,7 @@ import httpx -from aidial_client._exception import ParsingDataException +from aidial_client._exception import ParsingDataError from aidial_client._internal_types._generic import NoneType, ResponseT from aidial_client._internal_types._model import ( ExtraAllowModel, @@ -21,14 +21,12 @@ def process_block_response( return cast(ResponseT, response.text) elif cast_to == NoneType: return cast(ResponseT, None) - elif issubclass(cast_to, ExtraForbidModel) or issubclass( - cast_to, ExtraAllowModel - ): + elif issubclass(cast_to, (ExtraForbidModel, ExtraAllowModel)): try: data = response.json() return cast_to(**data) except Exception as e: - raise ParsingDataException( + raise ParsingDataError( message=f"Error during parsing of response data: {str(e)}" ) else: diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 8a79179..695f75d 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -4,7 +4,7 @@ from aidial_client._compatibility.pydantic_v1 import BaseModel from aidial_client._constants import API_PREFIX -from aidial_client._exception import InvalidDialURLException +from aidial_client._exception import InvalidDialURLError from aidial_client.helpers._url import ( enforce_trailing_slash, remove_leading_slash, @@ -74,28 +74,26 @@ def parse_storage_resource( if url_parsed.netloc != dial_api_parsed.netloc: if ignore_non_dial_url: return None - raise InvalidDialURLException( - message="Provided url is not DIAL url: {url}" - ) + raise InvalidDialURLError(message="Provided url is not DIAL url: {url}") try: url_path = PurePosixPath(url_parsed.path) api_path = url_path.relative_to(dial_api_parsed.path) except ValueError: - raise InvalidDialURLException( + raise InvalidDialURLError( f"Provided url path {url_parsed.path} does not match with" f" DIAL API url {dial_api_parsed.path}" ) resource_path = api_path.parents[len(api_path.parents) - 2] if str(resource_path) != resource_type: - raise InvalidDialURLException( + raise InvalidDialURLError( f"Invalid resource type for url: {url}\n" f"Expected: {resource_type}, got: {resource_path}" ) if len(api_path.parents) < 3: - raise InvalidDialURLException(f"Missing bucket in url: {url}") + raise InvalidDialURLError(f"Missing bucket in url: {url}") bucket_path = api_path.parents[len(api_path.parents) - 3] diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index 4cb1447..c445575 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -5,7 +5,7 @@ import httpx from aidial_client._constants import API_PREFIX -from aidial_client._exception import DialException, InvalidBucketException +from aidial_client._exception import DialException, InvalidBucketError from aidial_client._internal_types._generic import NoneType from aidial_client._internal_types._http_request import ( FileTypes, @@ -28,7 +28,7 @@ def _error_processor(error: httpx.HTTPStatusError) -> Optional[DialException]: # when adapter will return it for this particular error and "Url has invalid bucket" in error_message ): - return InvalidBucketException(error_message) + return InvalidBucketError(error_message) except Exception: return None else: diff --git a/tests/helpers/test_storage_resource_mixin.py b/tests/helpers/test_storage_resource_mixin.py index d0fcfdc..b0b46db 100644 --- a/tests/helpers/test_storage_resource_mixin.py +++ b/tests/helpers/test_storage_resource_mixin.py @@ -1,6 +1,6 @@ import pytest -from aidial_client._exception import InvalidDialURLException +from aidial_client._exception import InvalidDialURLError from aidial_client.helpers.storage_resource import DialStorageResourceMixin DIAL_API_URL = "https://dial.core/v1/" @@ -91,7 +91,7 @@ def test_get_api_path_invalid_dial_url(resource_type): ) url = "https://other-dial.core/v1/files/bucket/file.txt" with pytest.raises( - InvalidDialURLException, match="Provided url is not DIAL url" + InvalidDialURLError, match="Provided url is not DIAL url" ): mixin.get_api_path(url) @@ -112,7 +112,7 @@ def test_get_api_path_invalid_resource_type(resource_type, url): resource_type=resource_type, dial_api_url=DIAL_API_URL ) with pytest.raises( - InvalidDialURLException, match="Invalid resource type for url" + InvalidDialURLError, match="Invalid resource type for url" ): mixin.get_api_path(url) @@ -129,7 +129,7 @@ def test_get_api_path_missing_bucket(resource_type, url): mixin = DialStorageResourceMixin( resource_type=resource_type, dial_api_url=DIAL_API_URL ) - with pytest.raises(InvalidDialURLException, match="Missing bucket in url"): + with pytest.raises(InvalidDialURLError, match="Missing bucket in url"): mixin.get_api_path(url) @@ -140,7 +140,7 @@ def test_get_api_path_invalid_path(resource_type): ) url = "https://dial.core/v2/files/bucket/file.txt" with pytest.raises( - InvalidDialURLException, + InvalidDialURLError, match="Provided url path .* does not match with DIAL API url", ): mixin.get_api_path( diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py index 7e28e7d..26ac2b5 100644 --- a/tests/helpers/test_storage_resource_parser.py +++ b/tests/helpers/test_storage_resource_parser.py @@ -1,6 +1,6 @@ import pytest -from aidial_client._exception import InvalidDialURLException +from aidial_client._exception import InvalidDialURLError from aidial_client.helpers.storage_resource import parse_storage_resource @@ -86,14 +86,14 @@ def test_parse_storage_resource_valid( ], ) def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): - with pytest.raises(InvalidDialURLException): + with pytest.raises(InvalidDialURLError): parse_storage_resource( url, dial_api_url, resource_type, ignore_non_dial_url=False ) def test_parse_storage_resource_non_dial_ignore(): - with pytest.raises(InvalidDialURLException): + with pytest.raises(InvalidDialURLError): parse_storage_resource( "https://example.com/files/my-bucket/file.txt", "https://dial.core/v1/", From 41b225c44a354e10633010527c165568643d756d Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:32:46 +0300 Subject: [PATCH 27/64] Minor linter fix --- aidial_client/_utils/_openai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidial_client/_utils/_openai.py b/aidial_client/_utils/_openai.py index f129e6b..545a474 100644 --- a/aidial_client/_utils/_openai.py +++ b/aidial_client/_utils/_openai.py @@ -1,4 +1,4 @@ -from typing import AsyncIterator, Iterator, Union +from typing import AsyncIterator, Iterator import openai from openai.types.chat import ChatCompletion as OpenAIChatCompletion From 5d6df564c5718ebabc4ce15ee90b551c06559c16 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:44:45 +0300 Subject: [PATCH 28/64] Refactoring for metadata type, avoid pydantic warnings --- aidial_client/_compatibility/__init__.py | 0 aidial_client/_compatibility/openai.py | 3 +-- aidial_client/_compatibility/pydantic.py | 5 +++++ aidial_client/_internal_types/_model.py | 2 +- aidial_client/types/metadata.py | 13 +++++++++++-- 5 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 aidial_client/_compatibility/__init__.py create mode 100644 aidial_client/_compatibility/pydantic.py diff --git a/aidial_client/_compatibility/__init__.py b/aidial_client/_compatibility/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/aidial_client/_compatibility/openai.py b/aidial_client/_compatibility/openai.py index 440c909..3862256 100644 --- a/aidial_client/_compatibility/openai.py +++ b/aidial_client/_compatibility/openai.py @@ -3,8 +3,7 @@ for easier handling of cases, when such member will migrate to another modules """ -from openai._compat import PYDANTIC_V2 from openai._models import BaseModel from openai._types import Omit -__all__ = ["Omit", "PYDANTIC_V2", "BaseModel"] +__all__ = ["Omit", "BaseModel"] diff --git a/aidial_client/_compatibility/pydantic.py b/aidial_client/_compatibility/pydantic.py new file mode 100644 index 0000000..1ee24c0 --- /dev/null +++ b/aidial_client/_compatibility/pydantic.py @@ -0,0 +1,5 @@ +import pydantic + +PYDANTIC_V2 = pydantic.VERSION.startswith("2.") + +__all__ = ["PYDANTIC_V2"] diff --git a/aidial_client/_internal_types/_model.py b/aidial_client/_internal_types/_model.py index a7aeb0f..886ab19 100644 --- a/aidial_client/_internal_types/_model.py +++ b/aidial_client/_internal_types/_model.py @@ -1,7 +1,7 @@ import pydantic -from aidial_client._compatibility.openai import PYDANTIC_V2 from aidial_client._compatibility.openai import BaseModel as OpenAIBaseModel +from aidial_client._compatibility.pydantic import PYDANTIC_V2 from aidial_client._compatibility.pydantic_v1 import BaseModel, Extra diff --git a/aidial_client/types/metadata.py b/aidial_client/types/metadata.py index 4a14820..26acc52 100644 --- a/aidial_client/types/metadata.py +++ b/aidial_client/types/metadata.py @@ -1,12 +1,21 @@ from typing import List, Literal, Optional +from aidial_client._compatibility.pydantic import PYDANTIC_V2 from aidial_client._internal_types._model import ExtraAllowModel from aidial_client._utils._alias import to_camel class BaseMetadata(ExtraAllowModel): - class Config: - alias_generator = to_camel + if PYDANTIC_V2: + model_config = { + "alias_generator": to_camel, + "allow_population_by_field_name": True, + } + else: + + class Config: + alias_generator = to_camel + allow_population_by_field_name = True name: str From b7406e31fb0863bf31d04bf7c05a18cd8ee615c5 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:53:16 +0300 Subject: [PATCH 29/64] Fix compatibility with pydantic v2 --- aidial_client/types/application.py | 2 +- aidial_client/types/chat/response.py | 29 +++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/aidial_client/types/application.py b/aidial_client/types/application.py index 3aac5c8..2f4afb7 100644 --- a/aidial_client/types/application.py +++ b/aidial_client/types/application.py @@ -30,7 +30,7 @@ class Application(ExtraAllowModel): created_at: int updated_at: Optional[int] = None features: Features - input_attachment_types: Optional[List[str]] + input_attachment_types: Optional[List[str]] = None defaults: Dict = {} diff --git a/aidial_client/types/chat/response.py b/aidial_client/types/chat/response.py index e163a1b..a84beab 100644 --- a/aidial_client/types/chat/response.py +++ b/aidial_client/types/chat/response.py @@ -1,8 +1,12 @@ from typing import Dict, List, Literal, Optional, Union +from aidial_client._compatibility.pydantic import PYDANTIC_V2 from aidial_client._compatibility.pydantic_v1 import root_validator from aidial_client._internal_types._model import ExtraAllowModel +if PYDANTIC_V2: + from pydantic import model_validator + class Attachment(ExtraAllowModel): type: Optional[str] = None @@ -12,11 +16,26 @@ class Attachment(ExtraAllowModel): reference_type: Optional[str] = None reference_url: Optional[str] = None - @root_validator(pre=True) - def validate_data_or_url(cls, values): - if "data" not in values and "url" not in values: - raise ValueError("Either data or url must be provided") - return values + if PYDANTIC_V2: + + @model_validator(mode="before") + @classmethod + def validate_data_or_url_v2(cls, values): + if ( + isinstance(values, dict) + and "data" not in values + and "url" not in values + ): + raise ValueError("Either data or url must be provided") + return values + + else: + + @root_validator(pre=True) + def validate_data_or_url_v1(cls, values): + if "data" not in values and "url" not in values: + raise ValueError("Either data or url must be provided") + return values class CustomContent(ExtraAllowModel): From 58d07570981eaf315bff875090dfd5bf51bae9b6 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 13:56:22 +0300 Subject: [PATCH 30/64] Minor fix --- aidial_client/helpers/storage_resource.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 695f75d..5b559d1 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -74,7 +74,9 @@ def parse_storage_resource( if url_parsed.netloc != dial_api_parsed.netloc: if ignore_non_dial_url: return None - raise InvalidDialURLError(message="Provided url is not DIAL url: {url}") + raise InvalidDialURLError( + message=f"Provided url is not DIAL url: {url}" + ) try: url_path = PurePosixPath(url_parsed.path) api_path = url_path.relative_to(dial_api_parsed.path) From 6f46d096904d5bf250987bd2dbd8557d562744f8 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:03:51 +0300 Subject: [PATCH 31/64] rework storage resource parser --- aidial_client/helpers/storage_resource.py | 33 +++++++++++++------ tests/helpers/test_storage_resource_parser.py | 22 ++++++++----- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 5b559d1..0e00f90 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -1,5 +1,5 @@ from pathlib import PurePosixPath -from typing import Literal, Optional, overload +from typing import Literal, Optional, cast, overload from urllib.parse import urljoin, urlparse from aidial_client._compatibility.pydantic_v1 import BaseModel @@ -40,27 +40,30 @@ class DialStorageResource(BaseModel): @overload def parse_storage_resource( + *, url: str, dial_api_url: str, - resource_type: StorageResourceType, ignore_non_dial_url: Literal[True], + expected_resource_type: Optional[StorageResourceType] = None, ) -> Optional[DialStorageResource]: ... @overload def parse_storage_resource( + *, url: str, dial_api_url: str, - resource_type: StorageResourceType, ignore_non_dial_url: Literal[False], + expected_resource_type: Optional[StorageResourceType] = None, ) -> DialStorageResource: ... def parse_storage_resource( + *, url: str, dial_api_url: str, - resource_type: StorageResourceType, ignore_non_dial_url: bool, + expected_resource_type: Optional[StorageResourceType] = None, ) -> Optional[DialStorageResource]: dial_api_url = enforce_trailing_slash(dial_api_url) url = remove_leading_slash(url) @@ -88,10 +91,20 @@ def parse_storage_resource( ) resource_path = api_path.parents[len(api_path.parents) - 2] - if str(resource_path) != resource_type: + parsed_resource_type = str(resource_path) + + if parsed_resource_type not in StorageResourceType.__args__: + raise InvalidDialURLError( + f"Invalid resource type: {parsed_resource_type}" + ) + # If user provided expected resource type, check it + if ( + expected_resource_type is not None + and parsed_resource_type != expected_resource_type + ): raise InvalidDialURLError( f"Invalid resource type for url: {url}\n" - f"Expected: {resource_type}, got: {resource_path}" + f"Expected: {expected_resource_type}, got: {parsed_resource_type}" ) if len(api_path.parents) < 3: @@ -100,7 +113,7 @@ def parse_storage_resource( bucket_path = api_path.parents[len(api_path.parents) - 3] return DialStorageResource( - resource_type=resource_type, + resource_type=cast(StorageResourceType, parsed_resource_type), absolute_url=absolute_url, api_path=str(api_path), bucket=str(bucket_path.relative_to(resource_path)), @@ -130,10 +143,10 @@ def get_storage_resource(self, url: str) -> DialStorageResource: DialStorageResource: The storage resource object """ return parse_storage_resource( - url, - self.dial_api_url, - self.resource_type, + url=url, + dial_api_url=self.dial_api_url, ignore_non_dial_url=False, + expected_resource_type=self.resource_type, ) def get_api_path(self, url: str) -> str: diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py index 26ac2b5..245afc4 100644 --- a/tests/helpers/test_storage_resource_parser.py +++ b/tests/helpers/test_storage_resource_parser.py @@ -55,7 +55,10 @@ def test_parse_storage_resource_valid( url, dial_api_url, resource_type, expected ): result = parse_storage_resource( - url, dial_api_url, resource_type, ignore_non_dial_url=False + url=url, + dial_api_url=dial_api_url, + expected_resource_type=resource_type, + ignore_non_dial_url=False, ) assert result.dict() == expected @@ -88,23 +91,26 @@ def test_parse_storage_resource_valid( def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): with pytest.raises(InvalidDialURLError): parse_storage_resource( - url, dial_api_url, resource_type, ignore_non_dial_url=False + url=url, + dial_api_url=dial_api_url, + expected_resource_type=resource_type, + ignore_non_dial_url=False, ) def test_parse_storage_resource_non_dial_ignore(): with pytest.raises(InvalidDialURLError): parse_storage_resource( - "https://example.com/files/my-bucket/file.txt", - "https://dial.core/v1/", - "files", + url="https://example.com/files/my-bucket/file.txt", + dial_api_url="https://dial.core/v1/", + expected_resource_type="files", ignore_non_dial_url=False, ) assert ( parse_storage_resource( - "https://example.com/files/my-bucket/file.txt", - "https://dial.core/v1/", - "files", + url="https://example.com/files/my-bucket/file.txt", + dial_api_url="https://dial.core/v1/", + expected_resource_type="files", ignore_non_dial_url=True, ) is None From 86e68e42a06e83a7585b5316cd60a13aeda7b376 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:17:24 +0300 Subject: [PATCH 32/64] Fix test case, when url is like `/files...`, add unit test for that --- aidial_client/helpers/storage_resource.py | 12 +++++++++--- tests/helpers/test_storage_resource_parser.py | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 0e00f90..e73f5fd 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -66,9 +66,15 @@ def parse_storage_resource( expected_resource_type: Optional[StorageResourceType] = None, ) -> Optional[DialStorageResource]: dial_api_url = enforce_trailing_slash(dial_api_url) - url = remove_leading_slash(url) - # If the URL starts with API_PREFIX, remove it - url = remove_prefix(url, API_PREFIX) + + if url.startswith(API_PREFIX) or url.startswith(f"/{API_PREFIX}"): + url = remove_leading_slash(url) + url = remove_prefix(url, API_PREFIX) + + if url.startswith("/"): + raise InvalidDialURLError( + f"Provided url does not have API prefix: {url}" + ) absolute_url = urljoin(dial_api_url, remove_leading_slash(url)) url_parsed = urlparse(absolute_url) diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py index 245afc4..faaa767 100644 --- a/tests/helpers/test_storage_resource_parser.py +++ b/tests/helpers/test_storage_resource_parser.py @@ -86,6 +86,11 @@ def test_parse_storage_resource_valid( "https://dial.core/v1/", "files", ), + ( + "/files/test-bucket/files.txt", + "https://dial.core/v1/", + "files", + ), ], ) def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): From 3aa5b6a334b56ea9dca239b8334673a085fe3a1a Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:19:15 +0300 Subject: [PATCH 33/64] Minor refactor --- aidial_client/resources/files.py | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index c445575..45de6ba 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -52,10 +52,7 @@ def upload( error_processor=_error_processor, ) - def download( - self, - url: Union[str, PurePosixPath], - ) -> FileDownloadResponse: + def download(self, url: Union[str, PurePosixPath]) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) response = self.http_client.request( cast_to=httpx.Response, @@ -70,10 +67,7 @@ def download( response=response, filename=storage_resource.filename ) - def delete( - self, - url: Union[str, PurePosixPath], - ) -> None: + def delete(self, url: Union[str, PurePosixPath]) -> None: return self.http_client.request( cast_to=NoneType, options=FinalRequestOptions( @@ -83,10 +77,7 @@ def delete( error_processor=_error_processor, ) - def get_metadata( - self, - url: Union[str, PurePosixPath], - ) -> FileMetadata: + def get_metadata(self, url: Union[str, PurePosixPath]) -> FileMetadata: return self.metadata.get( resource="files", relative_url=self.get_api_path(str(url)), @@ -112,8 +103,7 @@ async def upload( ) async def download( - self, - url: Union[str, PurePosixPath], + self, url: Union[str, PurePosixPath] ) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) response = await self.http_client.request( @@ -129,10 +119,7 @@ async def download( response=response, filename=storage_resource.filename ) - async def delete( - self, - url: Union[str, PurePosixPath], - ) -> None: + async def delete(self, url: Union[str, PurePosixPath]) -> None: return await self.http_client.request( cast_to=NoneType, options=FinalRequestOptions( @@ -143,8 +130,7 @@ async def delete( ) async def get_metadata( - self, - url: Union[str, PurePosixPath], + self, url: Union[str, PurePosixPath] ) -> FileMetadata: return await self.metadata.get( resource="files", From 3c54eb37ff1b53f3425f77d4fcaf85e1ced35b83 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:25:19 +0300 Subject: [PATCH 34/64] Minor refactor --- aidial_client/resources/metadata.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aidial_client/resources/metadata.py b/aidial_client/resources/metadata.py index a9c3e89..ae8f049 100644 --- a/aidial_client/resources/metadata.py +++ b/aidial_client/resources/metadata.py @@ -1,6 +1,8 @@ from typing import Literal, Type, Union, overload from urllib.parse import urljoin +from typing_extensions import assert_never + from aidial_client._constants import METADATA_PREFIX from aidial_client._exception import DialException from aidial_client._internal_types._http_request import FinalRequestOptions @@ -25,9 +27,7 @@ def _get_cast_to( elif resource == "prompts": return PromptMetadata else: - raise DialException( - message="Not supported type of resource for metadata" - ) + assert_never(resource) class Metadata(Resource): From 646abf382309c1a18978063a7ccb148f7de1bcf6 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:29:17 +0300 Subject: [PATCH 35/64] naming refactor --- aidial_client/_http_client/_async.py | 4 ++-- aidial_client/resources/files.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py index 3ae14ed..31a771c 100644 --- a/aidial_client/_http_client/_async.py +++ b/aidial_client/_http_client/_async.py @@ -48,7 +48,7 @@ async def request( options: FinalRequestOptions, cast_to: Type[ResponseT], remaining_retries: Optional[int] = None, - error_processor: Optional[ + _on_http_error: Optional[ Callable[[httpx.HTTPStatusError], Optional[DialException]] ] = None, ) -> ResponseT: @@ -100,7 +100,7 @@ async def request( remaining_retries=retries, ) # Try to get custom error from response status_code/code/message - custom_error = error_processor(err) if error_processor else None + custom_error = _on_http_error(err) if _on_http_error else None # or fallback to default processing raised_error = custom_error or self._make_dial_error_from_response( err.response diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index 45de6ba..e77701b 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -18,7 +18,7 @@ from aidial_client.types.metadata import FileMetadata -def _error_processor(error: httpx.HTTPStatusError) -> Optional[DialException]: +def _on_http_error(error: httpx.HTTPStatusError) -> Optional[DialException]: try: response = error.response error_message = response.text @@ -49,7 +49,7 @@ def upload( url=urljoin(API_PREFIX, self.get_api_path(str(url))), files={"file": file}, ), - error_processor=_error_processor, + error_processor=_on_http_error, ) def download(self, url: Union[str, PurePosixPath]) -> FileDownloadResponse: @@ -60,7 +60,7 @@ def download(self, url: Union[str, PurePosixPath]) -> FileDownloadResponse: method="GET", url=urljoin(API_PREFIX, storage_resource.api_path), ), - error_processor=_error_processor, + error_processor=_on_http_error, ) assert storage_resource.filename return FileDownloadResponse( @@ -74,7 +74,7 @@ def delete(self, url: Union[str, PurePosixPath]) -> None: method="DELETE", url=urljoin(API_PREFIX, self.get_api_path(str(url))), ), - error_processor=_error_processor, + error_processor=_on_http_error, ) def get_metadata(self, url: Union[str, PurePosixPath]) -> FileMetadata: @@ -99,7 +99,7 @@ async def upload( url=urljoin(API_PREFIX, self.get_api_path(str(url))), files={"file": file}, ), - error_processor=_error_processor, + _on_http_error=_on_http_error, ) async def download( @@ -112,7 +112,7 @@ async def download( method="GET", url=urljoin(API_PREFIX, storage_resource.api_path), ), - error_processor=_error_processor, + _on_http_error=_on_http_error, ) assert storage_resource.filename return FileDownloadResponse( @@ -126,7 +126,7 @@ async def delete(self, url: Union[str, PurePosixPath]) -> None: method="DELETE", url=urljoin(API_PREFIX, self.get_api_path(str(url))), ), - error_processor=_error_processor, + _on_http_error=_on_http_error, ) async def get_metadata( From 6b74f0c3540265042b20f9afe6e18ece11c12546 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:30:33 +0300 Subject: [PATCH 36/64] refactor of deployments, test fix --- aidial_client/resources/deployments.py | 4 ++-- tests/resources/files/test_upload.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/aidial_client/resources/deployments.py b/aidial_client/resources/deployments.py index adf6ab9..cac9ea4 100644 --- a/aidial_client/resources/deployments.py +++ b/aidial_client/resources/deployments.py @@ -12,7 +12,7 @@ def raw_get(self) -> DeploymentsResponse: options=FinalRequestOptions(method="GET", url="openai/deployments"), ) - def get(self) -> List[Deployment]: + def list(self) -> List[Deployment]: return self.raw_get().data @@ -23,5 +23,5 @@ async def raw_get(self) -> DeploymentsResponse: options=FinalRequestOptions(method="GET", url="openai/deployments"), ) - async def get(self) -> List[Deployment]: + async def list(self) -> List[Deployment]: return (await self.raw_get()).data diff --git a/tests/resources/files/test_upload.py b/tests/resources/files/test_upload.py index 73f8302..e979610 100644 --- a/tests/resources/files/test_upload.py +++ b/tests/resources/files/test_upload.py @@ -3,7 +3,7 @@ import pytest -from aidial_client._exception import InvalidDialURLException +from aidial_client._exception import InvalidDialURLError from aidial_client.types.metadata import FileMetadata from tests.client_mock import get_async_client_mock, get_client_mock @@ -50,7 +50,7 @@ async def test_upload_file_object_async(): with open(current_file_path, "rb") as file: with pytest.raises( - InvalidDialURLException, match="Invalid resource type for url" + InvalidDialURLError, match="Invalid resource type for url" ): await client.files.upload( url="prompts/test-bucket/folder1/folder2/file.png", file=file From fe8218a8ffc6af322608ece864dc9b20782d6f3b Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:31:47 +0300 Subject: [PATCH 37/64] One more interface change for deployments - remove `get_raw` --- aidial_client/resources/deployments.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aidial_client/resources/deployments.py b/aidial_client/resources/deployments.py index cac9ea4..32234e3 100644 --- a/aidial_client/resources/deployments.py +++ b/aidial_client/resources/deployments.py @@ -6,22 +6,22 @@ class Deployments(Resource): - def raw_get(self) -> DeploymentsResponse: + def _list_raw(self) -> DeploymentsResponse: return self.http_client.request( cast_to=DeploymentsResponse, options=FinalRequestOptions(method="GET", url="openai/deployments"), ) def list(self) -> List[Deployment]: - return self.raw_get().data + return self._list_raw().data class AsyncDeployments(AsyncResource): - async def raw_get(self) -> DeploymentsResponse: + async def _list_raw(self) -> DeploymentsResponse: return await self.http_client.request( cast_to=DeploymentsResponse, options=FinalRequestOptions(method="GET", url="openai/deployments"), ) async def list(self) -> List[Deployment]: - return (await self.raw_get()).data + return (await self._list_raw()).data From a082db2ccc75fe7d7b2c7759ea3e15b7fb474b1e Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:33:20 +0300 Subject: [PATCH 38/64] Minor comment fix --- aidial_client/_utils/_alias.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/aidial_client/_utils/_alias.py b/aidial_client/_utils/_alias.py index 60b0572..49cfc05 100644 --- a/aidial_client/_utils/_alias.py +++ b/aidial_client/_utils/_alias.py @@ -1,6 +1,8 @@ """ -Just copy of alias generators from pydantic V2, -so we can use library with pydantic < 2.0 version +Just copy of alias generators from pydantic V2: +https://github.com/pydantic/pydantic/blob/c772b43edb952c5fe54bb28da5124b10d5470caf/pydantic/alias_generators.py + +So we can use library with pydantic < 2.0 version """ import re From b2a03ec655ecf9aab4669f132fcea5c63da3c94d Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 14:41:02 +0300 Subject: [PATCH 39/64] fix integration tests (`deployment.get()`) --- tests/integration/fixtures.py | 2 +- tests/integration/test_async_completions.py | 2 +- tests/integration/test_sync_completions.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/fixtures.py b/tests/integration/fixtures.py index d39c319..1789cbe 100644 --- a/tests/integration/fixtures.py +++ b/tests/integration/fixtures.py @@ -31,7 +31,7 @@ def async_client(dial_url, dial_api_key): @pytest.fixture def test_deployment(sync_client: Dial) -> str: - deployments = sync_client.deployments.get() + deployments = sync_client.deployments.list() assert len(deployments) deployment = next((d for d in deployments if d.id.startswith("gpt-"))) assert deployment diff --git a/tests/integration/test_async_completions.py b/tests/integration/test_async_completions.py index 82f87fa..6246877 100644 --- a/tests/integration/test_async_completions.py +++ b/tests/integration/test_async_completions.py @@ -71,7 +71,7 @@ async def test_completions_without_streaming( @pytest.mark.asyncio async def test_completions_with_streaming(async_client: AsyncDial): - deployments = await async_client.deployments.get() + deployments = await async_client.deployments.list() assert len(deployments) deployment = next((d for d in deployments if d.id.startswith("gpt-"))) assert deployment diff --git a/tests/integration/test_sync_completions.py b/tests/integration/test_sync_completions.py index 67bfac6..a35b61c 100644 --- a/tests/integration/test_sync_completions.py +++ b/tests/integration/test_sync_completions.py @@ -63,7 +63,7 @@ def test_default_api_version( def test_completions_with_streaming(sync_client: Dial): - deployments = sync_client.deployments.get() + deployments = sync_client.deployments.list() assert len(deployments) deployment = next((d for d in deployments if d.id.startswith("gpt-"))) assert deployment From c63070e15f902b1879829669bb972c2cf545eae1 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 15:04:26 +0300 Subject: [PATCH 40/64] Bump github workflow --- .github/workflows/pr-title-check.yml | 2 +- .github/workflows/pr.yml | 2 +- .github/workflows/release.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml index 7a621e9..a2ad43e 100644 --- a/.github/workflows/pr-title-check.yml +++ b/.github/workflows/pr-title-check.yml @@ -9,6 +9,6 @@ on: jobs: pr-title-check: - uses: epam/ai-dial-ci/.github/workflows/pr-title-check.yml@1.9.0 + uses: epam/ai-dial-ci/.github/workflows/pr-title-check.yml@1.9.1 secrets: ACTIONS_BOT_TOKEN: ${{ secrets.ACTIONS_BOT_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b3748b9..b5320b3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -6,7 +6,7 @@ on: jobs: run_tests: - uses: epam/ai-dial-ci/.github/workflows/python_package_pr.yml@1.9.0 + uses: epam/ai-dial-ci/.github/workflows/python_package_pr.yml@1.9.1 secrets: inherit with: python_version: 3.8 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ab1985..c3496de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: jobs: release: - uses: epam/ai-dial-ci/.github/workflows/python_package_release.yml@1.9.0 + uses: epam/ai-dial-ci/.github/workflows/python_package_release.yml@1.9.1 secrets: inherit with: python_version: 3.8 From 945a36035133c5e4c976b18cfee15bc4d7b9317e Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 15:08:38 +0300 Subject: [PATCH 41/64] Fix linter --- aidial_client/helpers/storage_resource.py | 4 ++-- aidial_client/resources/metadata.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index e73f5fd..af44212 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -1,5 +1,5 @@ from pathlib import PurePosixPath -from typing import Literal, Optional, cast, overload +from typing import Literal, Optional, cast, get_args, overload from urllib.parse import urljoin, urlparse from aidial_client._compatibility.pydantic_v1 import BaseModel @@ -99,7 +99,7 @@ def parse_storage_resource( resource_path = api_path.parents[len(api_path.parents) - 2] parsed_resource_type = str(resource_path) - if parsed_resource_type not in StorageResourceType.__args__: + if parsed_resource_type not in get_args(StorageResourceType): raise InvalidDialURLError( f"Invalid resource type: {parsed_resource_type}" ) diff --git a/aidial_client/resources/metadata.py b/aidial_client/resources/metadata.py index ae8f049..d970935 100644 --- a/aidial_client/resources/metadata.py +++ b/aidial_client/resources/metadata.py @@ -4,7 +4,6 @@ from typing_extensions import assert_never from aidial_client._constants import METADATA_PREFIX -from aidial_client._exception import DialException from aidial_client._internal_types._http_request import FinalRequestOptions from aidial_client.helpers.storage_resource import StorageResourceType from aidial_client.resources.base import AsyncResource, Resource From bd3b4097ccf195d3c5bd87b3abaace86123a12cd Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 15:19:11 +0300 Subject: [PATCH 42/64] forgotten commit - relax aiofiles dependency --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index befbc01..228dcb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,7 @@ openai = ">=1.0.0,<2.0.0" python = ">=3.8.1,<4.0" httpx = ">=0.25.0,<1.0" pydantic = ">=1.10,<3" -aiofiles = "24.1.0" - +aiofiles = ">=22.1.0" [tool.setuptools] packages = ["aidial_client"] From ef57a5406ed3604f4886ee2dec06869d0cf49f8b Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 15:20:46 +0300 Subject: [PATCH 43/64] update poetry lock --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 58c3878..308f667 100644 --- a/poetry.lock +++ b/poetry.lock @@ -938,4 +938,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0" -content-hash = "260fa6f956b76b58e9270a5837b1d032cfba70a2c2db61964811062c645f3092" +content-hash = "ced8c2ede71b093e3228d70e4988202d16a5d0c2cbcacbf41541dbf8a309eb72" From b437cd1b5ab783aed9f543fc374893e0364364d5 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 17:48:51 +0300 Subject: [PATCH 44/64] add test for attachments --- tests/test_types.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/test_types.py diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..5d67b48 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,32 @@ +import pytest +from pydantic import ValidationError + +from aidial_client.types.chat.response import Attachment + + +@pytest.mark.parametrize( + "invalid_attachment", + [ + {}, + {"reference_type": "test"}, + {"reference_url": "test"}, + ], +) +def test_invalid_attachment(invalid_attachment): + with pytest.raises(ValidationError): + Attachment(**invalid_attachment) + + +@pytest.mark.parametrize( + "valid_attachment", + [ + {"data": "test"}, + {"url": "test"}, + {"data": "test", "url": "test"}, + {"data": "test", "reference_type": "test", "reference_url": "test"}, + ], +) +def test_valid_attachment(valid_attachment): + attachment = Attachment(**valid_attachment) + for key, value in valid_attachment.items(): + assert getattr(attachment, key) == value From 88a200abc3936e9d4b4a2e2dde8a994566584c1d Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 17:54:17 +0300 Subject: [PATCH 45/64] relax aiofiles dependency even more --- noxfile.py | 2 +- poetry.lock | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/noxfile.py b/noxfile.py index 23ef63d..1d5c875 100644 --- a/noxfile.py +++ b/noxfile.py @@ -53,7 +53,7 @@ def format(session: nox.Session): @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("httpx", ["0.25.0", "0.27.0"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -@nox.parametrize("aiofiles", ["24.1.0", "22.1.0"]) +@nox.parametrize("aiofiles", ["24.1.0", "0.5.0"]) def test( session: nox.Session, pydantic: str, httpx: str, openai: str, aiofiles: str ) -> None: diff --git a/poetry.lock b/poetry.lock index 308f667..42b92b9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -938,4 +938,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4.0" -content-hash = "ced8c2ede71b093e3228d70e4988202d16a5d0c2cbcacbf41541dbf8a309eb72" +content-hash = "eea85f4ac82a62809786e104d0b48febe5223d100733856ab97b1648d64d163d" diff --git a/pyproject.toml b/pyproject.toml index 228dcb6..c8fcbe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ openai = ">=1.0.0,<2.0.0" python = ">=3.8.1,<4.0" httpx = ">=0.25.0,<1.0" pydantic = ">=1.10,<3" -aiofiles = ">=22.1.0" +aiofiles = ">=0.5.0" [tool.setuptools] packages = ["aidial_client"] From fbeb5656c6409c198de9d4a984a1b41ffcea0c42 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 19:01:05 +0300 Subject: [PATCH 46/64] update noxfile to test lower version of aiofiles --- noxfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index 1d5c875..50ccb67 100644 --- a/noxfile.py +++ b/noxfile.py @@ -53,7 +53,7 @@ def format(session: nox.Session): @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("httpx", ["0.25.0", "0.27.0"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -@nox.parametrize("aiofiles", ["24.1.0", "0.5.0"]) +@nox.parametrize("aiofiles", ["0.5.0", "22.1.0", "24.1.0"]) def test( session: nox.Session, pydantic: str, httpx: str, openai: str, aiofiles: str ) -> None: From ae84bb03344c8dbcd2aa7028743d8b0ea8a6ca25 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 19:03:27 +0300 Subject: [PATCH 47/64] update noxfile one more time, remove few typing ignores --- noxfile.py | 4 ++-- tests/test_client_retry.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index 50ccb67..1279559 100644 --- a/noxfile.py +++ b/noxfile.py @@ -53,7 +53,7 @@ def format(session: nox.Session): @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("httpx", ["0.25.0", "0.27.0"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -@nox.parametrize("aiofiles", ["0.5.0", "22.1.0", "24.1.0"]) +@nox.parametrize("aiofiles", ["0.5.0", "24.1.0"]) def test( session: nox.Session, pydantic: str, httpx: str, openai: str, aiofiles: str ) -> None: @@ -71,7 +71,7 @@ def test( @nox.session(python=["3.11"]) @nox.parametrize("pydantic", ["1.10.17", "2.8.2"]) @nox.parametrize("openai", ["1.0.0", "1.51.0"]) -@nox.parametrize("aiofiles", ["24.1.0", "22.1.0"]) +@nox.parametrize("aiofiles", ["0.5.0", "24.1.0"]) def integration_test( session: nox.Session, pydantic: str, openai: str, aiofiles: str ) -> None: diff --git a/tests/test_client_retry.py b/tests/test_client_retry.py index 16a0900..dc3bebc 100644 --- a/tests/test_client_retry.py +++ b/tests/test_client_retry.py @@ -95,7 +95,7 @@ def test_status_codes_retries(status_code, is_retry_called): else: with pytest.raises(DialException) as e: client.bucket.get_bucket() - assert e.value.status_code == status_code # type: ignore + assert e.value.status_code == status_code assert not retry_request_mock.called @@ -125,4 +125,4 @@ async def test_status_codes_retries_async(status_code, is_retry_called): else: with pytest.raises(DialException) as e: await client.bucket.get_bucket() - assert e.value.status_code == status_code # type: ignore + assert e.value.status_code == status_code From 436e07e597a1d4f25a09ddff2eb9c490773ef60b Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 19:35:09 +0300 Subject: [PATCH 48/64] refactor appdata for client --- aidial_client/_client.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 3e97084..2da0e4a 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from pathlib import PurePosixPath -from typing import Dict, Generic, Optional, TypeVar, Union +from typing import Dict, Generic, Optional, TypeVar, Union, cast from urllib.parse import urljoin import openai @@ -21,6 +21,7 @@ OPENAI_PREFIX, ) from aidial_client._http_client import AsyncHTTPClient, SyncHTTPClient +from aidial_client._internal_types._defaults import NOT_GIVEN, NotGiven from aidial_client.helpers._url import enforce_trailing_slash from aidial_client.types.bucket import AppData @@ -36,6 +37,7 @@ class BaseDialClient(Generic[_HttpClientT, AuthValueT], ABC): _http_client: _HttpClientT _auth_headers: Dict[str, str] _my_bucket: Optional[str] + _my_appdata: Union[AppData, None, NotGiven] def __init__( self, @@ -57,6 +59,7 @@ def __init__( self._api_version = api_version self._http_client = http_client or self._create_http_client() self._my_bucket = None + self._my_appdata = NOT_GIVEN self._init_resources() @abstractmethod @@ -138,9 +141,9 @@ def _get_my_appdata(self) -> Optional[AppData]: return self.bucket.get_appdata() def my_appdata(self) -> Optional[AppData]: - if not hasattr(self, "_my_appdata"): + if self._my_appdata is NOT_GIVEN: self._my_appdata = self._get_my_appdata() - return self._my_appdata + return cast(Optional[AppData], self._my_appdata) def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = self.my_appdata() @@ -221,7 +224,7 @@ async def _get_my_appdata(self) -> Optional[AppData]: async def my_appdata(self) -> Optional[AppData]: if not hasattr(self, "_my_appdata"): self._my_appdata = await self._get_my_appdata() - return self._my_appdata + return cast(Optional[AppData], self._my_appdata) async def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = await self.my_appdata() From d38844e0d80aeda44acfeaa1d085fb5c00488610 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 19:57:10 +0300 Subject: [PATCH 49/64] narrow down allowed link in storage resource parser --- aidial_client/helpers/storage_resource.py | 13 ++++++++----- tests/helpers/test_storage_resource_parser.py | 5 +++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index af44212..f67dd69 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -66,14 +66,17 @@ def parse_storage_resource( expected_resource_type: Optional[StorageResourceType] = None, ) -> Optional[DialStorageResource]: dial_api_url = enforce_trailing_slash(dial_api_url) - - if url.startswith(API_PREFIX) or url.startswith(f"/{API_PREFIX}"): + # URL that came starts with "v1/...", when should be "/v1/..." + if url.startswith(API_PREFIX): + raise InvalidDialURLError( + f"Provided url has API prefix as relative part, that is not allowed: {url}" + ) + elif url.startswith(f"/{API_PREFIX}"): url = remove_leading_slash(url) url = remove_prefix(url, API_PREFIX) - - if url.startswith("/"): + elif url.startswith("/"): raise InvalidDialURLError( - f"Provided url does not have API prefix: {url}" + f"Provided url is root url, but should have API prefix: {url}" ) absolute_url = urljoin(dial_api_url, remove_leading_slash(url)) diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py index faaa767..75cd5e9 100644 --- a/tests/helpers/test_storage_resource_parser.py +++ b/tests/helpers/test_storage_resource_parser.py @@ -91,6 +91,11 @@ def test_parse_storage_resource_valid( "https://dial.core/v1/", "files", ), + ( + "v1/files/test-bucket/files.txt", + "https://dial.core/v1/", + "files", + ), ], ) def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): From b49005b48baeac0fd31707e3a893c86d33c98c3c Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 19:57:52 +0300 Subject: [PATCH 50/64] naming refactor --- aidial_client/_client.py | 2 +- aidial_client/_http_client/_async.py | 2 +- aidial_client/_http_client/_base.py | 10 ++++++---- aidial_client/_http_client/_sync.py | 2 +- tests/client_mock.py | 4 ++-- tests/test_client_pool.py | 4 ++-- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 2da0e4a..9449655 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -120,7 +120,7 @@ def _create_http_client(self) -> SyncHTTPClient: ) def _get_my_bucket(self) -> str: - """Wrapper for convenience of unit tests""" + # Wrapper for convenience of unit tests return self.bucket.get_bucket() def my_bucket(self) -> str: diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py index 31a771c..02b4ce6 100644 --- a/aidial_client/_http_client/_async.py +++ b/aidial_client/_http_client/_async.py @@ -57,7 +57,7 @@ async def request( request = self._build_request(options, auth_headers) try: - response = await self._client.send(request) + response = await self._internal_client.send(request) except httpx.TimeoutException as err: logger.debug("Request failed by timeout") diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index 21aa10e..768a338 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -18,7 +18,7 @@ class BaseHTTPClient(ABC, Generic[_HttpInternalClientT, AuthValueT]): - _client: _HttpInternalClientT + _internal_client: _HttpInternalClientT _auth_value: AuthValueT _auth_type: AuthType @@ -36,7 +36,9 @@ def __init__( self._auth_type = auth_type self._max_retries = max_retries self._timeout = timeout - self._client = internal_client or self._create_internal_client() + self._internal_client = ( + internal_client or self._create_internal_client() + ) @abstractmethod def _create_internal_client( @@ -58,7 +60,7 @@ def _build_request( auth_headers: Dict[str, str], ) -> httpx.Request: custom_headers = options.headers or {} - return self._client.build_request( + return self._internal_client.build_request( headers={**auth_headers, **custom_headers}, method=options.method, url=self._prepare_url(options.url), @@ -136,4 +138,4 @@ def _make_dial_error_from_response( @property def internal_http_client(self) -> _HttpInternalClientT: - return self._client + return self._internal_client diff --git a/aidial_client/_http_client/_sync.py b/aidial_client/_http_client/_sync.py index 0928e6d..3bbfa3d 100644 --- a/aidial_client/_http_client/_sync.py +++ b/aidial_client/_http_client/_sync.py @@ -56,7 +56,7 @@ def request( request = self._build_request(options, auth_headers) try: - response = self._client.send(request) + response = self._internal_client.send(request) except httpx.TimeoutException as err: logger.debug("Request failed by timeout") diff --git a/tests/client_mock.py b/tests/client_mock.py index 65ffb09..3955042 100644 --- a/tests/client_mock.py +++ b/tests/client_mock.py @@ -55,7 +55,7 @@ def send_mock(request: httpx.Request, **kwargs): else: raise NotImplementedError() - client_mock._http_client._client.send = send_mock + client_mock._http_client._internal_client.send = send_mock return client_mock @@ -92,5 +92,5 @@ async def send_mock(request: httpx.Request, **kwargs): else: raise NotImplementedError() - client_mock._http_client._client.send = send_mock + client_mock._http_client._internal_client.send = send_mock return client_mock diff --git a/tests/test_client_pool.py b/tests/test_client_pool.py index 90380ec..54b2a5c 100644 --- a/tests/test_client_pool.py +++ b/tests/test_client_pool.py @@ -43,6 +43,6 @@ def test_pools(pool, api_key): assert id(client_1) != id(client_2) assert id(client_1._http_client) != id(client_2._http_client) # Clients are different, but internal httpx client is the same - assert id(client_1._http_client._client) == id( - client_2._http_client._client + assert id(client_1._http_client._internal_client) == id( + client_2._http_client._internal_client ) From 0b22a774dbf58bdb47e6aabfcf5f75f821e9c105 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 20:06:18 +0300 Subject: [PATCH 51/64] add test for metadata --- tests/test_types.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_types.py b/tests/test_types.py index 5d67b48..0fcea3a 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -2,6 +2,7 @@ from pydantic import ValidationError from aidial_client.types.chat.response import Attachment +from aidial_client.types.metadata import BaseMetadata @pytest.mark.parametrize( @@ -30,3 +31,26 @@ def test_valid_attachment(valid_attachment): attachment = Attachment(**valid_attachment) for key, value in valid_attachment.items(): assert getattr(attachment, key) == value + + +def test_metadata_population(): + metadata_by_name = BaseMetadata( + name="test", + bucket="test", + url="test", + node_type="FOLDER", + resource_type="FILE", + ) + alias_json = { + "name": "test", + "bucket": "test", + "url": "test", + "nodeType": "FOLDER", + "resourceType": "FILE", + } + metadata_by_alias = BaseMetadata(**alias_json) # type: ignore + + for field in BaseMetadata.__annotations__: + assert getattr(metadata_by_name, field) == getattr( + metadata_by_alias, field + ) From bc127ca09539972dfdeb2dd0e7f676de9ee6210a Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Fri, 11 Oct 2024 20:12:49 +0300 Subject: [PATCH 52/64] Fix metadata, improve tests --- aidial_client/types/metadata.py | 5 ++--- tests/helpers/test_storage_resource_mixin.py | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/aidial_client/types/metadata.py b/aidial_client/types/metadata.py index 26acc52..b414af5 100644 --- a/aidial_client/types/metadata.py +++ b/aidial_client/types/metadata.py @@ -9,14 +9,13 @@ class BaseMetadata(ExtraAllowModel): if PYDANTIC_V2: model_config = { "alias_generator": to_camel, - "allow_population_by_field_name": True, + "populate_by_name": True, } else: class Config: alias_generator = to_camel - - allow_population_by_field_name = True + allow_population_by_field_name = True name: str parent_path: Optional[str] = None diff --git a/tests/helpers/test_storage_resource_mixin.py b/tests/helpers/test_storage_resource_mixin.py index b0b46db..efe2dc2 100644 --- a/tests/helpers/test_storage_resource_mixin.py +++ b/tests/helpers/test_storage_resource_mixin.py @@ -90,10 +90,9 @@ def test_get_api_path_invalid_dial_url(resource_type): resource_type=resource_type, dial_api_url=DIAL_API_URL ) url = "https://other-dial.core/v1/files/bucket/file.txt" - with pytest.raises( - InvalidDialURLError, match="Provided url is not DIAL url" - ): + with pytest.raises(InvalidDialURLError) as e: mixin.get_api_path(url) + assert e.value.message == f"Provided url is not DIAL url: {url}" @pytest.mark.parametrize( From d2d1e95e0639c4f4aaef331cf2e06643029499c3 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 10:44:43 +0300 Subject: [PATCH 53/64] Fix naming of internal http client --- aidial_client/_client_pool.py | 4 ++-- aidial_client/_http_client/_async.py | 2 +- aidial_client/_http_client/_base.py | 12 ++++++------ aidial_client/_http_client/_sync.py | 2 +- tests/client_mock.py | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/aidial_client/_client_pool.py b/aidial_client/_client_pool.py index 05f308a..91a90c6 100644 --- a/aidial_client/_client_pool.py +++ b/aidial_client/_client_pool.py @@ -45,7 +45,7 @@ def create_client( auth_type=auth_type, max_retries=max_retries, timeout=timeout, - internal_client=self._internal_http_client, + internal_http_client=self._internal_http_client, ), ) @@ -83,6 +83,6 @@ def create_client( auth_type=auth_type, max_retries=max_retries, timeout=timeout, - internal_client=self._internal_http_client, + internal_http_client=self._internal_http_client, ), ) diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py index 02b4ce6..266b096 100644 --- a/aidial_client/_http_client/_async.py +++ b/aidial_client/_http_client/_async.py @@ -57,7 +57,7 @@ async def request( request = self._build_request(options, auth_headers) try: - response = await self._internal_client.send(request) + response = await self._internal_http_client.send(request) except httpx.TimeoutException as err: logger.debug("Request failed by timeout") diff --git a/aidial_client/_http_client/_base.py b/aidial_client/_http_client/_base.py index 768a338..49bf530 100644 --- a/aidial_client/_http_client/_base.py +++ b/aidial_client/_http_client/_base.py @@ -18,7 +18,7 @@ class BaseHTTPClient(ABC, Generic[_HttpInternalClientT, AuthValueT]): - _internal_client: _HttpInternalClientT + _internal_http_client: _HttpInternalClientT _auth_value: AuthValueT _auth_type: AuthType @@ -29,15 +29,15 @@ def __init__( auth_type: AuthType, max_retries: int, timeout: Union[float, httpx.Timeout, None], - internal_client: Optional[_HttpInternalClientT] = None, + internal_http_client: Optional[_HttpInternalClientT] = None, ): self.base_url = httpx.URL(enforce_trailing_slash(base_url)) self._auth_value = auth_value self._auth_type = auth_type self._max_retries = max_retries self._timeout = timeout - self._internal_client = ( - internal_client or self._create_internal_client() + self._internal_http_client = ( + internal_http_client or self._create_internal_client() ) @abstractmethod @@ -60,7 +60,7 @@ def _build_request( auth_headers: Dict[str, str], ) -> httpx.Request: custom_headers = options.headers or {} - return self._internal_client.build_request( + return self._internal_http_client.build_request( headers={**auth_headers, **custom_headers}, method=options.method, url=self._prepare_url(options.url), @@ -138,4 +138,4 @@ def _make_dial_error_from_response( @property def internal_http_client(self) -> _HttpInternalClientT: - return self._internal_client + return self._internal_http_client diff --git a/aidial_client/_http_client/_sync.py b/aidial_client/_http_client/_sync.py index 3bbfa3d..382b9a5 100644 --- a/aidial_client/_http_client/_sync.py +++ b/aidial_client/_http_client/_sync.py @@ -56,7 +56,7 @@ def request( request = self._build_request(options, auth_headers) try: - response = self._internal_client.send(request) + response = self._internal_http_client.send(request) except httpx.TimeoutException as err: logger.debug("Request failed by timeout") diff --git a/tests/client_mock.py b/tests/client_mock.py index 3955042..fb46ade 100644 --- a/tests/client_mock.py +++ b/tests/client_mock.py @@ -55,7 +55,7 @@ def send_mock(request: httpx.Request, **kwargs): else: raise NotImplementedError() - client_mock._http_client._internal_client.send = send_mock + client_mock._http_client._internal_http_client.send = send_mock return client_mock @@ -92,5 +92,5 @@ async def send_mock(request: httpx.Request, **kwargs): else: raise NotImplementedError() - client_mock._http_client._internal_client.send = send_mock + client_mock._http_client._internal_http_client.send = send_mock return client_mock From 71822a127812858dbd957902c241f7e756711b51 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 10:46:30 +0300 Subject: [PATCH 54/64] Replace docstring with just comment --- aidial_client/_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 9449655..94652c1 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -201,7 +201,7 @@ def _create_http_client(self) -> AsyncHTTPClient: ) async def _get_my_bucket(self) -> str: - """Wrapper for convenience of unit tests""" + # Wrapper for convenience of unit tests return await self.bucket.get_bucket() async def my_bucket(self) -> str: From 64deba4fd6fbaac58c8bf32c254da4d68e4d37dc Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 10:50:07 +0300 Subject: [PATCH 55/64] Refactor _my_appdata property, use NOT_GIVEN for it --- aidial_client/_client.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index 94652c1..ae88491 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -141,7 +141,7 @@ def _get_my_appdata(self) -> Optional[AppData]: return self.bucket.get_appdata() def my_appdata(self) -> Optional[AppData]: - if self._my_appdata is NOT_GIVEN: + if self._my_appdata == NOT_GIVEN: self._my_appdata = self._get_my_appdata() return cast(Optional[AppData], self._my_appdata) @@ -156,8 +156,6 @@ def auth_headers(self) -> Dict[str, str]: class AsyncDial(BaseDialClient[AsyncHTTPClient, AsyncAuthValue]): - _my_bucket: Optional[str] - def _init_resources(self) -> None: openai_client = openai.AsyncAzureOpenAI( # set empty string, we will override @@ -222,7 +220,7 @@ async def _get_my_appdata(self) -> Optional[AppData]: return await self.bucket.get_appdata() async def my_appdata(self) -> Optional[AppData]: - if not hasattr(self, "_my_appdata"): + if self._my_appdata == NOT_GIVEN: self._my_appdata = await self._get_my_appdata() return cast(Optional[AppData], self._my_appdata) From 21acd43b411bd32d351809f17859fa9935307353 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 11:17:14 +0300 Subject: [PATCH 56/64] Fix client pool test --- tests/test_client_pool.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_client_pool.py b/tests/test_client_pool.py index 54b2a5c..2150f15 100644 --- a/tests/test_client_pool.py +++ b/tests/test_client_pool.py @@ -43,6 +43,6 @@ def test_pools(pool, api_key): assert id(client_1) != id(client_2) assert id(client_1._http_client) != id(client_2._http_client) # Clients are different, but internal httpx client is the same - assert id(client_1._http_client._internal_client) == id( - client_2._http_client._internal_client + assert id(client_1._http_client._internal_http_client) == id( + client_2._http_client._internal_http_client ) From 1457f55b123e19e039c9950206c83e5c83abb22f Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 11:36:05 +0300 Subject: [PATCH 57/64] Refine storage resource helpers, introduce new exception `NotDialURLException` --- aidial_client/_exception.py | 4 + aidial_client/helpers/storage_resource.py | 84 +++++-------- tests/helpers/test_storage_resource_mixin.py | 28 +---- tests/helpers/test_storage_resource_parser.py | 119 +++++++++++++----- 4 files changed, 124 insertions(+), 111 deletions(-) diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py index f8936ea..5c1c492 100644 --- a/aidial_client/_exception.py +++ b/aidial_client/_exception.py @@ -63,6 +63,10 @@ class InvalidDialURLError(InvalidRequestError): pass +class NotDialURLError(InvalidRequestError): + pass + + class InvalidBucketError(InvalidRequestError): pass diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index f67dd69..6f338fa 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -1,15 +1,11 @@ from pathlib import PurePosixPath -from typing import Literal, Optional, cast, get_args, overload +from typing import Literal, Optional, Union, cast, get_args, overload from urllib.parse import urljoin, urlparse from aidial_client._compatibility.pydantic_v1 import BaseModel from aidial_client._constants import API_PREFIX -from aidial_client._exception import InvalidDialURLError -from aidial_client.helpers._url import ( - enforce_trailing_slash, - remove_leading_slash, - remove_prefix, -) +from aidial_client._exception import InvalidDialURLError, NotDialURLError +from aidial_client.helpers._url import enforce_trailing_slash StorageResourceType = Literal["files", "conversations", "prompts"] @@ -38,63 +34,32 @@ class DialStorageResource(BaseModel): filename: Optional[str] = None -@overload -def parse_storage_resource( - *, - url: str, - dial_api_url: str, - ignore_non_dial_url: Literal[True], - expected_resource_type: Optional[StorageResourceType] = None, -) -> Optional[DialStorageResource]: ... - - -@overload -def parse_storage_resource( +def safe_parse_storage_resource( *, url: str, dial_api_url: str, - ignore_non_dial_url: Literal[False], expected_resource_type: Optional[StorageResourceType] = None, -) -> DialStorageResource: ... - - -def parse_storage_resource( - *, - url: str, - dial_api_url: str, - ignore_non_dial_url: bool, - expected_resource_type: Optional[StorageResourceType] = None, -) -> Optional[DialStorageResource]: +) -> Union[DialStorageResource, NotDialURLError, InvalidDialURLError]: dial_api_url = enforce_trailing_slash(dial_api_url) + + if url.startswith("/"): + return InvalidDialURLError(f"Relative root url is forbidden: {url}") # URL that came starts with "v1/...", when should be "/v1/..." if url.startswith(API_PREFIX): - raise InvalidDialURLError( - f"Provided url has API prefix as relative part, that is not allowed: {url}" - ) - elif url.startswith(f"/{API_PREFIX}"): - url = remove_leading_slash(url) - url = remove_prefix(url, API_PREFIX) - elif url.startswith("/"): - raise InvalidDialURLError( - f"Provided url is root url, but should have API prefix: {url}" + return InvalidDialURLError( + f"API prefix as relative part is not allowed: {url}" ) - absolute_url = urljoin(dial_api_url, remove_leading_slash(url)) + absolute_url = urljoin(dial_api_url, url) url_parsed = urlparse(absolute_url) dial_api_parsed = urlparse(dial_api_url) - # url is not from DIAL if url_parsed.netloc != dial_api_parsed.netloc: - if ignore_non_dial_url: - return None - raise InvalidDialURLError( - message=f"Provided url is not DIAL url: {url}" - ) + return NotDialURLError(message=f"Provided url is not DIAL url: {url}") try: url_path = PurePosixPath(url_parsed.path) api_path = url_path.relative_to(dial_api_parsed.path) except ValueError: - - raise InvalidDialURLError( + return InvalidDialURLError( f"Provided url path {url_parsed.path} does not match with" f" DIAL API url {dial_api_parsed.path}" ) @@ -103,7 +68,7 @@ def parse_storage_resource( parsed_resource_type = str(resource_path) if parsed_resource_type not in get_args(StorageResourceType): - raise InvalidDialURLError( + return InvalidDialURLError( f"Invalid resource type: {parsed_resource_type}" ) # If user provided expected resource type, check it @@ -111,13 +76,13 @@ def parse_storage_resource( expected_resource_type is not None and parsed_resource_type != expected_resource_type ): - raise InvalidDialURLError( + return InvalidDialURLError( f"Invalid resource type for url: {url}\n" f"Expected: {expected_resource_type}, got: {parsed_resource_type}" ) if len(api_path.parents) < 3: - raise InvalidDialURLError(f"Missing bucket in url: {url}") + return InvalidDialURLError(f"Missing bucket in url: {url}") bucket_path = api_path.parents[len(api_path.parents) - 3] @@ -132,6 +97,22 @@ def parse_storage_resource( ) +def parse_storage_resource( + *, + url: str, + dial_api_url: str, + expected_resource_type: Optional[StorageResourceType] = None, +) -> DialStorageResource: + result = safe_parse_storage_resource( + url=url, + dial_api_url=dial_api_url, + expected_resource_type=expected_resource_type, + ) + if isinstance(result, (NotDialURLError, InvalidDialURLError)): + raise result + return result + + class DialStorageResourceMixin(BaseModel): """ Mixin class for resources that are using DIAL storage: @@ -154,7 +135,6 @@ def get_storage_resource(self, url: str) -> DialStorageResource: return parse_storage_resource( url=url, dial_api_url=self.dial_api_url, - ignore_non_dial_url=False, expected_resource_type=self.resource_type, ) diff --git a/tests/helpers/test_storage_resource_mixin.py b/tests/helpers/test_storage_resource_mixin.py index efe2dc2..fde9342 100644 --- a/tests/helpers/test_storage_resource_mixin.py +++ b/tests/helpers/test_storage_resource_mixin.py @@ -1,6 +1,6 @@ import pytest -from aidial_client._exception import InvalidDialURLError +from aidial_client._exception import InvalidDialURLError, NotDialURLError from aidial_client.helpers.storage_resource import DialStorageResourceMixin DIAL_API_URL = "https://dial.core/v1/" @@ -60,37 +60,13 @@ def test_get_api_path_absolute_url(resource_type, url, expected): assert result == expected -@pytest.mark.parametrize( - "resource_type, url, expected", - [ - ("files", "/v1/files/bucket/file.txt", "files/bucket/file.txt"), - ( - "conversations", - "/v1/conversations/bucket/conv.json", - "conversations/bucket/conv.json", - ), - ( - "prompts", - "/v1/prompts/bucket/prompt.txt", - "prompts/bucket/prompt.txt", - ), - ], -) -def test_get_api_path_with_api_prefix(resource_type, url, expected): - mixin = DialStorageResourceMixin( - resource_type=resource_type, dial_api_url=DIAL_API_URL - ) - result = mixin.get_api_path(url) - assert result == expected - - @pytest.mark.parametrize("resource_type", RESOURCE_TYPES) def test_get_api_path_invalid_dial_url(resource_type): mixin = DialStorageResourceMixin( resource_type=resource_type, dial_api_url=DIAL_API_URL ) url = "https://other-dial.core/v1/files/bucket/file.txt" - with pytest.raises(InvalidDialURLError) as e: + with pytest.raises(NotDialURLError) as e: mixin.get_api_path(url) assert e.value.message == f"Provided url is not DIAL url: {url}" diff --git a/tests/helpers/test_storage_resource_parser.py b/tests/helpers/test_storage_resource_parser.py index 75cd5e9..dba29a2 100644 --- a/tests/helpers/test_storage_resource_parser.py +++ b/tests/helpers/test_storage_resource_parser.py @@ -1,6 +1,6 @@ import pytest -from aidial_client._exception import InvalidDialURLError +from aidial_client._exception import InvalidDialURLError, NotDialURLError from aidial_client.helpers.storage_resource import parse_storage_resource @@ -21,20 +21,6 @@ "filename": "my-file.txt", }, ), - ( - "/v1/conversations/my-bucket/conversation-123", - "https://dial.core/v1/", - "conversations", - { - "resource_type": "conversations", - "bucket": "my-bucket", - "absolute_url": "https://dial.core/v1/conversations/my-bucket/conversation-123", # noqa: E501 - "relative_url": "/v1/conversations/my-bucket/conversation-123", - "api_path": "conversations/my-bucket/conversation-123", - "bucket_path": "conversation-123", - "filename": "conversation-123", - }, - ), ( "prompts/my-bucket/prompt-456.txt", "https://dial.core/v1/", @@ -49,6 +35,34 @@ "filename": "prompt-456.txt", }, ), + ( + "https://dial.core/v1/conversations/my-bucket/conv-123.json", + "https://dial.core/v1/", + "conversations", + { + "resource_type": "conversations", + "bucket": "my-bucket", + "absolute_url": "https://dial.core/v1/conversations/my-bucket/conv-123.json", + "relative_url": "/v1/conversations/my-bucket/conv-123.json", + "api_path": "conversations/my-bucket/conv-123.json", + "bucket_path": "conv-123.json", + "filename": "conv-123.json", + }, + ), + ( + "https://dial.core/v1/files/my-bucket/subfolder/document.pdf", + "https://dial.core/v1/", + "files", + { + "resource_type": "files", + "bucket": "my-bucket", + "absolute_url": "https://dial.core/v1/files/my-bucket/subfolder/document.pdf", + "relative_url": "/v1/files/my-bucket/subfolder/document.pdf", + "api_path": "files/my-bucket/subfolder/document.pdf", + "bucket_path": "subfolder/document.pdf", + "filename": "document.pdf", + }, + ), ], ) def test_parse_storage_resource_valid( @@ -58,19 +72,19 @@ def test_parse_storage_resource_valid( url=url, dial_api_url=dial_api_url, expected_resource_type=resource_type, - ignore_non_dial_url=False, ) assert result.dict() == expected + without_resource_type = parse_storage_resource( + url=url, + dial_api_url=dial_api_url, + ) + assert without_resource_type.dict() == expected + @pytest.mark.parametrize( "url, dial_api_url, resource_type", [ - ( - "https://example.com/files/my-bucket/file.txt", - "https://dial.core/v1/", - "files", - ), ( "files/my-bucket/file.txt", "https://dial.core/v1/", @@ -96,6 +110,11 @@ def test_parse_storage_resource_valid( "https://dial.core/v1/", "files", ), + ( + "/v1/files/test-bucket/files.txt", + "https://dial.core/v1/", + "files", + ), ], ) def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): @@ -104,24 +123,58 @@ def test_parse_storage_resource_invalid_url(url, dial_api_url, resource_type): url=url, dial_api_url=dial_api_url, expected_resource_type=resource_type, - ignore_non_dial_url=False, ) +@pytest.mark.parametrize( + "url, dial_api_url, expected_resource_type", + [ + ( + "files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ), + ( + "prompts/my-bucket/prompt-123", + "https://dial.core/v1/", + "prompts", + ), + ( + "conversations/my-bucket/conversation-123", + "https://dial.core/v1/", + "conversations", + ), + ( + "https://dial.core/v1/files/my-bucket/file.txt", + "https://dial.core/v1/", + "files", + ), + ( + "https://dial.core/v1/prompts/my-bucket/prompt-123", + "https://dial.core/v1/", + "prompts", + ), + ( + "https://dial.core/v1/conversations/my-bucket/conversation-123", + "https://dial.core/v1/", + "conversations", + ), + ], +) +def test_parse_storage_resource_unknown_resource_type( + url, dial_api_url, expected_resource_type +): + storage_resource = parse_storage_resource( + url=url, + dial_api_url=dial_api_url, + ) + assert storage_resource.resource_type == expected_resource_type + + def test_parse_storage_resource_non_dial_ignore(): - with pytest.raises(InvalidDialURLError): + with pytest.raises(NotDialURLError): parse_storage_resource( url="https://example.com/files/my-bucket/file.txt", dial_api_url="https://dial.core/v1/", expected_resource_type="files", - ignore_non_dial_url=False, ) - assert ( - parse_storage_resource( - url="https://example.com/files/my-bucket/file.txt", - dial_api_url="https://dial.core/v1/", - expected_resource_type="files", - ignore_non_dial_url=True, - ) - is None - ) From 3d35f369586ef1245fed5a75224a6ea8eac96ebf Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 12:01:14 +0300 Subject: [PATCH 58/64] More refactoring, fix minor bug with storage resource --- aidial_client/__init__.py | 2 -- aidial_client/_compatibility/openai.py | 6 ++-- aidial_client/_compatibility/pydantic.py | 2 -- aidial_client/_exception.py | 4 --- aidial_client/_http_client/__init__.py | 8 ++--- aidial_client/helpers/storage_resource.py | 16 ++++++---- aidial_client/resources/files.py | 33 ++++----------------- aidial_client/types/chat/legacy/__init__.py | 4 +-- 8 files changed, 23 insertions(+), 52 deletions(-) diff --git a/aidial_client/__init__.py b/aidial_client/__init__.py index 41bdca5..08e34d4 100644 --- a/aidial_client/__init__.py +++ b/aidial_client/__init__.py @@ -3,7 +3,6 @@ from aidial_client._client_pool import AsyncDialClientPool, DialClientPool from aidial_client._exception import ( DialException, - InvalidBucketError, InvalidDialURLError, InvalidRequestError, ParsingDataError, @@ -20,7 +19,6 @@ # Exceptions "DialException", "InvalidDialURLError", - "InvalidBucketError", "InvalidRequestError", "ParsingDataError", ] diff --git a/aidial_client/_compatibility/openai.py b/aidial_client/_compatibility/openai.py index 3862256..b8fb3b1 100644 --- a/aidial_client/_compatibility/openai.py +++ b/aidial_client/_compatibility/openai.py @@ -3,7 +3,5 @@ for easier handling of cases, when such member will migrate to another modules """ -from openai._models import BaseModel -from openai._types import Omit - -__all__ = ["Omit", "BaseModel"] +from openai._models import BaseModel # noqa: F401 +from openai._types import Omit # noqa: F401 diff --git a/aidial_client/_compatibility/pydantic.py b/aidial_client/_compatibility/pydantic.py index 1ee24c0..04d3c2e 100644 --- a/aidial_client/_compatibility/pydantic.py +++ b/aidial_client/_compatibility/pydantic.py @@ -1,5 +1,3 @@ import pydantic PYDANTIC_V2 = pydantic.VERSION.startswith("2.") - -__all__ = ["PYDANTIC_V2"] diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py index 5c1c492..a7e041e 100644 --- a/aidial_client/_exception.py +++ b/aidial_client/_exception.py @@ -67,10 +67,6 @@ class NotDialURLError(InvalidRequestError): pass -class InvalidBucketError(InvalidRequestError): - pass - - class ParsingDataError(DialException): def __init__(self, message: str, **kwargs) -> None: super().__init__( diff --git a/aidial_client/_http_client/__init__.py b/aidial_client/_http_client/__init__.py index 816248e..d145ff8 100644 --- a/aidial_client/_http_client/__init__.py +++ b/aidial_client/_http_client/__init__.py @@ -1,5 +1,3 @@ -from ._async import AsyncHTTPClient -from ._base import BaseHTTPClient -from ._sync import SyncHTTPClient - -__all__ = ["AsyncHTTPClient", "SyncHTTPClient", "BaseHTTPClient"] +from ._async import AsyncHTTPClient # noqa: F401 +from ._base import BaseHTTPClient # noqa: F401 +from ._sync import SyncHTTPClient # noqa: F401 diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 6f338fa..6c4a0c5 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -1,5 +1,5 @@ from pathlib import PurePosixPath -from typing import Literal, Optional, Union, cast, get_args, overload +from typing import Literal, Optional, Union, cast, get_args from urllib.parse import urljoin, urlparse from aidial_client._compatibility.pydantic_v1 import BaseModel @@ -10,6 +10,10 @@ StorageResourceType = Literal["files", "conversations", "prompts"] +def is_directory(s: str) -> bool: + return s[-1] == "/" + + class DialStorageResource(BaseModel): resource_type: StorageResourceType @@ -40,11 +44,14 @@ def safe_parse_storage_resource( dial_api_url: str, expected_resource_type: Optional[StorageResourceType] = None, ) -> Union[DialStorageResource, NotDialURLError, InvalidDialURLError]: + """ + Parse the storage resource from the URL, that could be + 1. Absolute: "https://dial.core/v1/files/my-bucket/my-file.txt" + 2. Relative to API prefix: "files/my-bucket/my-file.txt" + """ dial_api_url = enforce_trailing_slash(dial_api_url) - if url.startswith("/"): return InvalidDialURLError(f"Relative root url is forbidden: {url}") - # URL that came starts with "v1/...", when should be "/v1/..." if url.startswith(API_PREFIX): return InvalidDialURLError( f"API prefix as relative part is not allowed: {url}" @@ -85,7 +92,6 @@ def safe_parse_storage_resource( return InvalidDialURLError(f"Missing bucket in url: {url}") bucket_path = api_path.parents[len(api_path.parents) - 3] - return DialStorageResource( resource_type=cast(StorageResourceType, parsed_resource_type), absolute_url=absolute_url, @@ -93,7 +99,7 @@ def safe_parse_storage_resource( bucket=str(bucket_path.relative_to(resource_path)), bucket_path=str(api_path.relative_to(bucket_path)), relative_url=str(url_path), - filename=url_path.name, + filename=url_path.name if not is_directory(url) else None, ) diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index e77701b..72bf53d 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -1,11 +1,11 @@ from pathlib import PurePosixPath -from typing import Optional, Union +from typing import Union from urllib.parse import urljoin import httpx from aidial_client._constants import API_PREFIX -from aidial_client._exception import DialException, InvalidBucketError +from aidial_client._exception import InvalidDialURLError from aidial_client._internal_types._generic import NoneType from aidial_client._internal_types._http_request import ( FileTypes, @@ -18,23 +18,6 @@ from aidial_client.types.metadata import FileMetadata -def _on_http_error(error: httpx.HTTPStatusError) -> Optional[DialException]: - try: - response = error.response - error_message = response.text - if ( - response.status_code == 400 - # TODO: move it to response.code check, - # when adapter will return it for this particular error - and "Url has invalid bucket" in error_message - ): - return InvalidBucketError(error_message) - except Exception: - return None - else: - return None - - class Files(Resource, DialStorageResourceMixin): metadata: Metadata resource_type: str = "files" @@ -49,20 +32,19 @@ def upload( url=urljoin(API_PREFIX, self.get_api_path(str(url))), files={"file": file}, ), - error_processor=_on_http_error, ) def download(self, url: Union[str, PurePosixPath]) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) + if storage_resource.filename is None: + raise InvalidDialURLError("Url points to a directory, not a file") response = self.http_client.request( cast_to=httpx.Response, options=FinalRequestOptions( method="GET", url=urljoin(API_PREFIX, storage_resource.api_path), ), - error_processor=_on_http_error, ) - assert storage_resource.filename return FileDownloadResponse( response=response, filename=storage_resource.filename ) @@ -74,7 +56,6 @@ def delete(self, url: Union[str, PurePosixPath]) -> None: method="DELETE", url=urljoin(API_PREFIX, self.get_api_path(str(url))), ), - error_processor=_on_http_error, ) def get_metadata(self, url: Union[str, PurePosixPath]) -> FileMetadata: @@ -99,22 +80,21 @@ async def upload( url=urljoin(API_PREFIX, self.get_api_path(str(url))), files={"file": file}, ), - _on_http_error=_on_http_error, ) async def download( self, url: Union[str, PurePosixPath] ) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) + if storage_resource.filename is None: + raise InvalidDialURLError("Url points to a directory, not a file") response = await self.http_client.request( cast_to=httpx.Response, options=FinalRequestOptions( method="GET", url=urljoin(API_PREFIX, storage_resource.api_path), ), - _on_http_error=_on_http_error, ) - assert storage_resource.filename return FileDownloadResponse( response=response, filename=storage_resource.filename ) @@ -126,7 +106,6 @@ async def delete(self, url: Union[str, PurePosixPath]) -> None: method="DELETE", url=urljoin(API_PREFIX, self.get_api_path(str(url))), ), - _on_http_error=_on_http_error, ) async def get_metadata( diff --git a/aidial_client/types/chat/legacy/__init__.py b/aidial_client/types/chat/legacy/__init__.py index ea55415..6e18d99 100644 --- a/aidial_client/types/chat/legacy/__init__.py +++ b/aidial_client/types/chat/legacy/__init__.py @@ -1,3 +1 @@ -from .chat_completion import ChatCompletionRequest - -__all__ = ["ChatCompletionRequest"] +from .chat_completion import ChatCompletionRequest # noqa: F401 From 65da79b373965d9bcbc79c1ca1b333f18ab84935 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 12:11:21 +0300 Subject: [PATCH 59/64] fix ort ignore rule --- .ort.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.ort.yml b/.ort.yml index df29451..c29f318 100644 --- a/.ort.yml +++ b/.ort.yml @@ -9,9 +9,9 @@ excludes: comment: "Packages for testing only." resolutions: rule_violations: - - message: ".*PyPI::httpcore:1\\.0\\.5*" + - message: ".*PyPI::httpcore:1\\.0\\.5.*" reason: "CANT_FIX_EXCEPTION" - comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpcore/blob/0.18.0/LICENSE.md" + comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpcore/blob/1.0.5/LICENSE.md" - message: ".*PyPI::httpx:0\\.25\\.2.*" reason: "CANT_FIX_EXCEPTION" comment: "BSD 3-Clause New or Revised License: https://github.com/encode/httpx/blob/0.25.0/LICENSE.md" \ No newline at end of file From 90867dfa2d173bd31b8cfc79f9d688723fdf63fe Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 12:50:28 +0300 Subject: [PATCH 60/64] Minor refactor --- aidial_client/_client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/aidial_client/_client.py b/aidial_client/_client.py index ae88491..adef69a 100644 --- a/aidial_client/_client.py +++ b/aidial_client/_client.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from pathlib import PurePosixPath -from typing import Dict, Generic, Optional, TypeVar, Union, cast +from typing import Dict, Generic, Optional, TypeVar, Union from urllib.parse import urljoin import openai @@ -141,9 +141,9 @@ def _get_my_appdata(self) -> Optional[AppData]: return self.bucket.get_appdata() def my_appdata(self) -> Optional[AppData]: - if self._my_appdata == NOT_GIVEN: + if isinstance(self._my_appdata, NotGiven): self._my_appdata = self._get_my_appdata() - return cast(Optional[AppData], self._my_appdata) + return self._my_appdata def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = self.my_appdata() @@ -220,9 +220,9 @@ async def _get_my_appdata(self) -> Optional[AppData]: return await self.bucket.get_appdata() async def my_appdata(self) -> Optional[AppData]: - if self._my_appdata == NOT_GIVEN: + if isinstance(self._my_appdata, NotGiven): self._my_appdata = await self._get_my_appdata() - return cast(Optional[AppData], self._my_appdata) + return self._my_appdata async def my_appdata_home(self) -> Optional[PurePosixPath]: appdata = await self.my_appdata() From ca193a0520ecf0bac3fa4b82bdf965ede832d9c3 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 13:02:26 +0300 Subject: [PATCH 61/64] Refactor error naming --- aidial_client/helpers/storage_resource.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 6c4a0c5..b36f01d 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -51,7 +51,7 @@ def safe_parse_storage_resource( """ dial_api_url = enforce_trailing_slash(dial_api_url) if url.startswith("/"): - return InvalidDialURLError(f"Relative root url is forbidden: {url}") + return InvalidDialURLError(f"Root-relative url is forbidden: {url}") if url.startswith(API_PREFIX): return InvalidDialURLError( f"API prefix as relative part is not allowed: {url}" From b2da8a683c8c48f0ff9e6840f0b6b275a28d8e62 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 13:03:22 +0300 Subject: [PATCH 62/64] refactor - make is_directory private --- aidial_client/helpers/storage_resource.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index b36f01d..9f0fdca 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -10,7 +10,7 @@ StorageResourceType = Literal["files", "conversations", "prompts"] -def is_directory(s: str) -> bool: +def _is_directory(s: str) -> bool: return s[-1] == "/" @@ -99,7 +99,7 @@ def safe_parse_storage_resource( bucket=str(bucket_path.relative_to(resource_path)), bucket_path=str(api_path.relative_to(bucket_path)), relative_url=str(url_path), - filename=url_path.name if not is_directory(url) else None, + filename=url_path.name if not _is_directory(url) else None, ) From 3839de31540beba0e87edb5c07c2b20f92f1aeb1 Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 13:04:17 +0300 Subject: [PATCH 63/64] Refactor docstring --- aidial_client/helpers/storage_resource.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index 9f0fdca..a891e2f 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -34,6 +34,7 @@ class DialStorageResource(BaseModel): """ Filename, like 'my-file.txt' + None for a directory """ filename: Optional[str] = None From 6d3ad486915b951a6c13eeeb7de12a39f164036f Mon Sep 17 00:00:00 2001 From: Roman Romanov Date: Mon, 14 Oct 2024 13:50:27 +0300 Subject: [PATCH 64/64] Refactor: capitalize `url` to `URL` in error message --- aidial_client/helpers/storage_resource.py | 12 ++++++------ aidial_client/resources/files.py | 4 ++-- aidial_client/types/chat/response.py | 4 ++-- tests/helpers/test_storage_resource_mixin.py | 8 ++++---- tests/resources/files/test_upload.py | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/aidial_client/helpers/storage_resource.py b/aidial_client/helpers/storage_resource.py index a891e2f..190e4b0 100644 --- a/aidial_client/helpers/storage_resource.py +++ b/aidial_client/helpers/storage_resource.py @@ -52,7 +52,7 @@ def safe_parse_storage_resource( """ dial_api_url = enforce_trailing_slash(dial_api_url) if url.startswith("/"): - return InvalidDialURLError(f"Root-relative url is forbidden: {url}") + return InvalidDialURLError(f"Root-relative URL is forbidden: {url}") if url.startswith(API_PREFIX): return InvalidDialURLError( f"API prefix as relative part is not allowed: {url}" @@ -62,14 +62,14 @@ def safe_parse_storage_resource( url_parsed = urlparse(absolute_url) dial_api_parsed = urlparse(dial_api_url) if url_parsed.netloc != dial_api_parsed.netloc: - return NotDialURLError(message=f"Provided url is not DIAL url: {url}") + return NotDialURLError(message=f"Provided URL is not DIAL URL: {url}") try: url_path = PurePosixPath(url_parsed.path) api_path = url_path.relative_to(dial_api_parsed.path) except ValueError: return InvalidDialURLError( - f"Provided url path {url_parsed.path} does not match with" - f" DIAL API url {dial_api_parsed.path}" + f"Provided URL path {url_parsed.path} does not match with" + f" DIAL API URL {dial_api_parsed.path}" ) resource_path = api_path.parents[len(api_path.parents) - 2] @@ -85,12 +85,12 @@ def safe_parse_storage_resource( and parsed_resource_type != expected_resource_type ): return InvalidDialURLError( - f"Invalid resource type for url: {url}\n" + f"Invalid resource type for URL: {url}\n" f"Expected: {expected_resource_type}, got: {parsed_resource_type}" ) if len(api_path.parents) < 3: - return InvalidDialURLError(f"Missing bucket in url: {url}") + return InvalidDialURLError(f"Missing bucket in URL: {url}") bucket_path = api_path.parents[len(api_path.parents) - 3] return DialStorageResource( diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py index 72bf53d..e489291 100644 --- a/aidial_client/resources/files.py +++ b/aidial_client/resources/files.py @@ -37,7 +37,7 @@ def upload( def download(self, url: Union[str, PurePosixPath]) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) if storage_resource.filename is None: - raise InvalidDialURLError("Url points to a directory, not a file") + raise InvalidDialURLError("URL points to a directory, not a file") response = self.http_client.request( cast_to=httpx.Response, options=FinalRequestOptions( @@ -87,7 +87,7 @@ async def download( ) -> FileDownloadResponse: storage_resource = self.get_storage_resource(str(url)) if storage_resource.filename is None: - raise InvalidDialURLError("Url points to a directory, not a file") + raise InvalidDialURLError("URL points to a directory, not a file") response = await self.http_client.request( cast_to=httpx.Response, options=FinalRequestOptions( diff --git a/aidial_client/types/chat/response.py b/aidial_client/types/chat/response.py index a84beab..6560b39 100644 --- a/aidial_client/types/chat/response.py +++ b/aidial_client/types/chat/response.py @@ -26,7 +26,7 @@ def validate_data_or_url_v2(cls, values): and "data" not in values and "url" not in values ): - raise ValueError("Either data or url must be provided") + raise ValueError("Either data or URL must be provided") return values else: @@ -34,7 +34,7 @@ def validate_data_or_url_v2(cls, values): @root_validator(pre=True) def validate_data_or_url_v1(cls, values): if "data" not in values and "url" not in values: - raise ValueError("Either data or url must be provided") + raise ValueError("Either data or URL must be provided") return values diff --git a/tests/helpers/test_storage_resource_mixin.py b/tests/helpers/test_storage_resource_mixin.py index fde9342..a12ce0c 100644 --- a/tests/helpers/test_storage_resource_mixin.py +++ b/tests/helpers/test_storage_resource_mixin.py @@ -68,7 +68,7 @@ def test_get_api_path_invalid_dial_url(resource_type): url = "https://other-dial.core/v1/files/bucket/file.txt" with pytest.raises(NotDialURLError) as e: mixin.get_api_path(url) - assert e.value.message == f"Provided url is not DIAL url: {url}" + assert e.value.message == f"Provided URL is not DIAL URL: {url}" @pytest.mark.parametrize( @@ -87,7 +87,7 @@ def test_get_api_path_invalid_resource_type(resource_type, url): resource_type=resource_type, dial_api_url=DIAL_API_URL ) with pytest.raises( - InvalidDialURLError, match="Invalid resource type for url" + InvalidDialURLError, match="Invalid resource type for URL" ): mixin.get_api_path(url) @@ -104,7 +104,7 @@ def test_get_api_path_missing_bucket(resource_type, url): mixin = DialStorageResourceMixin( resource_type=resource_type, dial_api_url=DIAL_API_URL ) - with pytest.raises(InvalidDialURLError, match="Missing bucket in url"): + with pytest.raises(InvalidDialURLError, match="Missing bucket in URL"): mixin.get_api_path(url) @@ -116,7 +116,7 @@ def test_get_api_path_invalid_path(resource_type): url = "https://dial.core/v2/files/bucket/file.txt" with pytest.raises( InvalidDialURLError, - match="Provided url path .* does not match with DIAL API url", + match="Provided URL path .* does not match with DIAL API URL", ): mixin.get_api_path( url, diff --git a/tests/resources/files/test_upload.py b/tests/resources/files/test_upload.py index e979610..cdd9c74 100644 --- a/tests/resources/files/test_upload.py +++ b/tests/resources/files/test_upload.py @@ -50,7 +50,7 @@ async def test_upload_file_object_async(): with open(current_file_path, "rb") as file: with pytest.raises( - InvalidDialURLError, match="Invalid resource type for url" + InvalidDialURLError, match="Invalid resource type for URL" ): await client.files.upload( url="prompts/test-bucket/folder1/folder2/file.png", file=file