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/.github/CODEOWNERS b/.github/CODEOWNERS index 7a8f93f..0ef014d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ -* @adubovik +* @adubovik @roman-romanov-o /.github/ @nepalevov @alexey-ban \ No newline at end of file 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 diff --git a/.ort.yml b/.ort.yml index a81c96d..c29f318 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" @@ -12,9 +9,9 @@ excludes: comment: "Packages for testing only." resolutions: rule_violations: - - message: ".*PyPI::httpcore:0\\.18\\.0.*" + - 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\\.0.*" + 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 diff --git a/Makefile b/Makefile index e2d65ca..279381b 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,16 @@ -all: build +.PHONY: all install clean lint format test spell_check -install: - poetry install --all-extras +all: build build: install poetry build -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 +install: + poetry install -publish: build - poetry publish -u __token__ -p ${PYPI_TOKEN} --skip-existing +clean: + poetry run clean + poetry env remove --all lint: install poetry run nox -s lint @@ -25,14 +21,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..08e34d4 --- /dev/null +++ b/aidial_client/__init__.py @@ -0,0 +1,24 @@ +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, + InvalidDialURLError, + InvalidRequestError, + ParsingDataError, +) + +__all__ = [ + "Dial", + "AsyncDial", + "DialClientPool", + "AsyncDialClientPool", + "AuthType", + "SyncAuthValue", + "AsyncAuthValue", + # Exceptions + "DialException", + "InvalidDialURLError", + "InvalidRequestError", + "ParsingDataError", +] diff --git a/aidial_client/_auth.py b/aidial_client/_auth.py new file mode 100644 index 0000000..28033cd --- /dev/null +++ b/aidial_client/_auth.py @@ -0,0 +1,99 @@ +from enum import Enum +from inspect import isawaitable +from typing import ( + Awaitable, + Callable, + Dict, + Optional, + Tuple, + TypeVar, + Union, + overload, +) + +from typing_extensions import assert_never + + +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], +) + + +@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) + + +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} + 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]: + processed_auth_value = get_auth_value(auth_value) + return _get_auth_headers(auth_type, processed_auth_value) + + +async def aget_auth_headers( + auth_value: AsyncAuthValue, + auth_type: AuthType, +) -> 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 new file mode 100644 index 0000000..adef69a --- /dev/null +++ b/aidial_client/_client.py @@ -0,0 +1,234 @@ +from abc import ABC, abstractmethod +from pathlib import PurePosixPath +from typing import Dict, Generic, Optional, TypeVar, Union +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, + process_auth, +) +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._internal_types._defaults import NOT_GIVEN, NotGiven +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] + _my_bucket: Optional[str] + _my_appdata: Union[AppData, None, NotGiven] + + def __init__( + self, + *, + base_url: str, + api_key: Optional[AuthValueT] = None, + bearer_token: Optional[AuthValueT] = None, + 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 = 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._my_bucket = None + self._my_appdata = NOT_GIVEN + 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 self._my_bucket is None: + self._my_bucket = self._get_my_bucket() + return 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 isinstance(self._my_appdata, NotGiven): + self._my_appdata = self._get_my_appdata() + return self._my_appdata + + def my_appdata_home(self) -> Optional[PurePosixPath]: + appdata = self.my_appdata() + if appdata: + return PurePosixPath(appdata.raw) + return None + + def auth_headers(self) -> Dict[str, str]: + return self._http_client.auth_headers() + + +class AsyncDial(BaseDialClient[AsyncHTTPClient, AsyncAuthValue]): + 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 self._my_bucket is None: + self._my_bucket = await self._get_my_bucket() + return 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 isinstance(self._my_appdata, NotGiven): + self._my_appdata = await self._get_my_appdata() + return self._my_appdata + + async def my_appdata_home(self) -> Optional[PurePosixPath]: + appdata = await self.my_appdata() + if appdata: + return PurePosixPath(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..91a90c6 --- /dev/null +++ b/aidial_client/_client_pool.py @@ -0,0 +1,88 @@ +from typing import Optional, Union + +import httpx + +from aidial_client._auth import AsyncAuthValue, SyncAuthValue, process_auth +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 = 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( + base_url=base_url, + auth_value=auth_value, + auth_type=auth_type, + max_retries=max_retries, + timeout=timeout, + internal_http_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 = 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, + internal_http_client=self._internal_http_client, + ), + ) 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 new file mode 100644 index 0000000..b8fb3b1 --- /dev/null +++ b/aidial_client/_compatibility/openai.py @@ -0,0 +1,7 @@ +""" +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._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 new file mode 100644 index 0000000..04d3c2e --- /dev/null +++ b/aidial_client/_compatibility/pydantic.py @@ -0,0 +1,3 @@ +import pydantic + +PYDANTIC_V2 = pydantic.VERSION.startswith("2.") 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..bdfc9c6 --- /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 = urljoin(OPENAI_PREFIX, "applications/") diff --git a/aidial_client/_exception.py b/aidial_client/_exception.py new file mode 100644 index 0000000..a7e041e --- /dev/null +++ b/aidial_client/_exception.py @@ -0,0 +1,76 @@ +from http import HTTPStatus +from typing import Mapping, Optional + + +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__() + + @classmethod + def from_error_data( + cls, status_code: int, error_data: Mapping + ) -> "DialException": + message = error_data["message"] + assert isinstance(message, str) + 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 InvalidRequestError(DialException): + def __init__(self, message: str, **kwargs) -> None: + super().__init__( + message=message, + type="invalid_request_error", + status_code=HTTPStatus.BAD_REQUEST, + **kwargs, + ) + + +class InvalidDialURLError(InvalidRequestError): + pass + + +class NotDialURLError(InvalidRequestError): + pass + + +class ParsingDataError(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..d145ff8 --- /dev/null +++ b/aidial_client/_http_client/__init__.py @@ -0,0 +1,3 @@ +from ._async import AsyncHTTPClient # noqa: F401 +from ._base import BaseHTTPClient # noqa: F401 +from ._sync import SyncHTTPClient # noqa: F401 diff --git a/aidial_client/_http_client/_async.py b/aidial_client/_http_client/_async.py new file mode 100644 index 0000000..266b096 --- /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, 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 +from aidial_client._internal_types._http_request import FinalRequestOptions +from aidial_client._log import logger +from aidial_client._utils._response_processing import process_block_response + + +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 aget_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, + _on_http_error: 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._internal_http_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 = _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 + ) + raise raised_error from err + + return process_block_response(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..49bf530 --- /dev/null +++ b/aidial_client/_http_client/_base.py @@ -0,0 +1,141 @@ +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]): + _internal_http_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], + 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_http_client = ( + internal_http_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._internal_http_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, + 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="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) + return DialException.from_error_data( + 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._internal_http_client diff --git a/aidial_client/_http_client/_sync.py b/aidial_client/_http_client/_sync.py new file mode 100644 index 0000000..382b9a5 --- /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 process_block_response + + +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._internal_http_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 process_block_response(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..da93638 --- /dev/null +++ b/aidial_client/_internal_types/_http_request.py @@ -0,0 +1,68 @@ +from io import BufferedReader +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, + # 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, + # (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..886ab19 --- /dev/null +++ b/aidial_client/_internal_types/_model.py @@ -0,0 +1,19 @@ +import pydantic + +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 + + +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/_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..49cfc05 --- /dev/null +++ b/aidial_client/_utils/_alias.py @@ -0,0 +1,42 @@ +""" +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 + + +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..545a474 --- /dev/null +++ b/aidial_client/_utils/_openai.py @@ -0,0 +1,55 @@ +from typing import AsyncIterator, Iterator + +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: openai.APIError) -> 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]: + try: + for chunk in openai_response: + yield ChatCompletionChunk(**chunk.model_dump()) + except openai.APIError as e: + raise convert_openai_error(e) from e + + +async def convert_openai_async_stream( + openai_response: AsyncIterator[OpenAIChatCompletionChunk], +) -> AsyncIterator[ChatCompletionChunk]: + try: + async for chunk in openai_response: + yield ChatCompletionChunk(**chunk.model_dump()) + except openai.APIError as e: + raise convert_openai_error(e) from e diff --git a/aidial_client/_utils/_response_processing.py b/aidial_client/_utils/_response_processing.py new file mode 100644 index 0000000..d3e4e51 --- /dev/null +++ b/aidial_client/_utils/_response_processing.py @@ -0,0 +1,33 @@ +from typing import Type, cast + +import httpx + +from aidial_client._exception import ParsingDataError +from aidial_client._internal_types._generic import NoneType, ResponseT +from aidial_client._internal_types._model import ( + ExtraAllowModel, + ExtraForbidModel, +) + + +def process_block_response( + 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, ExtraAllowModel)): + try: + data = response.json() + return cast_to(**data) + except Exception as e: + raise ParsingDataError( + message=f"Error during parsing of response data: {str(e)}" + ) + else: + raise NotImplementedError("This cast_to type is not supported.") 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/_url.py b/aidial_client/helpers/_url.py new file mode 100644 index 0000000..51c4ceb --- /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): + s = s.lstrip(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 new file mode 100644 index 0000000..190e4b0 --- /dev/null +++ b/aidial_client/helpers/storage_resource.py @@ -0,0 +1,158 @@ +from pathlib import PurePosixPath +from typing import Literal, Optional, Union, cast, get_args +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, NotDialURLError +from aidial_client.helpers._url import enforce_trailing_slash + +StorageResourceType = Literal["files", "conversations", "prompts"] + + +def _is_directory(s: str) -> bool: + return s[-1] == "/" + + +class DialStorageResource(BaseModel): + resource_type: StorageResourceType + + """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' + None for a directory + """ + filename: Optional[str] = None + + +def safe_parse_storage_resource( + *, + url: str, + 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"Root-relative URL is forbidden: {url}") + if url.startswith(API_PREFIX): + return InvalidDialURLError( + f"API prefix as relative part is not allowed: {url}" + ) + + absolute_url = urljoin(dial_api_url, url) + 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}") + 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}" + ) + + resource_path = api_path.parents[len(api_path.parents) - 2] + parsed_resource_type = str(resource_path) + + if parsed_resource_type not in get_args(StorageResourceType): + return 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 + ): + 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: + 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, + 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 if not _is_directory(url) else None, + ) + + +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: + - /v1/files + - /v1/conversations + - /v1/prompts + """ + + resource_type: StorageResourceType + 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=url, + dial_api_url=self.dial_api_url, + expected_resource_type=self.resource_type, + ) + + def get_api_path(self, url: str) -> str: + """ + Convert URL, that could relative or absolute, to relative 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 + """ + return self.get_storage_resource(url).bucket_path 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..32234e3 --- /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 _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._list_raw().data + + +class AsyncDeployments(AsyncResource): + 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._list_raw()).data diff --git a/aidial_client/resources/files.py b/aidial_client/resources/files.py new file mode 100644 index 0000000..e489291 --- /dev/null +++ b/aidial_client/resources/files.py @@ -0,0 +1,117 @@ +from pathlib import PurePosixPath +from typing import Union +from urllib.parse import urljoin + +import httpx + +from aidial_client._constants import API_PREFIX +from aidial_client._exception import InvalidDialURLError +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 + + +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}, + ), + ) + + 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), + ), + ) + 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))), + ), + ) + + 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}, + ), + ) + + 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), + ), + ) + 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))), + ), + ) + + 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..d970935 --- /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 typing_extensions import assert_never + +from aidial_client._constants import METADATA_PREFIX +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, + FileMetadata, + PromptMetadata, +) + + +def _get_cast_to( + resource: StorageResourceType, +) -> Union[ + Type[FileMetadata], Type[ConversationMetadata], Type[PromptMetadata] +]: + if resource == "files": + return FileMetadata + elif resource == "conversations": + return ConversationMetadata + elif resource == "prompts": + return PromptMetadata + else: + assert_never(resource) + + +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: StorageResourceType, + 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: StorageResourceType, + 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..2f4afb7 --- /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]] = None + 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..6e18d99 --- /dev/null +++ b/aidial_client/types/chat/legacy/__init__.py @@ -0,0 +1 @@ +from .chat_completion import ChatCompletionRequest # noqa: F401 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..6560b39 --- /dev/null +++ b/aidial_client/types/chat/response.py @@ -0,0 +1,109 @@ +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 + title: Optional[str] = None + data: Optional[str] = None + url: Optional[str] = None + reference_type: Optional[str] = None + reference_url: Optional[str] = None + + 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): + 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 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..b414af5 --- /dev/null +++ b/aidial_client/types/metadata.py @@ -0,0 +1,64 @@ +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): + if PYDANTIC_V2: + model_config = { + "alias_generator": to_camel, + "populate_by_name": True, + } + else: + + 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..1279559 100644 --- a/noxfile.py +++ b/noxfile.py @@ -15,19 +15,71 @@ 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", + f"--cov={SRC}", + "--cov-report=xml", + "--cov-report=term", + "--ignore=tests/integration", + ) + 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"]) +@nox.parametrize("aiofiles", ["0.5.0", "24.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") + + +@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", ["0.5.0", "24.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") diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..42b92b9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,941 @@ +# 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.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[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)", "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.4.0" +description = "Bash tab completion for argparse" +optional = false +python-versions = ">=3.8" +files = [ + {file = "argcomplete-3.4.0-py3-none-any.whl", hash = "sha256:69a79e083a716173e5532e0fa3bef45f793f4e61096cf52b5a42c0211c8b8aa5"}, + {file = "argcomplete-3.4.0.tar.gz", hash = "sha256:c2abcdfe1be8ace47ba777d4fce319eb13bf8ad9dace8d085dcad6eded88057f"}, +] + +[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.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {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] +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.7.4" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, +] + +[[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.15.4" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {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 (>=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" +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.5" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[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,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.25.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.25.2-py3-none-any.whl", hash = "sha256:a05d3d052d9b2dfce0e3896636467f8a5342fb2b902c819428e1ac65413ca118"}, + {file = "httpx-0.25.2.tar.gz", hash = "sha256:8b8fcaa0c8ea7b05edd69a094e63a2094c4efcb48129fb757361bc423c0ad9e8"}, +] + +[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.*)"] + +[[package]] +name = "idna" +version = "3.7" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.5" +files = [ + {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"}, + {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, +] + +[[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.5.0" +description = "Fast iterable JSON parser." +optional = false +python-versions = ">=3.8" +files = [ + {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]] +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.0" +description = "The official Python library for the openai API" +optional = false +python-versions = ">=3.7.1" +files = [ + {file = "openai-1.51.0-py3-none-any.whl", hash = "sha256:d9affafb7e51e5a27dce78589d4964ce4d6f6d560307265933a94b2e3f3c5d2c"}, + {file = "openai-1.51.0.tar.gz", hash = "sha256:8dc4f9d75ccdd5466fc8c99a952186eddceb9fd6ba694044773f3736a847149d"}, +] + +[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.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.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +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" +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.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {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.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\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {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] +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.372" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +files = [ + {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" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + +[[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.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[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.3" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, +] + +[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 = "eea85f4ac82a62809786e104d0b48febe5223d100733856ab97b1648d64d163d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c8fcbe5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[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 = ">=0.5.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"] +filterwarnings = ["error::pytest.PytestUnhandledCoroutineWarning"] + +[tool.pyright] +typeCheckingMode = "basic" +reportUnusedVariable = "error" +reportIncompatibleMethodOverride = "error" +exclude = [ + ".git", + "**/.venv", + ".nox", + ".pytest_cache", + "**/__pycache__", + "build", +] + +[tool.black] +line-length = 80 +exclude = ''' +/( + \.git + | \.venv + | \.nox + | \.pytest_cache + | \.__pycache__ +)/ +''' + +[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"] + +[tool.coverage.run] +source = ["."] +omit = [ + "tests/*", + "*/__init__.py", + "noxfile.py", + 'aidial_client/_compatibility/pydantic_v1.py', + 'htmlcov/*', +] diff --git a/tests/client_mock.py b/tests/client_mock.py new file mode 100644 index 0000000..fb46ade --- /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._internal_http_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._internal_http_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..a12ce0c --- /dev/null +++ b/tests/helpers/test_storage_resource_mixin.py @@ -0,0 +1,151 @@ +import pytest + +from aidial_client._exception import InvalidDialURLError, NotDialURLError +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", 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(NotDialURLError) as e: + mixin.get_api_path(url) + assert e.value.message == f"Provided URL is not DIAL URL: {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( + InvalidDialURLError, 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(InvalidDialURLError, 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( + InvalidDialURLError, + 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..dba29a2 --- /dev/null +++ b/tests/helpers/test_storage_resource_parser.py @@ -0,0 +1,180 @@ +import pytest + +from aidial_client._exception import InvalidDialURLError, NotDialURLError +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", + }, + ), + ( + "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", + }, + ), + ( + "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( + url, dial_api_url, resource_type, expected +): + result = parse_storage_resource( + url=url, + dial_api_url=dial_api_url, + expected_resource_type=resource_type, + ) + 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", + [ + ( + "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", + ), + ( + "/files/test-bucket/files.txt", + "https://dial.core/v1/", + "files", + ), + ( + "v1/files/test-bucket/files.txt", + "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): + with pytest.raises(InvalidDialURLError): + parse_storage_resource( + url=url, + dial_api_url=dial_api_url, + expected_resource_type=resource_type, + ) + + +@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(NotDialURLError): + parse_storage_resource( + url="https://example.com/files/my-bucket/file.txt", + dial_api_url="https://dial.core/v1/", + expected_resource_type="files", + ) diff --git a/tests/helpers/test_url_helpers.py b/tests/helpers/test_url_helpers.py new file mode 100644 index 0000000..103b574 --- /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..1789cbe --- /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.list() + 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..6246877 --- /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.list() + 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..a35b61c --- /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.list() + 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..cdd9c74 --- /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 InvalidDialURLError +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( + InvalidDialURLError, 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..56bb4cf --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,85 @@ +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"}, + ), + ], +) +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..2150f15 --- /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._internal_http_client) == id( + client_2._http_client._internal_http_client + ) diff --git a/tests/test_client_retry.py b/tests/test_client_retry.py new file mode 100644 index 0000000..dc3bebc --- /dev/null +++ b/tests/test_client_retry.py @@ -0,0 +1,128 @@ +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, expected_message", + [ + (httpx.TimeoutException("Request timed out"), "Request timed out"), + (Exception("Unknown"), "Unknown error during request"), + ], +) +def test_exception_retry_sync(exception, expected_message): + 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.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.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, expected_message", + [ + (httpx.TimeoutException("Request timed out"), "Request timed out"), + (Exception("Unknown"), "Unknown error during request"), + ], +) +@pytest.mark.asyncio +async def test_exception_retry_async(exception, expected_message): + 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.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.value, DialException) + assert e.value.message == expected_message + assert retry_request_mock.call_count == 1 + + +@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.value.status_code == status_code + 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.value.status_code == status_code diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 0000000..0fcea3a --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,56 @@ +import pytest +from pydantic import ValidationError + +from aidial_client.types.chat.response import Attachment +from aidial_client.types.metadata import BaseMetadata + + +@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 + + +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 + )