diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87c1dbe..9c66ddf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,12 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install poetry==1.3.2 + pip install poetry==2.2.1 poetry config virtualenvs.create false poetry -vv install + - name: generate sync code + run: | + make generate-sync - name: Lint with black run: | black . --check diff --git a/.gitignore b/.gitignore index 5ba9f0c..7fa4a78 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ **/.pytest_cache dist **/__pycache__ +kindwise/core.py +kindwise/crop_health.py +kindwise/insect.py +kindwise/mushroom.py +kindwise/plant.py \ No newline at end of file diff --git a/Makefile b/Makefile index ed7f465..9ebd6f8 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,6 @@ +generate-sync: + python generate/generate_sync.py + version-patch: bump2version patch diff --git a/README.md b/README.md index 8680d18..ba67305 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,12 @@ If you want to use the router offline classifier: pip install kindwise-api-client[router] ``` +If you want to use async interface: + +``` +pip install kindwise-api-client[async] +``` + ### API key The API key serves to identify your account and is required to make requests to the API. Get API key at @@ -548,3 +554,37 @@ print(router.identify(image_path).simple) # } ``` + +### Async Interface + +The same available methods are also available in async interface. Here is an example of how to use it. + +```python +import asyncio +from kindwise import AsyncPlantApi, PlantIdentification, UsageInfo + +async def main(): + # initialize plant.id api + # "PLANT_API_KEY" environment variable can be set instead of specifying api_key + api = AsyncPlantApi(api_key='your_api_key') + # get usage information + usage: UsageInfo = await api.usage_info() + + # identify plant by image + latitude_longitude = (49.20340, 16.57318) + # pass the image as a path + image_path = 'path/to/plant_image.jpg' + # make identification + identification: PlantIdentification = await api.identify(image_path, latitude_longitude=latitude_longitude) + + # get identification by a token with changed views + # this method can be used to modify additional information in identification or to get identification from database + # also works with identification.custom_id + identification_with_different_views: PlantIdentification = await api.get_identification(identification.access_token) + + # delete identification + await api.delete_identification(identification) # also works with identification.access_token or identification.custom_id + +if __name__ == "__main__": + asyncio.run(main()) +``` \ No newline at end of file diff --git a/README_internal.md b/README_internal.md index 3b70cea..fb42490 100644 --- a/README_internal.md +++ b/README_internal.md @@ -3,11 +3,22 @@ ```bash conda env create conda activate kindowse-sdk -poetry install +pipx install poetry==2.2.1 +poetry install --extras router pre-commit install pre-commit install --hook-type commit-msg +make generate-sync ``` +## Developmnet + +Do not directly modify files under `kindwise` directory. The ground truth code is located in `kindwise/async_api` directory. Use the following command to generate sync code from async code: + +```bash +python generate/generate_sync_code.py +# or +make generate-sync +``` ## Tests Specify server used for testing via environmental variable `ENVIRONMENT`. @@ -15,3 +26,20 @@ Specify server used for testing via environmental variable `ENVIRONMENT`. - LOCAL: usually used for development on one system which is run locally - STAGING: default - PRODUCTION + + +## Deployment + +```bash +# Bump version +# +# if changes are not backward compatible, use "major" +make version-major +# if changes are backward compatible, use "minor" and some new features are added +make version-minor +# if only bug fixes, use "patch" +make version-patch + +# Publish to PyPI +make publish +``` \ No newline at end of file diff --git a/generate/generate_sync.py b/generate/generate_sync.py new file mode 100644 index 0000000..126b24f --- /dev/null +++ b/generate/generate_sync.py @@ -0,0 +1,53 @@ +import unasync +import os +from pathlib import Path + + +def post_process(filepath): + """ + Manually fixes imports that unasync's token matching cannot handle. + """ + with open(filepath, "r", encoding="utf-8") as f: + content = f.read() + + # FIX 1: Rewrite the dotted import path + # Turns "from kindwise.async_api.core" -> "from kindwise.core" + content = content.replace("kindwise.async_api", "kindwise") + + # FIX 2: Ensure any leftover relative imports are correct (if needed) + # content = content.replace("... something else ...", "...") + + with open(filepath, "w", encoding="utf-8") as f: + f.write(content) + + +def main(): + gt_code_dir = Path(__file__).resolve().parent.parent / 'kindwise' / 'async_api' + filepaths = [path for path in gt_code_dir.glob('*.py') if path.stem != '__init__'] + rules = [ + unasync.Rule( + fromdir="/kindwise/async_api", + todir="/kindwise", + additional_replacements={ + "AsyncClient": "Client", + "AsyncKindwiseApi": "KindwiseApi", + "anyio": "pathlib", + "AsyncInsectApi": "InsectApi", + "AsyncMushroomApi": "MushroomApi", + "AsyncCropHealthApi": "CropHealthApi", + "AsyncPlantApi": "PlantApi", + }, + ) + ] + + filepaths_to_process = [os.path.abspath(p) for p in filepaths] + unasync.unasync_files(filepaths_to_process, rules) + generated_files = [] + for path in filepaths: + dest_path = path.parent.parent / path.name + post_process(dest_path) + generated_files.append(str(dest_path)) + + +if __name__ == "__main__": + main() diff --git a/kindwise/__init__.py b/kindwise/__init__.py index dec6ec5..3a0061d 100644 --- a/kindwise/__init__.py +++ b/kindwise/__init__.py @@ -13,3 +13,7 @@ from kindwise.mushroom import MushroomApi, MushroomKBType from kindwise.plant import HealthAssessment, PlantApi, PlantIdentification, PlantKBType, RawPlantIdentification from kindwise.router import Router, RouterSize +from kindwise.async_api.crop_health import AsyncCropHealthApi +from kindwise.async_api.insect import AsyncInsectApi +from kindwise.async_api.mushroom import AsyncMushroomApi +from kindwise.async_api.plant import AsyncPlantApi diff --git a/kindwise/async_api/__init__.py b/kindwise/async_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kindwise/core.py b/kindwise/async_api/core.py similarity index 79% rename from kindwise/core.py rename to kindwise/async_api/core.py index e0db0c9..28db977 100644 --- a/kindwise/core.py +++ b/kindwise/async_api/core.py @@ -7,7 +7,8 @@ from pathlib import Path, PurePath from typing import Any, BinaryIO, Generic, TypeVar -import requests +import anyio +import httpx from PIL import Image from kindwise.models import Conversation, Identification, SearchResult, UsageInfo @@ -16,7 +17,7 @@ KBType = TypeVar('KBType') -class KindwiseApi(abc.ABC, Generic[IdentificationType, KBType]): +class AsyncKindwiseApi(abc.ABC, Generic[IdentificationType, KBType]): identification_class = Identification default_kb_type = None @@ -26,16 +27,19 @@ def __init__(self, api_key: str): @property @abc.abstractmethod def identification_url(self): + "Url for identification endpoint" ... @property @abc.abstractmethod def usage_info_url(self): + "Url for usage info endpoint" ... @property @abc.abstractmethod def kb_api_url(self): + "Url for knowledge base endpoint" ... def feedback_url(self, token: str): @@ -47,33 +51,34 @@ def conversation_url(self, token: str): def conversation_feedback_url(self, token: str): return f'{self.identification_url}/{token}/conversation/feedback' - def _make_api_call(self, url, method: str, data: dict | None = None, timeout: float = 60.0): + async def _make_api_call(self, url, method: str, data: dict | None = None, timeout: float = 60.0): headers = { 'Content-Type': 'application/json', 'Api-Key': self.api_key, } - response = requests.request(method, url, json=data, headers=headers, timeout=timeout) - if not response.ok: - raise ValueError(f'Error while making an API call: {response.status_code=} {response.text=}') - return response + async with httpx.AsyncClient() as client: + response = await client.request(method, url, json=data, headers=headers, timeout=timeout) + if response.is_error: + raise ValueError(f'Error while making an API call: {response.status_code=} {response.text=}') + return response @staticmethod - def _load_image_buffer(image: PurePath | str | bytes | BinaryIO | Image.Image) -> io.BytesIO: - def get_from_url() -> None | bytes: + async def _load_image_buffer(image: PurePath | str | bytes | BinaryIO | Image.Image) -> io.BytesIO: + async def get_from_url() -> None | bytes: if not isinstance(image, str) or not image.startswith(('http://', 'https://')): return None - response = requests.get(image) - if not response.ok: + async with httpx.AsyncClient() as client: + response = await client.get(image) + if not response.is_success: return None return io.BytesIO(response.content) - if _img := get_from_url(): + if _img := await get_from_url(): return _img if isinstance(image, str) and len(image) <= 250: # first try str as a path to a file image = Path(image) if isinstance(image, PurePath): # Path - with open(image, 'rb') as f: - return io.BytesIO(f.read()) + return io.BytesIO(await anyio.Path(image).read_bytes()) if hasattr(image, 'read') and hasattr(image, 'seek') and hasattr(image, 'mode'): # BinaryIO if 'rb' not in image.mode: # what will it do if this is not there raise ValueError(f'Invalid file mode {image.mode=}, expected "rb"(binary mode)') @@ -98,8 +103,8 @@ def is_base64(): return io.BytesIO(sb_bytes) @staticmethod - def _encode_image(image: PurePath | str | bytes | BinaryIO | Image.Image, max_image_size: int | None) -> str: - buffer = KindwiseApi._load_image_buffer(image) + async def _encode_image(image: PurePath | str | bytes | BinaryIO | Image.Image, max_image_size: int | None) -> str: + buffer = await AsyncKindwiseApi._load_image_buffer(image) def resize_image(file) -> bytes: img = Image.open(file) @@ -122,7 +127,7 @@ def resize_image(file) -> bytes: buffer.close() return base64.b64encode(img).decode('ascii') - def _build_payload( + async def _build_payload( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], similar_images: bool = True, @@ -137,7 +142,7 @@ def _build_payload( image = [image] payload = { - 'images': [self._encode_image(img, max_image_size) for img in image], + 'images': [await self._encode_image(img, max_image_size) for img in image], 'similar_images': similar_images, } if latitude_longitude is not None: @@ -160,7 +165,7 @@ def _build_payload( payload.update(extra_post_params) return payload - def identify( + async def identify( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], details: str | list[str] = None, @@ -177,7 +182,7 @@ def identify( timeout: float = 60.0, **kwargs, ) -> IdentificationType | dict: - payload = self._build_payload( + payload = await self._build_payload( image, similar_images=similar_images, latitude_longitude=latitude_longitude, @@ -191,7 +196,7 @@ def identify( details=details, language=language, asynchronous=asynchronous, extra_get_params=extra_get_params, **kwargs ) url = f'{self.identification_url}{query}' - response = self._make_api_call(url, 'POST', payload, timeout=timeout) + response = await self._make_api_call(url, 'POST', payload, timeout=timeout) data = response.json() return data if as_dict else self.identification_class.from_dict(data) @@ -218,7 +223,7 @@ def _build_query( extra_get_params = '&'.join(f'{k}={v}' for k, v in extra_get_params.items()) if extra_get_params.startswith('?'): extra_get_params = extra_get_params[1:] + '&' - async_query = f'async=true&' if asynchronous else '' + async_query = 'async=true&' if asynchronous else '' query = '' if query is None else f'q={query}&' limit = '' if limit is None else f'limit={limit}&' query = f'?{query}{limit}{details_query}{language_query}{async_query}{extra_get_params}' @@ -226,7 +231,7 @@ def _build_query( query = query[:-1] return '' if query == '?' else query - def get_identification( + async def get_identification( self, token: str | int, details: str | list[str] = None, @@ -237,26 +242,26 @@ def get_identification( ) -> IdentificationType | dict: query = self._build_query(details=details, language=language, extra_get_params=extra_get_params) url = f'{self.identification_url}/{token}{query}' - response = self._make_api_call(url, 'GET', timeout=timeout) + response = await self._make_api_call(url, 'GET', timeout=timeout) data = response.json() return data if as_dict else self.identification_class.from_dict(data) - def delete_identification( + async def delete_identification( self, identification: IdentificationType | str | int, timeout: float = 60.0, ) -> bool: token = identification.access_token if isinstance(identification, Identification) else identification url = f'{self.identification_url}/{token}' - self._make_api_call(url, 'DELETE', timeout=timeout) + await self._make_api_call(url, 'DELETE', timeout=timeout) return True - def usage_info(self, as_dict: bool = False, timeout: float = 60.0) -> UsageInfo | dict: - response = self._make_api_call(self.usage_info_url, 'GET', timeout=timeout) + async def usage_info(self, as_dict: bool = False, timeout: float = 60.0) -> UsageInfo | dict: + response = await self._make_api_call(self.usage_info_url, 'GET', timeout=timeout) data = response.json() return data if as_dict else UsageInfo.from_dict(data) - def feedback( + async def feedback( self, identification: IdentificationType | str | int, comment: str | None = None, @@ -271,7 +276,7 @@ def feedback( data['comment'] = comment if rating is not None: data['rating'] = rating - self._make_api_call(self.feedback_url(token), 'POST', data, timeout=timeout) + await self._make_api_call(self.feedback_url(token), 'POST', data, timeout=timeout) return True @property @@ -283,7 +288,7 @@ def available_details(self) -> list[dict[str, any]]: with open(self.views_path) as f: return json.load(f) - def search( + async def search( self, query: str, limit: int = None, @@ -295,18 +300,18 @@ def search( if not query: raise ValueError('Query parameter q must be provided') if isinstance(limit, int) and limit < 1: - raise ValueError(f'Limit must be positive integer.') + raise ValueError('Limit must be positive integer.') if kb_type is None: kb_type = self.default_kb_type if isinstance(kb_type, enum.Enum): kb_type = kb_type.value url = f'{self.kb_api_url}/{kb_type}/name_search{self._build_query(query=query, limit=limit, language=language)}' - response = self._make_api_call(url, 'GET', timeout=timeout) - if not response.ok: + response = await self._make_api_call(url, 'GET', timeout=timeout) + if not response.is_success: raise ValueError(f'Error while searching knowledge base: {response.status_code=} {response.text=}') return response.json() if as_dict else SearchResult.from_dict(response.json()) - def get_kb_detail( + async def get_kb_detail( self, access_token: str, details: str | list[str], @@ -319,12 +324,12 @@ def get_kb_detail( if isinstance(kb_type, enum.Enum): kb_type = kb_type.value url = f'{self.kb_api_url}/{kb_type}/{access_token}{self._build_query(language=language, details=details)}' - response = self._make_api_call(url, 'GET', timeout=timeout) - if not response.ok: + response = await self._make_api_call(url, 'GET', timeout=timeout) + if not response.is_success: raise ValueError(f'Error while getting knowledge base detail: {response.status_code=} {response.text=}') return response.json() - def ask_question( + async def ask_question( self, identification: IdentificationType | str | int, question: str, @@ -340,23 +345,27 @@ def ask_question( for key, value in [('model', model), ('app_name', app_name), ('prompt', prompt), ('temperature', temperature)]: if value is not None: data[key] = value - response = self._make_api_call(self.conversation_url(token), 'POST', data, timeout=timeout) + response = await self._make_api_call(self.conversation_url(token), 'POST', data, timeout=timeout) data = response.json() return data if as_dict else Conversation.from_dict(data) - def get_conversation(self, identification: IdentificationType | str | int, timeout: float = 60.0) -> Conversation: + async def get_conversation( + self, identification: IdentificationType | str | int, timeout: float = 60.0 + ) -> Conversation: token = identification.access_token if isinstance(identification, Identification) else identification - response = self._make_api_call(self.conversation_url(token), 'GET', timeout=timeout) + response = await self._make_api_call(self.conversation_url(token), 'GET', timeout=timeout) return Conversation.from_dict(response.json()) - def delete_conversation(self, identification: IdentificationType | str | int, timeout: float = 60.0) -> bool: + async def delete_conversation(self, identification: IdentificationType | str | int, timeout: float = 60.0) -> bool: token = identification.access_token if isinstance(identification, Identification) else identification - self._make_api_call(self.conversation_url(token), 'DELETE', timeout=timeout) + await self._make_api_call(self.conversation_url(token), 'DELETE', timeout=timeout) return True - def conversation_feedback( + async def conversation_feedback( self, identification: IdentificationType | str | int, feedback: str | int | dict, timeout: float = 60.0 ) -> bool: token = identification.access_token if isinstance(identification, Identification) else identification - self._make_api_call(self.conversation_feedback_url(token), 'POST', {'feedback': feedback}, timeout=timeout) + await self._make_api_call( + self.conversation_feedback_url(token), 'POST', {'feedback': feedback}, timeout=timeout + ) return True diff --git a/kindwise/crop_health.py b/kindwise/async_api/crop_health.py similarity index 89% rename from kindwise/crop_health.py rename to kindwise/async_api/crop_health.py index 81efb9c..e61b2a9 100644 --- a/kindwise/crop_health.py +++ b/kindwise/async_api/crop_health.py @@ -3,7 +3,7 @@ from pathlib import Path from kindwise import settings -from kindwise.core import KindwiseApi +from kindwise.async_api.core import AsyncKindwiseApi from kindwise.models import Identification, ResultEvaluation, ClassificationWithScientificName, Conversation @@ -35,7 +35,7 @@ class CropHealthKBType(str, enum.Enum): CROP = 'crop' -class CropHealthApi(KindwiseApi[CropIdentification, CropHealthKBType]): +class AsyncCropHealthApi(AsyncKindwiseApi[CropIdentification, CropHealthKBType]): host = 'https://crop.kindwise.com' default_kb_type = CropHealthKBType.CROP identification_class = CropIdentification @@ -58,13 +58,13 @@ def usage_info_url(self): @property def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.crop_health.disease.json' + return settings.APP_DIR / 'resources' / 'views.crop_health.disease.json' @property def kb_api_url(self): raise NotImplementedError('Crop health API does not support knowledge base API') - def ask_question( + async def ask_question( self, identification: CropIdentification | str | int, question: str, diff --git a/kindwise/insect.py b/kindwise/async_api/insect.py similarity index 92% rename from kindwise/insect.py rename to kindwise/async_api/insect.py index a0b7ea2..55fda1f 100644 --- a/kindwise/insect.py +++ b/kindwise/async_api/insect.py @@ -7,7 +7,7 @@ from PIL import Image from kindwise import settings -from kindwise.core import KindwiseApi +from kindwise.async_api.core import AsyncKindwiseApi from kindwise.models import ( Identification, Conversation, @@ -58,7 +58,7 @@ def from_dict(cls, data: dict) -> 'InsectIdentification': ) -class InsectApi(KindwiseApi[InsectIdentification, InsectKBType]): +class AsyncInsectApi(AsyncKindwiseApi[InsectIdentification, InsectKBType]): host = 'https://insect.kindwise.com' default_kb_type = InsectKBType.INSECT @@ -82,7 +82,7 @@ def usage_info_url(self): def kb_api_url(self): return f'{self.host}/api/v1/kb' - def identify( + async def identify( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], details: str | list[str] = None, @@ -99,7 +99,7 @@ def identify( extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, timeout=60.0, ) -> InsectIdentification | dict: - identification = super().identify( + identification = await super().identify( image=image, details=details, language=language, @@ -118,7 +118,7 @@ def identify( return identification return InsectIdentification.from_dict(identification) - def get_identification( + async def get_identification( self, token: str | int, details: str | list[str] = None, @@ -128,7 +128,7 @@ def get_identification( extra_get_params: str | dict[str, str] = None, timeout=60.0, ) -> InsectIdentification | dict: - identification = super().get_identification( + identification = await super().get_identification( token=token, details=details, language=language, @@ -140,9 +140,9 @@ def get_identification( @property def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.insect.json' + return settings.APP_DIR / 'resources' / 'views.insect.json' - def ask_question( + async def ask_question( self, identification: Identification | str | int, question: str, diff --git a/kindwise/mushroom.py b/kindwise/async_api/mushroom.py similarity index 91% rename from kindwise/mushroom.py rename to kindwise/async_api/mushroom.py index a834bea..ba1ee24 100644 --- a/kindwise/mushroom.py +++ b/kindwise/async_api/mushroom.py @@ -7,7 +7,7 @@ from PIL import Image from kindwise import settings -from kindwise.core import KindwiseApi +from kindwise.async_api.core import AsyncKindwiseApi from kindwise.models import ( Identification, Conversation, @@ -58,7 +58,7 @@ def from_dict(cls, data: dict) -> 'MushroomIdentification': ) -class MushroomApi(KindwiseApi[Identification, MushroomKBType]): +class AsyncMushroomApi(AsyncKindwiseApi[Identification, MushroomKBType]): host = 'https://mushroom.kindwise.com' default_kb_type = MushroomKBType.MUSHROOM @@ -80,13 +80,13 @@ def usage_info_url(self): @property def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.mushroom.json' + return settings.APP_DIR / 'resources' / 'views.mushroom.json' @property def kb_api_url(self): return f'{self.host}/api/v1/kb' - def identify( + async def identify( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], details: str | list[str] = None, @@ -103,7 +103,7 @@ def identify( extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, timeout=60.0, ) -> MushroomIdentification | dict: - identification = super().identify( + identification = await super().identify( image=image, details=details, language=language, @@ -122,7 +122,7 @@ def identify( return identification return MushroomIdentification.from_dict(identification) - def get_identification( + async def get_identification( self, token: str | int, details: str | list[str] = None, @@ -132,7 +132,7 @@ def get_identification( extra_get_params: str | dict[str, str] = None, timeout=60.0, ) -> MushroomIdentification | dict: - identification = super().get_identification( + identification = await super().get_identification( token=token, details=details, language=language, @@ -142,11 +142,7 @@ def get_identification( ) return identification if as_dict else MushroomIdentification.from_dict(identification) - @property - def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.mushroom.json' - - def ask_question( + async def ask_question( self, identification: Identification | str | int, question: str, diff --git a/kindwise/plant.py b/kindwise/async_api/plant.py similarity index 90% rename from kindwise/plant.py rename to kindwise/async_api/plant.py index 928df16..526908b 100644 --- a/kindwise/plant.py +++ b/kindwise/async_api/plant.py @@ -8,7 +8,7 @@ from PIL import Image from kindwise import settings -from kindwise.core import KindwiseApi +from kindwise.async_api.core import AsyncKindwiseApi from kindwise.models import ( ClassificationLevel, Identification, @@ -56,9 +56,9 @@ def from_dict(cls, data: dict) -> 'PlantInput': latitude=data['latitude'], longitude=data['longitude'], similar_images=data['similar_images'], - classification_level=ClassificationLevel(data['classification_level']) - if 'classification_level' in data - else None, + classification_level=( + ClassificationLevel(data['classification_level']) if 'classification_level' in data else None + ), classification_raw=data.get('classification_raw', False), ) @@ -96,9 +96,11 @@ def from_dict(cls, data: dict): return cls( genus=[Suggestion.from_dict(suggestion) for suggestion in data['genus']], species=[Suggestion.from_dict(suggestion) for suggestion in data['species']], - infraspecies=None - if 'infraspecies' not in data - else [Suggestion.from_dict(suggestion) for suggestion in data['infraspecies']], + infraspecies=( + None + if 'infraspecies' not in data + else [Suggestion.from_dict(suggestion) for suggestion in data['infraspecies']] + ), ) @@ -187,7 +189,7 @@ def from_dict(cls, data: dict) -> 'HealthAssessment': ) -class PlantApi(KindwiseApi[PlantIdentification, PlantKBType]): +class AsyncPlantApi(AsyncKindwiseApi[PlantIdentification, PlantKBType]): host = 'https://plant.id' default_kb_type = PlantKBType.PLANTS @@ -215,7 +217,7 @@ def kb_api_url(self): def health_assessment_url(self): return f'{self.host}/api/v3/health_assessment' - def _build_payload( + async def _build_payload( self, *args, health: str = None, @@ -223,7 +225,7 @@ def _build_payload( classification_raw: bool = False, **kwargs, ): - payload = super()._build_payload(*args, **kwargs) + payload = await super()._build_payload(*args, **kwargs) if health is not None: payload['health'] = health if classification_level is not None: @@ -244,7 +246,7 @@ def _build_details(details: str | list[str] = None, disease_details: str | list[ details = list(dict.fromkeys(details + disease_details)) return details - def identify( + async def identify( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], details: str | list[str] = None, @@ -264,7 +266,7 @@ def identify( extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, timeout=60.0, ) -> PlantIdentification | RawPlantIdentification | HealthAssessment | dict: - identification = super().identify( + identification = await super().identify( image=image, details=self._build_details(details, disease_details), language=language, @@ -290,7 +292,7 @@ def identify( return HealthAssessment.from_dict(identification) return PlantIdentification.from_dict(identification) - def get_identification( + async def get_identification( self, token: str | int, details: str | list[str] = None, @@ -300,7 +302,7 @@ def get_identification( extra_get_params: str | dict[str, str] = None, timeout=60.0, ) -> PlantIdentification | dict: - identification = super().get_identification( + identification = await super().get_identification( token=token, details=self._build_details(details, disease_details), language=language, @@ -316,7 +318,7 @@ def _build_query( **kwargs, ): query = super()._build_query(**kwargs) - disease_query = f'full_disease_list=true' if full_disease_list else '' + disease_query = 'full_disease_list=true' if full_disease_list else '' if disease_query == '': return query @@ -324,7 +326,7 @@ def _build_query( return f'?{disease_query}' return f'{query}&{disease_query}' - def health_assessment( + async def health_assessment( self, image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], details: str | list[str] = None, @@ -349,7 +351,7 @@ def health_assessment( full_disease_list=full_disease_list, ) url = f'{self.health_assessment_url}{query}' - payload = self._build_payload( + payload = await self._build_payload( image, similar_images=similar_images, latitude_longitude=latitude_longitude, @@ -358,13 +360,13 @@ def health_assessment( max_image_size=max_image_size, extra_post_params=extra_post_params, ) - response = self._make_api_call(url, 'POST', payload, timeout=timeout) - if not response.ok: + response = await self._make_api_call(url, 'POST', payload, timeout=timeout) + if not response.is_success: raise ValueError(f'Error while creating a health assessment: {response.status_code=} {response.text=}') health_assessment = response.json() return health_assessment if as_dict else HealthAssessment.from_dict(health_assessment) - def get_health_assessment( + async def get_health_assessment( self, token: str | int, details: str | list[str] = None, @@ -378,24 +380,24 @@ def get_health_assessment( details=details, language=language, full_disease_list=full_disease_list, extra_get_params=extra_get_params ) url = f'{self.identification_url}/{token}{query}' - response = self._make_api_call(url, 'GET', timeout=timeout) - if not response.ok: + response = await self._make_api_call(url, 'GET', timeout=timeout) + if not response.is_success: raise ValueError(f'Error while getting a health assessment: {response.status_code=} {response.text=}') health_assessment = response.json() return health_assessment if as_dict else HealthAssessment.from_dict(health_assessment) - def delete_health_assessment( + async def delete_health_assessment( self, identification: HealthAssessment | str | int, timeout=60.0, ) -> bool: - return self.delete_identification(identification, timeout=timeout) + return await self.delete_identification(identification, timeout=timeout) @property def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.plant.json' + return settings.APP_DIR / 'resources' / 'views.plant.json' @classmethod def available_disease_details(cls) -> list[dict[str, any]]: - with open(settings.APP_DIR / 'resources' / f'views.plant.disease.json') as f: + with open(settings.APP_DIR / 'resources' / 'views.plant.disease.json') as f: return json.load(f) diff --git a/kindwise/tests/async_api/test_core.py b/kindwise/tests/async_api/test_core.py new file mode 100644 index 0000000..423114f --- /dev/null +++ b/kindwise/tests/async_api/test_core.py @@ -0,0 +1,202 @@ +import enum +from pathlib import Path +from unittest.mock import patch +from kindwise.async_api.core import AsyncKindwiseApi +from kindwise.models import Identification +import pytest +import base64 +import httpx + + +class TestKBType(str, enum.Enum): + TEST = 'test' + default = TEST + + +class AsyncTestApi(AsyncKindwiseApi[Identification, TestKBType]): + default_kb_type = TestKBType.TEST + + @property + def identification_url(self): + return 'https://api.kindwise.com/api/v1/identification' + + @property + def usage_info_url(self): + return 'https://api.kindwise.com/api/v1/usage_info' + + @property + def kb_api_url(self): + return 'https://api.kindwise.com/api/v1/kb' + + @property + def views_path(self): + return Path('.') + + +@pytest.fixture +def api(): + return AsyncTestApi(api_key='test_key') + + +@pytest.fixture +def image_base64(): + return 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' + + +@pytest.fixture +def identification_data(): + return { + 'access_token': 'token', + 'model_version': 'test:1.0', + 'custom_id': None, + 'input': { + 'images': ['img'], + 'datetime': '2023-01-01T00:00:00', + 'latitude': None, + 'longitude': None, + 'similar_images': True, + }, + 'result': {'classification': {'suggestions': []}}, + 'status': 'COMPLETED', + 'sla_compliant_client': True, + 'sla_compliant_system': True, + 'created': 1234567890, + 'completed': 1234567890, + } + + +@pytest.mark.anyio +async def test_identify(api, respx_mock, identification_data, image_base64): + respx_mock.post(api.identification_url).mock(return_value=httpx.Response(200, json=identification_data)) + + # Pass bytes to avoid file path detection for short strings + result = await api.identify(image_base64.encode('ascii')) + assert isinstance(result, Identification) + assert result.access_token == 'token' + + +@pytest.mark.anyio +async def test_get_identification(api, respx_mock, identification_data): + respx_mock.get(f'{api.identification_url}/token').mock(return_value=httpx.Response(200, json=identification_data)) + + result = await api.get_identification('token') + assert isinstance(result, Identification) + assert result.access_token == 'token' + + +@pytest.mark.anyio +async def test_identify_with_path(api, respx_mock, identification_data, image_base64): + respx_mock.post(api.identification_url).mock(return_value=httpx.Response(200, json=identification_data)) + + # Patch anyio.Path used in the async_core module + with patch('kindwise.async_api.core.anyio.Path') as MockAnyioPath: + mock_instance = MockAnyioPath.return_value + + real_bytes = base64.b64decode(image_base64) + + async def get_bytes(): + return real_bytes + + mock_instance.read_bytes.side_effect = get_bytes + + result = await api.identify('/tmp/fake.jpg') + assert isinstance(result, Identification) + assert result.access_token == 'token' + + +@pytest.mark.anyio +async def test_delete_identification(api, respx_mock): + respx_mock.delete(f'{api.identification_url}/token').mock(return_value=httpx.Response(200, json=True)) + result = await api.delete_identification('token') + assert result is True + + +@pytest.mark.anyio +async def test_usage_info(api, respx_mock): + usage_data = { + "active": True, + "credit_limits": {"day": None, "week": None, "month": None, "total": 100}, + "used": {"day": 1, "week": 1, "month": 1, "total": 2}, + "can_use_credits": {"value": True, "reason": None}, + "remaining": {"day": None, "week": None, "month": None, "total": 98}, + } + respx_mock.get(api.usage_info_url).mock(return_value=httpx.Response(200, json=usage_data)) + result = await api.usage_info() + # It returns a UsageInfo object because as_dict=False by default + assert result.active is True + assert result.used.total == 2 + + +@pytest.mark.anyio +async def test_feedback(api, respx_mock): + respx_mock.post(f'{api.identification_url}/token/feedback').mock(return_value=httpx.Response(200, json=True)) + result = await api.feedback('token', rating=5) + assert result is True + + +@pytest.mark.anyio +async def test_search(api, respx_mock): + search_data = { + 'entities': [ + { + 'matched_in': 'Bee', + 'matched_in_type': 'common_name', + 'access_token': '1', + 'match_position': 0, + 'match_length': 3, + } + ], + 'entities_trimmed': False, + 'limit': 20, + } + respx_mock.get(f'{api.kb_api_url}/test/name_search').mock(return_value=httpx.Response(200, json=search_data)) + result = await api.search('Bee') + assert len(result.entities) == 1 + assert result.entities[0].matched_in == 'Bee' + + +@pytest.mark.anyio +async def test_get_kb_detail(api, respx_mock): + kb_data = {'gbif_id': 123} + respx_mock.get(f'{api.kb_api_url}/test/token').mock(return_value=httpx.Response(200, json=kb_data)) + # get_kb_detail returns dict + result = await api.get_kb_detail('token', details='gbif_id') + assert result['gbif_id'] == 123 + + +@pytest.mark.anyio +async def test_conversation_flow(api, respx_mock): + # Ask question + question_resp = { + 'messages': [{'content': 'hi', 'type': 'question', 'created': '2023-01-01T00:00:00'}], + 'identification': 'token', + 'remaining_calls': 10, + 'model_parameters': {}, + 'feedback': {}, + } + respx_mock.post(f'{api.identification_url}/token/conversation').mock( + return_value=httpx.Response(200, json=question_resp) + ) + + conv = await api.ask_question('token', 'hi') + assert conv.identification == 'token' + assert len(conv.messages) == 1 + + # Get conversation + respx_mock.get(f'{api.identification_url}/token/conversation').mock( + return_value=httpx.Response(200, json=question_resp) + ) + conv_get = await api.get_conversation('token') + assert conv_get.identification == 'token' + + # Conversation feedback + respx_mock.post(f'{api.identification_url}/token/conversation/feedback').mock( + return_value=httpx.Response(200, json=True) + ) + res = await api.conversation_feedback('token', {'rating': 5}) + assert res is True + + # Delete conversation + respx_mock.delete(f'{api.identification_url}/token/conversation').mock(return_value=httpx.Response(200, json=True)) + del_res = await api.delete_conversation('token') + assert del_res is True diff --git a/kindwise/tests/async_api/test_crop.py b/kindwise/tests/async_api/test_crop.py new file mode 100644 index 0000000..64cefcd --- /dev/null +++ b/kindwise/tests/async_api/test_crop.py @@ -0,0 +1,15 @@ +import pytest + +from kindwise.async_api.crop_health import AsyncCropHealthApi, CropIdentification +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server + + +@pytest.mark.asyncio +async def test_requests_to_crop_server(api_key): + await run_async_test_requests_to_server( + AsyncCropHealthApi(api_key=api_key), + 'crop', + IMAGE_DIR / 'potato.late_blight.jpg', + CropIdentification, + model_name='disease', + ) diff --git a/kindwise/tests/async_api/test_insect.py b/kindwise/tests/async_api/test_insect.py new file mode 100644 index 0000000..01a8973 --- /dev/null +++ b/kindwise/tests/async_api/test_insect.py @@ -0,0 +1,11 @@ +import pytest +from kindwise.async_api.insect import AsyncInsectApi +from kindwise.models import Identification +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server + + +@pytest.mark.asyncio +async def test_requests_to_insect_server(api_key): + await run_async_test_requests_to_server( + AsyncInsectApi(api_key=api_key), 'insect', IMAGE_DIR / 'bee.jpeg', Identification + ) diff --git a/kindwise/tests/async_api/test_mushroom.py b/kindwise/tests/async_api/test_mushroom.py new file mode 100644 index 0000000..5bc51f1 --- /dev/null +++ b/kindwise/tests/async_api/test_mushroom.py @@ -0,0 +1,11 @@ +import pytest +from kindwise.async_api.mushroom import AsyncMushroomApi +from kindwise.models import Identification +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server + + +@pytest.mark.asyncio +async def test_requests_to_mushroom_server(api_key): + await run_async_test_requests_to_server( + AsyncMushroomApi(api_key=api_key), 'mushroom', IMAGE_DIR / 'amanita_muscaria.jpg', Identification + ) diff --git a/kindwise/tests/async_api/test_plant.py b/kindwise/tests/async_api/test_plant.py new file mode 100644 index 0000000..56d4653 --- /dev/null +++ b/kindwise/tests/async_api/test_plant.py @@ -0,0 +1,66 @@ +import random +from datetime import datetime + +import pytest + +from kindwise.async_api.plant import AsyncPlantApi, HealthAssessment, PlantIdentification +from kindwise.tests.conftest import IMAGE_DIR, environment_api, run_async_test_requests_to_server + + +@pytest.fixture +def image_path(): + return IMAGE_DIR / 'aloe-vera.jpg' + + +@pytest.fixture +def api(api_key): + return AsyncPlantApi(api_key) + + +@pytest.mark.asyncio +async def test_requests_to_plant_server(api, image_path): + await run_async_test_requests_to_server(api, 'plant', image_path, PlantIdentification) + + +@pytest.mark.asyncio +async def test_requests_to_plant_server__health_assessment(api, image_path): + with environment_api(AsyncPlantApi(api), 'plant') as api: + custom_id = random.randint(1000000, 2000000) + date_time = datetime.now() + print(f'Health assessment with {custom_id=} and {date_time=}:') + health_assessment = await api.health_assessment( + image_path, + custom_id=custom_id, + date_time=date_time, + latitude_longitude=(49.20340, 16.57318), + full_disease_list=True, + ) + print(health_assessment) + assert health_assessment.input.datetime == date_time + assert health_assessment.input.similar_images + assert health_assessment.input.latitude == 49.20340 + assert health_assessment.input.longitude == 16.57318 + assert health_assessment.custom_id == custom_id + print() + + print('Feedback for health assessment:') + assert await api.feedback(health_assessment.access_token, comment='correct', rating=5) + + health_assessment = await api.get_health_assessment( + health_assessment.access_token, full_disease_list=True, language='cz', details=['treatment'] + ) + print('Health assessment with treatment details, cz language and full_disease_list:') + print(health_assessment) + print() + assert isinstance(health_assessment, HealthAssessment) + assert 'treatment' in health_assessment.result.disease.suggestions[0].details + assert health_assessment.result.disease.suggestions[0].details['language'] == 'cz' + assert health_assessment.feedback.comment == 'correct' + assert health_assessment.feedback.rating == 5 + assert health_assessment.custom_id == custom_id + assert health_assessment.input.datetime == date_time + + print('Deleting health assessment:') + assert await api.delete_health_assessment(health_assessment.access_token) + with pytest.raises(ValueError): + await api.get_health_assessment(health_assessment.access_token) diff --git a/kindwise/tests/conftest.py b/kindwise/tests/conftest.py index cf6135b..d8bfd95 100644 --- a/kindwise/tests/conftest.py +++ b/kindwise/tests/conftest.py @@ -1,15 +1,71 @@ import os import random +import json from contextlib import contextmanager from datetime import datetime from pathlib import Path from unittest.mock import patch import pytest +from httpx import Response from kindwise import settings from kindwise.models import UsageInfo, MessageType, SearchResult + +class LegacyRequestWrapper: + def __init__(self, request): + self._request = request + self.method = request.method + self.url = str(request.url) + self.headers = request.headers + + def json(self): + return json.loads(self._request.content) + + +class RequestHistoryWrapper: + def __init__(self, calls): + self._calls = calls + + def pop(self, index=-1): + call = self._calls.pop(index) + return LegacyRequestWrapper(call.request) + + def __len__(self): + return len(self._calls) + + def __getitem__(self, item): + return LegacyRequestWrapper(self._calls[item].request) + + +class RequestsMockAdapter: + def __init__(self, respx_mock): + self._respx_mock = respx_mock + + @property + def request_history(self): + return RequestHistoryWrapper(self._respx_mock.calls) + + def get(self, url, json=None, content=None, status_code=200, **kwargs): + if content is not None: + # Handle regex or starts with pattern if url is not a string, but here likely string. + # If url is provided, respx expects it. + return self._respx_mock.get(url).mock(return_value=Response(status_code, content=content)) + return self._respx_mock.get(url).mock(return_value=Response(status_code, json=json)) + + def post(self, url, json=None, status_code=200, **kwargs): + return self._respx_mock.post(url).mock(return_value=Response(status_code, json=json)) + + def delete(self, url, json=None, status_code=200, **kwargs): + return self._respx_mock.delete(url).mock(return_value=Response(status_code, json=json)) + + +@pytest.fixture +def requests_mock(respx_mock): + return RequestsMockAdapter(respx_mock) + + SYSTEMS = ['insect', 'mushroom', 'plant', 'crop'] TEST_DIR = Path(__file__).resolve().parent @@ -110,7 +166,7 @@ def check_conversation(conv): try: search_results: SearchResult = api.search(entity_name, limit=1) except NotImplementedError: - print(f'Skipped KB api check ') + print('Skipped KB api check ') else: print(f'Search results for {entity_name=}, limit=1 {system_name=}:') print(search_results) @@ -124,12 +180,93 @@ def check_conversation(conv): assert 'image' in kb_entity_detail +async def run_async_test_requests_to_server( + api, system_name, image_path, identification_type, model_name='classification' +): + assert system_name.lower() in SYSTEMS + with environment_api(api, system_name) as api: + usage_info = await api.usage_info() + print('Usage info:') + print(usage_info) + print() + + custom_id = random.randint(1000000, 2000000) + date_time = datetime.now() + identification = await api.identify( + image_path, latitude_longitude=(1.0, 2.0), custom_id=custom_id, date_time=date_time + ) + assert isinstance(identification, identification_type) + print(f'Identification created with, {date_time=} and {custom_id=}:') + print(identification) + print() + assert await api.feedback(identification.access_token, comment='correct', rating=5) + + identification = await api.get_identification(identification.access_token, details=['image'], language='cz') + print('Identification with image details, custom id and cz language:') + print(identification) + assert isinstance(identification, identification_type) + assert 'image' in getattr(identification.result, model_name).suggestions[0].details + assert getattr(identification.result, model_name).suggestions[0].details['language'] == 'cz' + assert identification.feedback.comment == 'correct' + assert identification.feedback.rating == 5 + assert identification.custom_id == custom_id + assert identification.input.datetime == date_time + + # conversation + + print('Conversation: `Hi`') + try: + conversation = await api.ask_question(identification.access_token, 'Hi') + except NotImplementedError: + print('Conversation is not implemented') + else: + + def check_conversation(conv): + assert len(conv.messages) == 2 + assert conv.identification == identification.access_token + assert conv.messages[0].content == 'Hi' + assert conv.messages[0].type == MessageType.QUESTION + assert conv.messages[1].content is not None + assert conv.messages[1].type == MessageType.ANSWER + + check_conversation(conversation) + assert await api.conversation_feedback(conversation.identification, {'rating': 5}) + conversation = await api.get_conversation(conversation.identification) + check_conversation(conversation) + assert conversation.feedback == {'rating': 5} + assert await api.delete_conversation(conversation.identification) + with pytest.raises(ValueError): + await api.get_conversation(conversation.identification) + + assert await api.delete_identification(identification.access_token) + + with pytest.raises(ValueError): + await api.get_identification(identification.access_token) + + entity_name = getattr(identification.result, model_name).suggestions[0].name + try: + search_results: SearchResult = await api.search(entity_name, limit=1) + except NotImplementedError: + print('Skipped KB api check ') + else: + print(f'Search results for {entity_name=}, limit=1 {system_name=}:') + print(search_results) + assert len(search_results.entities) == search_results.limit == 1 + assert search_results.entities[0].matched_in.lower() == entity_name.lower() + kb_entity_detail = await api.get_kb_detail(search_results.entities[0].access_token, 'image') + print(f'KB entity detail for {entity_name=}:') + print(kb_entity_detail) + assert kb_entity_detail['name'].lower() == entity_name.lower() + assert kb_entity_detail['language'] == 'en' + assert 'image' in kb_entity_detail + + class RequestMatcher: - def __init__(self, api, api_key, image_path, requests_mock, identification_dict, identification): + def __init__(self, api, api_key, image_path, respx_mock, identification_dict, identification): self.api = api self.api_key = api_key self.image_path = image_path - self.requests_mock = requests_mock + self.respx_mock = respx_mock self.identification_dict = identification_dict self.identification = identification @@ -141,16 +278,16 @@ def _check_post_request( expected_query: str = None, expected_result=None, ): - request_record = self.requests_mock.request_history.pop() - assert request_record.method == 'POST' - assert request_record.headers['Content-Type'] == 'application/json' - assert request_record.headers['Api-Key'] == self.api_key + request_record = self.respx_mock.calls[-1] + assert request_record.request.method == 'POST' + assert request_record.request.headers['Content-Type'] == 'application/json' + assert request_record.request.headers['Api-Key'] == self.api_key if expected_query is not None: if not expected_query.startswith('?') and len(expected_query) > 0: expected_query = '?' + expected_query - assert request_record.url == f'{base_url}{expected_query}' + assert str(request_record.request.url) == f'{base_url}{expected_query}' if expected_payload is not None: - payload = request_record.json() + payload = json.loads(request_record.request.content) for key, value in expected_payload: assert payload[key] == value if expected_result is not None: @@ -166,9 +303,13 @@ def check_identify_request( **kwargs, ): if output is not None: - self.requests_mock.post(self.api.identification_url, json=output) + self.respx_mock.post(url__startswith=self.api.identification_url).mock( + return_value=Response(200, json=output) + ) else: - self.requests_mock.post(self.api.identification_url, json=self.identification_dict) + self.respx_mock.post(url__startswith=self.api.identification_url).mock( + return_value=Response(200, json=self.identification_dict) + ) if raises is None: if 'image' not in kwargs: kwargs['image'] = self.image_path @@ -182,24 +323,22 @@ def check_identify_request( ) def _check_get_request(self, response, base_url, expected_query, expected_result): - assert len(self.requests_mock.request_history) == 1 - request_record = self.requests_mock.request_history.pop() - assert request_record.method == 'GET' - assert request_record.headers['Content-Type'] == 'application/json' - assert request_record.headers['Api-Key'] == self.api_key + request_record = self.respx_mock.calls[-1] + assert request_record.request.method == 'GET' + assert request_record.request.headers['Content-Type'] == 'application/json' + assert request_record.request.headers['Api-Key'] == self.api_key if expected_query is not None: if not expected_query.startswith('?'): expected_query = '?' + expected_query - assert request_record.url == f'{base_url}{expected_query}' + # Normalize URL to remove trailing ? if empty query + actual_url = str(request_record.request.url) + assert actual_url == f'{base_url}{expected_query}' if expected_result is not None: assert response == expected_result def check_get_identification_request(self, expected_query: str = None, expected_result=None, **kwargs): - self.requests_mock.get( - f'{self.api.identification_url}/{self.identification_dict["access_token"]}', - json=self.identification_dict, - ) base_url = f'{self.api.identification_url}/{self.identification_dict["access_token"]}' + self.respx_mock.get(url__startswith=base_url).mock(return_value=Response(200, json=self.identification_dict)) response_identification = self.api.get_identification(self.identification_dict['access_token'], **kwargs) self._check_get_request(response_identification, base_url, expected_query, expected_result) @@ -214,9 +353,13 @@ def check_health_assessment_request( **kwargs, ): if output is not None: - self.requests_mock.post(self.api.health_assessment_url, json=output) + self.respx_mock.post(url__startswith=self.api.health_assessment_url).mock( + return_value=Response(200, json=output) + ) else: - self.requests_mock.post(self.api.health_assessment_url, json=health_assessment_dict) + self.respx_mock.post(url__startswith=self.api.health_assessment_url).mock( + return_value=Response(200, json=health_assessment_dict) + ) if raises is None: if 'image' not in kwargs: kwargs['image'] = self.image_path @@ -233,14 +376,14 @@ def check_get_health_assessment_request( self, health_assessment_dict, expected_query: str = None, expected_result=None, **kwargs ): base_url = f'{self.api.identification_url}/{health_assessment_dict["access_token"]}' - self.requests_mock.get(base_url, json=health_assessment_dict) + self.respx_mock.get(url__startswith=base_url).mock(return_value=Response(200, json=health_assessment_dict)) response_identification = self.api.get_health_assessment(health_assessment_dict["access_token"], **kwargs) self._check_get_request(response_identification, base_url, expected_query, expected_result) @pytest.fixture -def request_matcher(api, api_key, image_path, requests_mock, identification_dict, identification): - return RequestMatcher(api, api_key, image_path, requests_mock, identification_dict, identification) +def request_matcher(api, api_key, image_path, respx_mock, identification_dict, identification): + return RequestMatcher(api, api_key, image_path, respx_mock, identification_dict, identification) @pytest.fixture diff --git a/kindwise/tests/test_core.py b/kindwise/tests/test_core.py index bd5b1c1..75e395d 100644 --- a/kindwise/tests/test_core.py +++ b/kindwise/tests/test_core.py @@ -46,7 +46,7 @@ def usage_info_url(self): @property def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / f'views.insect.json' + return settings.APP_DIR / 'resources' / 'views.insect.json' @property def kb_api_url(self): @@ -643,7 +643,7 @@ def test_delete_conversation(api, api_key, identification, requests_mock): def test_conversation_feedback(api, api_key, identification, requests_mock): created = datetime(2024, 3, 19, 10, 57, 40, 35562, tzinfo=timezone.utc) requests_mock.post( - f'{api.identification_url}/{identification.access_token}/conversation', + f'{api.identification_url}/{identification.access_token}/conversation/feedback', json={ 'messages': [ { @@ -663,3 +663,12 @@ def test_conversation_feedback(api, api_key, identification, requests_mock): 'feedback': {}, }, ) + + response = api.conversation_feedback(identification.access_token, {'rating': 5}) + assert response + request_record = requests_mock.request_history.pop() + assert request_record.method == 'POST' + assert request_record.url == f'{api.identification_url}/{identification.access_token}/conversation/feedback' + assert request_record.headers['Content-Type'] == 'application/json' + assert request_record.headers['Api-Key'] == api_key + assert request_record.json() == {'feedback': {'rating': 5}} diff --git a/kindwise/tests/test_plant.py b/kindwise/tests/test_plant.py index 3961b4d..c76c756 100644 --- a/kindwise/tests/test_plant.py +++ b/kindwise/tests/test_plant.py @@ -836,7 +836,7 @@ def test_delete_health_assessment(api, api_key, health_assessment, requests_mock def test_delete_plant_identification(api, api_key, identification, requests_mock): identification.custom_id = 123 requests_mock.delete(f'{api.identification_url}/{identification.custom_id}', json=True) - response = api.delete_identification(identification.custom_id) + _ = api.delete_identification(identification.custom_id) request_record = requests_mock.request_history.pop() assert request_record.url == f'{api.identification_url}/{identification.custom_id}' diff --git a/poetry.lock b/poetry.lock index 932dea5..bd0c01d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,51 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +[[package]] +name = "aiofiles" +version = "25.1.0" +description = "File support for asyncio." +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"async\"" +files = [ + {file = "aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695"}, + {file = "aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2"}, +] + +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["test"] +markers = "python_version == \"3.10\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + [[package]] name = "black" version = "23.11.0" @@ -61,11 +107,12 @@ version = "2023.11.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, ] +markers = {main = "extra == \"router\""} [[package]] name = "cfgv" @@ -178,6 +225,7 @@ files = [ {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] +markers = {main = "extra == \"router\""} [[package]] name = "click" @@ -225,7 +273,7 @@ version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" -groups = ["test"] +groups = ["dev", "test"] markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, @@ -294,6 +342,18 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] tqdm = ["tqdm"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + [[package]] name = "hf-xet" version = "1.1.10" @@ -316,6 +376,53 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "huggingface-hub" version = "0.35.3" @@ -377,11 +484,12 @@ version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" -groups = ["main", "test"] +groups = ["main", "dev", "test"] files = [ {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, ] +markers = {main = "extra == \"router\""} [[package]] name = "iniconfig" @@ -871,14 +979,14 @@ files = [ [[package]] name = "packaging" -version = "23.2" +version = "25.0" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" groups = ["main", "dev", "test"] files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] markers = {main = "extra == \"router\""} @@ -980,19 +1088,19 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co [[package]] name = "pluggy" -version = "1.3.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["test"] files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "pre-commit" @@ -1013,28 +1121,65 @@ nodeenv = ">=0.11.1" pyyaml = ">=5.1" virtualenv = ">=20.10.0" +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["test"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pytest" -version = "7.4.3" +version = "9.0.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["test"] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.10" groups = ["test"] files = [ - {file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, - {file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, + {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"}, + {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"}, ] [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\""} +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} +pytest = ">=8.2,<10" +typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "python-dotenv" @@ -1115,19 +1260,20 @@ markers = {main = "extra == \"router\""} [[package]] name = "requests" -version = "2.31.0" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] +markers = {main = "extra == \"router\""} [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -1155,6 +1301,21 @@ six = "*" fixture = ["fixtures"] test = ["fixtures", "mock ; python_version < \"3.3\"", "purl", "pytest", "requests-futures", "sphinx", "testtools"] +[[package]] +name = "respx" +version = "0.22.0" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0"}, + {file = "respx-0.22.0.tar.gz", hash = "sha256:3c8924caa2a50bd71aefc07aa812f2466ff489f1848c96e954a5362d17095d91"}, +] + +[package.dependencies] +httpx = ">=0.25.0" + [[package]] name = "setuptools" version = "69.0.2" @@ -1204,6 +1365,18 @@ mpmath = ">=1.1.0,<1.4" [package.extras] dev = ["hypothesis (>=6.70.0)", "pytest (>=7.1.0)"] +[[package]] +name = "tokenize-rt" +version = "6.2.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tokenize_rt-6.2.0-py2.py3-none-any.whl", hash = "sha256:a152bf4f249c847a66497a4a95f63376ed68ac6abf092a2f7cfb29d044ecff44"}, + {file = "tokenize_rt-6.2.0.tar.gz", hash = "sha256:8439c042b330c553fdbe1758e4a05c0ed460dbbbb24a606f11f0dee75da4cad6"}, +] + [[package]] name = "tomli" version = "2.0.1" @@ -1378,12 +1551,28 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main", "dev"] +groups = ["main", "dev", "test"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "extra == \"router\"", dev = "python_version == \"3.10\""} +markers = {main = "extra == \"router\"", dev = "python_version < \"3.13\"", test = "python_version < \"3.13\""} + +[[package]] +name = "unasync" +version = "0.6.0" +description = "The async transformation code." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "unasync-0.6.0-py3-none-any.whl", hash = "sha256:9cf7aaaea9737e417d8949bf9be55dc25fdb4ef1f4edc21b58f76ff0d2b9d73f"}, + {file = "unasync-0.6.0.tar.gz", hash = "sha256:a9d01ace3e1068b20550ab15b7f9723b15b8bcde728bc1770bcb578374c7ee58"}, +] + +[package.dependencies] +setuptools = "*" +tokenize-rt = "*" [[package]] name = "urllib3" @@ -1396,6 +1585,7 @@ files = [ {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, ] +markers = {main = "extra == \"router\""} [package.extras] brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] @@ -1424,9 +1614,10 @@ docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx- 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) ; platform_python_implementation == \"PyPy\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] [extras] +async = ["aiofiles"] router = ["huggingface-hub", "numpy", "torch", "torchvision"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "b5397caec9f3ecee0781befb462c10a52c10dd20c0ef3b3b1f276072c9359ca8" +content-hash = "50a83b6501c296e3ac84bf7e8db53293b98724d79b02d8d6769795b09ee06411" diff --git a/pyproject.toml b/pyproject.toml index 85a0ec9..ab1ebf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ authors = ["Simon Plhak "] license = "MIT" readme = "README.md" repository = "https://github.com/flowerchecker/kindwise" +build = "generate/generate_sync.py" packages = [ { include = "kindwise"}, ] @@ -13,12 +14,12 @@ packages = [ [tool.poetry.dependencies] python = "^3.10" python-dotenv = "*" -requests = "*" pillow = "*" torch = { version = "2.7.0", optional = true } huggingface-hub = { version = "^0.35.3", optional = true } numpy = { version = "2.2.6", optional = true } torchvision = { version = "0.22.0", optional = true } +aiofiles = { version = "^25.1.0", optional = true } [tool.poetry.extras] router = [ @@ -27,19 +28,25 @@ router = [ "numpy", "torchvision", ] +async = [ + "aiofiles" +] [tool.poetry.group.dev.dependencies] pre-commit = "^3.5.0" black = "^23.11.0" bump2version = "^1.0.1" +unasync = "^0.6.0" +respx = "^0.22.0" [tool.poetry.group.test.dependencies] -pytest = "^7.4.3" +pytest = "^9.0.2" requests-mock = "^1.11.0" +pytest-asyncio = "^1.3.0" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core", "unasync"] build-backend = "poetry.core.masonry.api" [tool.black]