From 0eacfb5b9f6846ebc26f085432776f52cb3f5dc1 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 09:26:13 +0100 Subject: [PATCH 01/24] feat: upgrade packaging dep to be compatabile with poetry2.2.1 --- poetry.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 932dea5..7ebcfde 100644 --- a/poetry.lock +++ b/poetry.lock @@ -871,14 +871,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\""} From a80ac1d7962e92093c6a21bc96f0b3aa8301da7a Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 09:27:28 +0100 Subject: [PATCH 02/24] refactor: fix formatting --- kindwise/core.py | 16 ++++++---------- kindwise/crop_health.py | 2 +- kindwise/insect.py | 2 +- kindwise/mushroom.py | 2 +- kindwise/plant.py | 20 +++++++++++--------- kindwise/tests/conftest.py | 2 +- kindwise/tests/test_core.py | 2 +- 7 files changed, 22 insertions(+), 24 deletions(-) diff --git a/kindwise/core.py b/kindwise/core.py index e0db0c9..1b3d999 100644 --- a/kindwise/core.py +++ b/kindwise/core.py @@ -25,18 +25,15 @@ def __init__(self, api_key: str): @property @abc.abstractmethod - def identification_url(self): - ... + def identification_url(self): ... @property @abc.abstractmethod - def usage_info_url(self): - ... + def usage_info_url(self): ... @property @abc.abstractmethod - def kb_api_url(self): - ... + def kb_api_url(self): ... def feedback_url(self, token: str): return f'{self.identification_url}/{token}/feedback' @@ -218,7 +215,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}' @@ -276,8 +273,7 @@ def feedback( @property @abc.abstractmethod - def views_path(self) -> Path: - ... + def views_path(self) -> Path: ... def available_details(self) -> list[dict[str, any]]: with open(self.views_path) as f: @@ -295,7 +291,7 @@ 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): diff --git a/kindwise/crop_health.py b/kindwise/crop_health.py index 81efb9c..680f5c2 100644 --- a/kindwise/crop_health.py +++ b/kindwise/crop_health.py @@ -58,7 +58,7 @@ 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): diff --git a/kindwise/insect.py b/kindwise/insect.py index a0b7ea2..4b941dd 100644 --- a/kindwise/insect.py +++ b/kindwise/insect.py @@ -140,7 +140,7 @@ 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( self, diff --git a/kindwise/mushroom.py b/kindwise/mushroom.py index a834bea..9fa3be4 100644 --- a/kindwise/mushroom.py +++ b/kindwise/mushroom.py @@ -80,7 +80,7 @@ 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): diff --git a/kindwise/plant.py b/kindwise/plant.py index 928df16..56bf447 100644 --- a/kindwise/plant.py +++ b/kindwise/plant.py @@ -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']] + ), ) @@ -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 @@ -393,9 +395,9 @@ def delete_health_assessment( @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/conftest.py b/kindwise/tests/conftest.py index cf6135b..4164b1e 100644 --- a/kindwise/tests/conftest.py +++ b/kindwise/tests/conftest.py @@ -110,7 +110,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) diff --git a/kindwise/tests/test_core.py b/kindwise/tests/test_core.py index bd5b1c1..fbf033b 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): From 6944c8b087bdbb7e104c6eb4e13b981ee2a4873a Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 09:27:43 +0100 Subject: [PATCH 03/24] fix: remove doubled method --- kindwise/mushroom.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/kindwise/mushroom.py b/kindwise/mushroom.py index 9fa3be4..094a612 100644 --- a/kindwise/mushroom.py +++ b/kindwise/mushroom.py @@ -142,10 +142,6 @@ 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( self, identification: Identification | str | int, From d82d912fffd7fa32d1873b6668ad8f33bf975fd7 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 09:30:03 +0100 Subject: [PATCH 04/24] docs: update internal readme --- README_internal.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README_internal.md b/README_internal.md index 3b70cea..18d5e59 100644 --- a/README_internal.md +++ b/README_internal.md @@ -3,7 +3,8 @@ ```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 ``` From a9d3bc3463f067370afe37c34a11d3910ed1ef18 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 12:00:34 +0100 Subject: [PATCH 05/24] fix: finish test for conversation feedback --- kindwise/tests/test_core.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/kindwise/tests/test_core.py b/kindwise/tests/test_core.py index fbf033b..75e395d 100644 --- a/kindwise/tests/test_core.py +++ b/kindwise/tests/test_core.py @@ -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}} From 42256d79a0e804d6e3abb3395770f4abe51ec1d1 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 12:03:20 +0100 Subject: [PATCH 06/24] feat: replace requests with httpx --- kindwise/core.py | 20 ++++--- kindwise/plant.py | 4 +- kindwise/tests/conftest.py | 112 ++++++++++++++++++++++++++-------- poetry.lock | 119 +++++++++++++++++++++++++++++++++---- pyproject.toml | 2 +- 5 files changed, 210 insertions(+), 47 deletions(-) diff --git a/kindwise/core.py b/kindwise/core.py index 1b3d999..f6a99f8 100644 --- a/kindwise/core.py +++ b/kindwise/core.py @@ -7,7 +7,7 @@ from pathlib import Path, PurePath from typing import Any, BinaryIO, Generic, TypeVar -import requests +import httpx from PIL import Image from kindwise.models import Conversation, Identification, SearchResult, UsageInfo @@ -49,18 +49,20 @@ def _make_api_call(self, url, method: str, data: dict | None = None, timeout: fl '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 + with httpx.Client() as client: + response = 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: if not isinstance(image, str) or not image.startswith(('http://', 'https://')): return None - response = requests.get(image) - if not response.ok: + with httpx.Client() as client: + response = client.get(image) + if not response.is_success: return None return io.BytesIO(response.content) @@ -298,7 +300,7 @@ def search( 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: + 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()) @@ -316,7 +318,7 @@ def get_kb_detail( 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: + if not response.is_success: raise ValueError(f'Error while getting knowledge base detail: {response.status_code=} {response.text=}') return response.json() diff --git a/kindwise/plant.py b/kindwise/plant.py index 56bf447..3579b00 100644 --- a/kindwise/plant.py +++ b/kindwise/plant.py @@ -361,7 +361,7 @@ def health_assessment( extra_post_params=extra_post_params, ) response = self._make_api_call(url, 'POST', payload, timeout=timeout) - if not response.ok: + 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) @@ -381,7 +381,7 @@ def get_health_assessment( ) url = f'{self.identification_url}/{token}{query}' response = self._make_api_call(url, 'GET', timeout=timeout) - if not response.ok: + 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) diff --git a/kindwise/tests/conftest.py b/kindwise/tests/conftest.py index 4164b1e..49b0fc7 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 @@ -125,11 +181,11 @@ def check_conversation(conv): 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 +197,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 +222,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 +242,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 +272,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 +295,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/poetry.lock b/poetry.lock index 7ebcfde..f591b08 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,25 @@ # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +[[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 = "black" version = "23.11.0" @@ -61,11 +81,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 +199,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 +247,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 +316,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 +350,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 +458,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" @@ -1115,19 +1197,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 +1238,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" @@ -1383,7 +1481,7 @@ 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\""} [[package]] name = "urllib3" @@ -1396,6 +1494,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\""] @@ -1429,4 +1528,4 @@ router = ["huggingface-hub", "numpy", "torch", "torchvision"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "b5397caec9f3ecee0781befb462c10a52c10dd20c0ef3b3b1f276072c9359ca8" +content-hash = "2770a1ddbfed87bb03331706cd63f1e1020dc64339d4c03c1624b1ca6994bf70" diff --git a/pyproject.toml b/pyproject.toml index 85a0ec9..4a1b469 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,6 @@ 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 } @@ -32,6 +31,7 @@ router = [ pre-commit = "^3.5.0" black = "^23.11.0" bump2version = "^1.0.1" +respx = "^0.22.0" [tool.poetry.group.test.dependencies] From 9eda261cb980f44bd7e68f591188776947d84625 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 18:55:09 +0100 Subject: [PATCH 07/24] feat: move logic to async code and generate sync code automatically --- Makefile | 3 + README.md | 35 ++ README_internal.md | 9 + generate/generate_sync.py | 50 +++ kindwise/async_api/__init__.py | 0 kindwise/async_api/core.py | 364 +++++++++++++++++++ kindwise/async_api/crop_health.py | 78 +++++ kindwise/async_api/insect.py | 156 +++++++++ kindwise/async_api/mushroom.py | 156 +++++++++ kindwise/async_api/plant.py | 403 ++++++++++++++++++++++ kindwise/core.py | 12 +- kindwise/tests/async_api/test_core.py | 202 +++++++++++ kindwise/tests/async_api/test_crop.py | 44 +++ kindwise/tests/async_api/test_insect.py | 36 ++ kindwise/tests/async_api/test_mushroom.py | 35 ++ kindwise/tests/conftest.py | 81 +++++ manage.py | 47 +++ poetry.lock | 130 ++++++- pyproject.toml | 8 +- 19 files changed, 1825 insertions(+), 24 deletions(-) create mode 100644 generate/generate_sync.py create mode 100644 kindwise/async_api/__init__.py create mode 100644 kindwise/async_api/core.py create mode 100644 kindwise/async_api/crop_health.py create mode 100644 kindwise/async_api/insect.py create mode 100644 kindwise/async_api/mushroom.py create mode 100644 kindwise/async_api/plant.py create mode 100644 kindwise/tests/async_api/test_core.py create mode 100644 kindwise/tests/async_api/test_crop.py create mode 100644 kindwise/tests/async_api/test_insect.py create mode 100644 kindwise/tests/async_api/test_mushroom.py create mode 100644 manage.py 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..8f78251 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,32 @@ 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 +from kindwise import AsyncPlantApi, PlantIdentification, UsageInfo + +# 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 +``` \ No newline at end of file diff --git a/README_internal.md b/README_internal.md index 18d5e59..521b82e 100644 --- a/README_internal.md +++ b/README_internal.md @@ -9,6 +9,15 @@ pre-commit install pre-commit install --hook-type commit-msg ``` +## Developmnet + +Do not directly modify files under `kindwise/sync` directory. The ground truth code is located in `kindwise/async_api` directory. Use the following command to generate sync code from async code: + +```bash +poetry run python generate/generate_sync_code.py +# or +make generate-sync +``` ## Tests Specify server used for testing via environmental variable `ENVIRONMENT`. diff --git a/generate/generate_sync.py b/generate/generate_sync.py new file mode 100644 index 0000000..04057dc --- /dev/null +++ b/generate/generate_sync.py @@ -0,0 +1,50 @@ +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) + for path in filepaths: + post_process(path.parent.parent / path.name) + + +if __name__ == "__main__": + main() 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/async_api/core.py b/kindwise/async_api/core.py new file mode 100644 index 0000000..07b2d75 --- /dev/null +++ b/kindwise/async_api/core.py @@ -0,0 +1,364 @@ +import abc +import base64 +import enum +import io +import json +from datetime import datetime +from pathlib import Path, PurePath +from typing import Any, BinaryIO, Generic, TypeVar + +import anyio +import httpx +from PIL import Image + +from kindwise.models import Conversation, Identification, SearchResult, UsageInfo + +IdentificationType = TypeVar('IdentificationType') +KBType = TypeVar('KBType') + + +class AsyncKindwiseApi(abc.ABC, Generic[IdentificationType, KBType]): + identification_class = Identification + default_kb_type = None + + def __init__(self, api_key: str): + self.api_key = api_key + + @property + @abc.abstractmethod + def identification_url(self): ... + + @property + @abc.abstractmethod + def usage_info_url(self): ... + + @property + @abc.abstractmethod + def kb_api_url(self): ... + + def feedback_url(self, token: str): + return f'{self.identification_url}/{token}/feedback' + + def conversation_url(self, token: str): + return f'{self.identification_url}/{token}/conversation' + + def conversation_feedback_url(self, token: str): + return f'{self.identification_url}/{token}/conversation/feedback' + + 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, + } + 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 + 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 + 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 := 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 + 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)') + image.seek(0) + return io.BytesIO(image.read()) + if isinstance(image, Image.Image): + buffer = io.BytesIO() + image.save(buffer, format='JPEG') + return buffer + # str | bytes: + + def is_base64(): + try: + byte_image = image if isinstance(image, bytes) else image.encode('ascii') + return base64.b64encode(base64.b64decode(byte_image)) == byte_image + except Exception: + return False + + if is_base64(): + return io.BytesIO(base64.b64decode(image)) + sb_bytes = bytes(image, 'ascii') if isinstance(image, str) else image + return io.BytesIO(sb_bytes) + + @staticmethod + 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) + if max(img.size) <= max_image_size: + resized_image = img + else: + aspect_ratio = img.width / img.height + new_width = max_image_size if aspect_ratio >= 1 else int(max_image_size * aspect_ratio) + new_height = int(new_width / aspect_ratio) + resized_image = img.resize((new_width, new_height)) + output_buffer = io.BytesIO() + if resized_image.mode != 'RGB': + resized_image = resized_image.convert('RGB') + resized_image.save(output_buffer, format='JPEG') + resized_image_bytes = output_buffer.getvalue() + output_buffer.close() + return resized_image_bytes + + img = buffer.getvalue() if max_image_size is None else resize_image(buffer) + buffer.close() + return base64.b64encode(img).decode('ascii') + + async def _build_payload( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + extra_post_params: dict[str, Any] = None, + **kwargs, + ): + if not isinstance(image, list): + image = [image] + + payload = { + 'images': [await self._encode_image(img, max_image_size) for img in image], + 'similar_images': similar_images, + } + if latitude_longitude is not None: + payload['latitude'], payload['longitude'] = latitude_longitude + if custom_id is not None: + payload['custom_id'] = custom_id + if date_time is not None: + if isinstance(date_time, datetime): + payload['datetime'] = date_time.isoformat() + elif isinstance(date_time, str): + # check if ISO format is valid + payload['datetime'] = datetime.fromisoformat(date_time).isoformat() + elif isinstance(date_time, float): + payload['datetime'] = datetime.fromtimestamp(date_time).isoformat() + else: + raise ValueError(f'Invalid date_time format {date_time=} {type(date_time)=}') + if extra_post_params is not None: + if 'suggestion_filter' in extra_post_params and isinstance(extra_post_params['suggestion_filter'], str): + extra_post_params['suggestion_filter'] = {'classification': extra_post_params['suggestion_filter']} + payload.update(extra_post_params) + return payload + + async def identify( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + as_dict: bool = False, + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + extra_get_params: str | dict[str | Any] = None, + extra_post_params: dict[str, Any] = None, + timeout: float = 60.0, + **kwargs, + ) -> IdentificationType | dict: + payload = await self._build_payload( + image, + similar_images=similar_images, + latitude_longitude=latitude_longitude, + custom_id=custom_id, + date_time=date_time, + max_image_size=max_image_size, + extra_post_params=extra_post_params, + **kwargs, + ) + query = self._build_query( + details=details, language=language, asynchronous=asynchronous, extra_get_params=extra_get_params, **kwargs + ) + url = f'{self.identification_url}{query}' + 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) + + def _build_query( + self, + details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + extra_get_params: str | dict[str, str] = None, + limit: int = None, + query: str = None, + **kwargs, + ): + if isinstance(details, str): + details = [details] + details_query = '' if details is None else f'details={",".join(details)}&' + if isinstance(language, str): + language = [language] + language_query = '' if language is None else f'language={",".join(language)}&' + if extra_get_params is None: + extra_get_params = '' + else: + if isinstance(extra_get_params, dict): + 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 = '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}' + if query.endswith('&'): + query = query[:-1] + return '' if query == '?' else query + + async def get_identification( + self, + token: str | int, + details: str | list[str] = None, + language: str | list[str] = None, + extra_get_params: str | dict[str, str] = None, + as_dict: bool = False, + timeout: float = 60.0, + ) -> IdentificationType | dict: + query = self._build_query(details=details, language=language, extra_get_params=extra_get_params) + url = f'{self.identification_url}/{token}{query}' + 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) + + 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}' + await self._make_api_call(url, 'DELETE', timeout=timeout) + return True + + 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) + + async def feedback( + self, + identification: IdentificationType | str | int, + comment: str | None = None, + rating: int | None = None, + timeout: float = 60.0, + ) -> bool: + token = identification.access_token if isinstance(identification, Identification) else identification + if comment is None and rating is None: + raise ValueError('Either comment or rating must be provided') + data = {} + if comment is not None: + data['comment'] = comment + if rating is not None: + data['rating'] = rating + await self._make_api_call(self.feedback_url(token), 'POST', data, timeout=timeout) + return True + + @property + @abc.abstractmethod + def views_path(self) -> Path: ... + + def available_details(self) -> list[dict[str, any]]: + with open(self.views_path) as f: + return json.load(f) + + async def search( + self, + query: str, + limit: int = None, + language: str = None, + kb_type: KBType | str = None, + as_dict=False, + timeout: float = 60.0, + ) -> SearchResult | dict: + if not query: + raise ValueError('Query parameter q must be provided') + if isinstance(limit, int) and limit < 1: + 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 = 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()) + + async def get_kb_detail( + self, + access_token: str, + details: str | list[str], + language: str = None, + kb_type: KBType | str = None, + timeout: float = 60.0, + ) -> dict: + 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}/{access_token}{self._build_query(language=language, details=details)}' + 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() + + async def ask_question( + self, + identification: IdentificationType | str | int, + question: str, + model: str = None, + app_name: str = None, + prompt: str = None, + temperature: float = None, + as_dict: bool = False, + timeout: float = 60.0, + ) -> Conversation: + token = identification.access_token if isinstance(identification, Identification) else identification + data = {'question': question} + for key, value in [('model', model), ('app_name', app_name), ('prompt', prompt), ('temperature', temperature)]: + if value is not None: + data[key] = value + 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) + + 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 = await self._make_api_call(self.conversation_url(token), 'GET', timeout=timeout) + return Conversation.from_dict(response.json()) + + async def delete_conversation(self, identification: IdentificationType | str | int, timeout: float = 60.0) -> bool: + token = identification.access_token if isinstance(identification, Identification) else identification + await self._make_api_call(self.conversation_url(token), 'DELETE', timeout=timeout) + return True + + 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 + await self._make_api_call( + self.conversation_feedback_url(token), 'POST', {'feedback': feedback}, timeout=timeout + ) + return True diff --git a/kindwise/async_api/crop_health.py b/kindwise/async_api/crop_health.py new file mode 100644 index 0000000..e61b2a9 --- /dev/null +++ b/kindwise/async_api/crop_health.py @@ -0,0 +1,78 @@ +import enum +from dataclasses import dataclass +from pathlib import Path + +from kindwise import settings +from kindwise.async_api.core import AsyncKindwiseApi +from kindwise.models import Identification, ResultEvaluation, ClassificationWithScientificName, Conversation + + +@dataclass +class CropResult: + is_plant: ResultEvaluation + crop: ClassificationWithScientificName + disease: ClassificationWithScientificName | None + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_plant=ResultEvaluation.from_dict(data['is_plant']), + crop=ClassificationWithScientificName.from_dict(data['crop']), + disease=ClassificationWithScientificName.from_dict(data['disease']) if 'disease' in data else None, + ) + + +@dataclass +class CropIdentification(Identification): + result: CropResult | None + + @classmethod + def get_result_class(cls): + return CropResult + + +class CropHealthKBType(str, enum.Enum): + CROP = 'crop' + + +class AsyncCropHealthApi(AsyncKindwiseApi[CropIdentification, CropHealthKBType]): + host = 'https://crop.kindwise.com' + default_kb_type = CropHealthKBType.CROP + identification_class = CropIdentification + + def __init__(self, api_key: str = None): + api_key = settings.CROP_HEALTH_API_KEY if api_key is None else api_key + if api_key is None: + raise ValueError( + 'API key is required, set it in init method of class or in .env file under "CROP_HEALTH_API_KEY" key' + ) + super().__init__(api_key) + + @property + def identification_url(self): + return f'{self.host}/api/v1/identification' + + @property + def usage_info_url(self): + return f'{self.host}/api/v1/usage_info' + + @property + def views_path(self) -> Path: + 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') + + async def ask_question( + self, + identification: CropIdentification | str | int, + question: str, + model: str = None, + app_name: str = None, + prompt: str = None, + temperature: float = None, + as_dict: bool = False, + timeout=60.0, + ) -> Conversation: + raise NotImplementedError('Asking questions is currently not supported by crop.health.') diff --git a/kindwise/async_api/insect.py b/kindwise/async_api/insect.py new file mode 100644 index 0000000..55fda1f --- /dev/null +++ b/kindwise/async_api/insect.py @@ -0,0 +1,156 @@ +import enum +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePath +from typing import BinaryIO + +from PIL import Image + +from kindwise import settings +from kindwise.async_api.core import AsyncKindwiseApi +from kindwise.models import ( + Identification, + Conversation, + ResultEvaluation, + Classification, + Input, + IdentificationStatus, + Feedback, +) + + +class InsectKBType(str, enum.Enum): + INSECT = 'insect' + + +@dataclass +class InsectResult: + is_insect: ResultEvaluation + classification: Classification + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_insect=ResultEvaluation.from_dict(data['is_insect']), + classification=Classification.from_dict(data['classification']), + ) + + +@dataclass +class InsectIdentification(Identification): + result: InsectResult | None + input: Input + + @classmethod + def from_dict(cls, data: dict) -> 'InsectIdentification': + return cls( + access_token=data['access_token'], + model_version=data['model_version'], + custom_id=data['custom_id'], + input=Input.from_dict(data['input']), + result=None if 'result' not in data else InsectResult.from_dict(data['result']), + status=IdentificationStatus(data['status']), + sla_compliant_client=data['sla_compliant_client'], + sla_compliant_system=data['sla_compliant_system'], + created=datetime.fromtimestamp(data['created']), + completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), + feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, + ) + + +class AsyncInsectApi(AsyncKindwiseApi[InsectIdentification, InsectKBType]): + host = 'https://insect.kindwise.com' + default_kb_type = InsectKBType.INSECT + + def __init__(self, api_key: str = None): + api_key = settings.INSECT_API_KEY if api_key is None else api_key + if api_key is None: + raise ValueError( + 'API key is required, set it in init method of class or in .env file under "INSECT_API_KEY" key' + ) + super().__init__(api_key) + + @property + def identification_url(self): + return f'{self.host}/api/v1/identification' + + @property + def usage_info_url(self): + return f'{self.host}/api/v1/usage_info' + + @property + def kb_api_url(self): + return f'{self.host}/api/v1/kb' + + async def identify( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, + timeout=60.0, + ) -> InsectIdentification | dict: + identification = await super().identify( + image=image, + details=details, + language=language, + asynchronous=asynchronous, + similar_images=similar_images, + latitude_longitude=latitude_longitude, + custom_id=custom_id, + date_time=date_time, + max_image_size=max_image_size, + as_dict=True, + extra_get_params=extra_get_params, + extra_post_params=extra_post_params, + timeout=timeout, + ) + if as_dict: + return identification + return InsectIdentification.from_dict(identification) + + async def get_identification( + self, + token: str | int, + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + timeout=60.0, + ) -> InsectIdentification | dict: + identification = await super().get_identification( + token=token, + details=details, + language=language, + as_dict=True, + extra_get_params=extra_get_params, + timeout=timeout, + ) + return identification if as_dict else InsectIdentification.from_dict(identification) + + @property + def views_path(self) -> Path: + return settings.APP_DIR / 'resources' / 'views.insect.json' + + async def ask_question( + self, + identification: Identification | str | int, + question: str, + model: str = None, + app_name: str = None, + prompt: str = None, + temperature: float = None, + as_dict: bool = False, + timeout=60.0, + ) -> Conversation: + raise NotImplementedError('Asking questions is currently not supported by insect.id') diff --git a/kindwise/async_api/mushroom.py b/kindwise/async_api/mushroom.py new file mode 100644 index 0000000..ba1ee24 --- /dev/null +++ b/kindwise/async_api/mushroom.py @@ -0,0 +1,156 @@ +import enum +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePath +from typing import BinaryIO + +from PIL import Image + +from kindwise import settings +from kindwise.async_api.core import AsyncKindwiseApi +from kindwise.models import ( + Identification, + Conversation, + ResultEvaluation, + Classification, + Input, + IdentificationStatus, + Feedback, +) + + +class MushroomKBType(str, enum.Enum): + MUSHROOM = 'mushroom' + + +@dataclass +class MushroomResult: + is_mushroom: ResultEvaluation + classification: Classification + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_mushroom=ResultEvaluation.from_dict(data['is_mushroom']), + classification=Classification.from_dict(data['classification']), + ) + + +@dataclass +class MushroomIdentification(Identification): + result: MushroomResult | None + input: Input + + @classmethod + def from_dict(cls, data: dict) -> 'MushroomIdentification': + return cls( + access_token=data['access_token'], + model_version=data['model_version'], + custom_id=data['custom_id'], + input=Input.from_dict(data['input']), + result=None if 'result' not in data else MushroomResult.from_dict(data['result']), + status=IdentificationStatus(data['status']), + sla_compliant_client=data['sla_compliant_client'], + sla_compliant_system=data['sla_compliant_system'], + created=datetime.fromtimestamp(data['created']), + completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), + feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, + ) + + +class AsyncMushroomApi(AsyncKindwiseApi[Identification, MushroomKBType]): + host = 'https://mushroom.kindwise.com' + default_kb_type = MushroomKBType.MUSHROOM + + def __init__(self, api_key: str = None): + api_key = settings.MUSHROOM_API_KEY if api_key is None else api_key + if api_key is None: + raise ValueError( + 'API key is required, set it in init method of class or in .env file under "MUSHROOM_API_KEY" key' + ) + super().__init__(api_key) + + @property + def identification_url(self): + return f'{self.host}/api/v1/identification' + + @property + def usage_info_url(self): + return f'{self.host}/api/v1/usage_info' + + @property + def views_path(self) -> Path: + return settings.APP_DIR / 'resources' / 'views.mushroom.json' + + @property + def kb_api_url(self): + return f'{self.host}/api/v1/kb' + + async def identify( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, + timeout=60.0, + ) -> MushroomIdentification | dict: + identification = await super().identify( + image=image, + details=details, + language=language, + asynchronous=asynchronous, + similar_images=similar_images, + latitude_longitude=latitude_longitude, + custom_id=custom_id, + date_time=date_time, + max_image_size=max_image_size, + as_dict=True, + extra_get_params=extra_get_params, + extra_post_params=extra_post_params, + timeout=timeout, + ) + if as_dict: + return identification + return MushroomIdentification.from_dict(identification) + + async def get_identification( + self, + token: str | int, + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + timeout=60.0, + ) -> MushroomIdentification | dict: + identification = await super().get_identification( + token=token, + details=details, + language=language, + as_dict=True, + extra_get_params=extra_get_params, + timeout=timeout, + ) + return identification if as_dict else MushroomIdentification.from_dict(identification) + + async def ask_question( + self, + identification: Identification | str | int, + question: str, + model: str = None, + app_name: str = None, + prompt: str = None, + temperature: float = None, + as_dict: bool = False, + timeout=60.0, + ) -> Conversation: + raise NotImplementedError('Asking questions is currently not supported by mushroom.id') diff --git a/kindwise/async_api/plant.py b/kindwise/async_api/plant.py new file mode 100644 index 0000000..526908b --- /dev/null +++ b/kindwise/async_api/plant.py @@ -0,0 +1,403 @@ +import enum +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePath +from typing import BinaryIO + +from PIL import Image + +from kindwise import settings +from kindwise.async_api.core import AsyncKindwiseApi +from kindwise.models import ( + ClassificationLevel, + Identification, + Input, + IdentificationStatus, + Feedback, + ResultEvaluation, + Classification, + Suggestion, +) + + +class PlantKBType(str, enum.Enum): + PLANTS = 'plants' + DISEASES = 'diseases' + + +@dataclass +class PlantResult: + is_plant: ResultEvaluation + is_healthy: ResultEvaluation | None + classification: Classification + disease: Classification | None + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_plant=ResultEvaluation.from_dict(data['is_plant']), + is_healthy=ResultEvaluation.from_dict(data['is_healthy']) if 'is_healthy' in data else None, + classification=Classification.from_dict(data['classification']), + disease=Classification.from_dict(data['disease']) if 'disease' in data else None, + ) + + +@dataclass +class PlantInput(Input): + classification_level: ClassificationLevel | None + classification_raw: bool + + @classmethod + def from_dict(cls, data: dict) -> 'PlantInput': + return cls( + images=data['images'], + datetime=datetime.fromisoformat(data['datetime']), + 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_raw=data.get('classification_raw', False), + ) + + +@dataclass +class PlantIdentification(Identification): + result: PlantResult | None + input: PlantInput + + @classmethod + def from_dict(cls, data: dict) -> 'PlantIdentification': + return cls( + access_token=data['access_token'], + model_version=data['model_version'], + custom_id=data['custom_id'], + input=PlantInput.from_dict(data['input']), + result=None if 'result' not in data else PlantResult.from_dict(data['result']), + status=IdentificationStatus(data['status']), + sla_compliant_client=data['sla_compliant_client'], + sla_compliant_system=data['sla_compliant_system'], + created=datetime.fromtimestamp(data['created']), + completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), + feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, + ) + + +@dataclass +class TaxaSpecificSuggestion: + genus: list[Suggestion] + species: list[Suggestion] + infraspecies: list[Suggestion] | None + + @classmethod + 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']] + ), + ) + + +@dataclass +class RawClassification: + suggestions: TaxaSpecificSuggestion + + @classmethod + def from_dict(cls, data: dict): + return cls( + suggestions=TaxaSpecificSuggestion.from_dict(data['suggestions']), + ) + + +@dataclass +class RawPlantResult: + is_plant: ResultEvaluation + is_healthy: ResultEvaluation | None + classification: RawClassification + disease: Classification | None + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_plant=ResultEvaluation.from_dict(data['is_plant']), + is_healthy=ResultEvaluation.from_dict(data['is_healthy']) if 'is_healthy' in data else None, + classification=RawClassification.from_dict(data['classification']), + disease=Classification.from_dict(data['disease']) if 'disease' in data else None, + ) + + +@dataclass +class RawPlantIdentification(Identification): + result: RawPlantResult | None + + @classmethod + def from_dict(cls, data: dict) -> 'RawPlantIdentification': + return cls( + access_token=data['access_token'], + model_version=data['model_version'], + custom_id=data['custom_id'], + input=Input.from_dict(data['input']), + result=None if 'result' not in data else RawPlantResult.from_dict(data['result']), + status=IdentificationStatus(data['status']), + sla_compliant_client=data['sla_compliant_client'], + sla_compliant_system=data['sla_compliant_system'], + created=datetime.fromtimestamp(data['created']), + completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), + feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, + ) + + +@dataclass +class HealthAssessmentResult: + is_plant: ResultEvaluation + is_healthy: ResultEvaluation + disease: Classification + + @classmethod + def from_dict(cls, data: dict): + return cls( + is_plant=ResultEvaluation.from_dict(data['is_plant']), + is_healthy=ResultEvaluation.from_dict(data['is_healthy']), + disease=Classification.from_dict(data['disease']), + ) + + +@dataclass +class HealthAssessment(Identification): + result: HealthAssessmentResult | None + + @classmethod + def from_dict(cls, data: dict) -> 'HealthAssessment': + return cls( + access_token=data['access_token'], + model_version=data['model_version'], + custom_id=data['custom_id'], + input=Input.from_dict(data['input']), + result=None if 'result' not in data else HealthAssessmentResult.from_dict(data['result']), + status=data['status'], + sla_compliant_client=data['sla_compliant_client'], + sla_compliant_system=data['sla_compliant_system'], + created=datetime.fromtimestamp(data['created']), + completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), + feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, + ) + + +class AsyncPlantApi(AsyncKindwiseApi[PlantIdentification, PlantKBType]): + host = 'https://plant.id' + default_kb_type = PlantKBType.PLANTS + + def __init__(self, api_key: str = None): + api_key = settings.PLANT_API_KEY if api_key is None else api_key + if api_key is None: + raise ValueError( + 'API key is required, set it in init method of class or in .env file under "PLANT_API_KEY" key' + ) + super().__init__(api_key) + + @property + def identification_url(self): + return f'{self.host}/api/v3/identification' + + @property + def usage_info_url(self): + return f'{self.host}/api/v3/usage_info' + + @property + def kb_api_url(self): + return f'{self.host}/api/v3/kb' + + @property + def health_assessment_url(self): + return f'{self.host}/api/v3/health_assessment' + + async def _build_payload( + self, + *args, + health: str = None, + classification_level: str | ClassificationLevel = None, + classification_raw: bool = False, + **kwargs, + ): + payload = await super()._build_payload(*args, **kwargs) + if health is not None: + payload['health'] = health + if classification_level is not None: + if not isinstance(classification_level, ClassificationLevel): + classification_level = ClassificationLevel(classification_level) + payload['classification_level'] = classification_level.value + if classification_raw: + payload['classification_raw'] = classification_raw + return payload + + @staticmethod + def _build_details(details: str | list[str] = None, disease_details: str | list[str] = None): + if isinstance(details, str): + details = details.split(',') + if disease_details is not None: + disease_details = disease_details.split(',') if isinstance(disease_details, str) else disease_details + details = [] if details is None else details + details = list(dict.fromkeys(details + disease_details)) + return details + + async def identify( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + health: str = None, + classification_level: str | ClassificationLevel = None, + classification_raw: bool = False, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, + timeout=60.0, + ) -> PlantIdentification | RawPlantIdentification | HealthAssessment | dict: + identification = await super().identify( + image=image, + details=self._build_details(details, disease_details), + language=language, + asynchronous=asynchronous, + similar_images=similar_images, + latitude_longitude=latitude_longitude, + health=health, + custom_id=custom_id, + date_time=date_time, + max_image_size=max_image_size, + classification_level=classification_level, + classification_raw=classification_raw, + as_dict=True, + extra_get_params=extra_get_params, + extra_post_params=extra_post_params, + timeout=timeout, + ) + if as_dict: + return identification + if classification_raw: + return RawPlantIdentification.from_dict(identification) + if health == 'only': + return HealthAssessment.from_dict(identification) + return PlantIdentification.from_dict(identification) + + async def get_identification( + self, + token: str | int, + details: str | list[str] = None, + disease_details: str | list[str] = None, + language: str | list[str] = None, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + timeout=60.0, + ) -> PlantIdentification | dict: + identification = await super().get_identification( + token=token, + details=self._build_details(details, disease_details), + language=language, + as_dict=True, + extra_get_params=extra_get_params, + timeout=timeout, + ) # todo might be RawPlantIdentification + return identification if as_dict else PlantIdentification.from_dict(identification) + + def _build_query( + self, + full_disease_list: bool = False, + **kwargs, + ): + query = super()._build_query(**kwargs) + disease_query = 'full_disease_list=true' if full_disease_list else '' + if disease_query == '': + return query + + if query == '': + return f'?{disease_query}' + return f'{query}&{disease_query}' + + async def health_assessment( + self, + image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], + details: str | list[str] = None, + language: str | list[str] = None, + asynchronous: bool = False, + similar_images: bool = True, + latitude_longitude: tuple[float, float] = None, + full_disease_list: bool = False, + custom_id: int | None = None, + date_time: datetime | str | float | None = None, + max_image_size: int | None = 1500, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + extra_post_params: str = None, + timeout=60.0, + ) -> HealthAssessment | dict: + query = self._build_query( + details=details, + language=language, + asynchronous=asynchronous, + extra_get_params=extra_get_params, + full_disease_list=full_disease_list, + ) + url = f'{self.health_assessment_url}{query}' + payload = await self._build_payload( + image, + similar_images=similar_images, + latitude_longitude=latitude_longitude, + custom_id=custom_id, + date_time=date_time, + max_image_size=max_image_size, + extra_post_params=extra_post_params, + ) + 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) + + async def get_health_assessment( + self, + token: str | int, + details: str | list[str] = None, + language: str | list[str] = None, + full_disease_list: bool = False, + as_dict: bool = False, + extra_get_params: str | dict[str, str] = None, + timeout=60.0, + ) -> HealthAssessment | dict: + query = self._build_query( + details=details, language=language, full_disease_list=full_disease_list, extra_get_params=extra_get_params + ) + url = f'{self.identification_url}/{token}{query}' + 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) + + async def delete_health_assessment( + self, + identification: HealthAssessment | str | int, + timeout=60.0, + ) -> bool: + return await self.delete_identification(identification, timeout=timeout) + + @property + def views_path(self) -> Path: + return settings.APP_DIR / 'resources' / 'views.plant.json' + + @classmethod + def available_disease_details(cls) -> list[dict[str, any]]: + with open(settings.APP_DIR / 'resources' / 'views.plant.disease.json') as f: + return json.load(f) diff --git a/kindwise/core.py b/kindwise/core.py index f6a99f8..493d1f7 100644 --- a/kindwise/core.py +++ b/kindwise/core.py @@ -7,6 +7,7 @@ from pathlib import Path, PurePath from typing import Any, BinaryIO, Generic, TypeVar +import pathlib import httpx from PIL import Image @@ -71,8 +72,7 @@ def get_from_url() -> None | bytes: 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(pathlib.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)') @@ -342,7 +342,9 @@ def ask_question( 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: + 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) return Conversation.from_dict(response.json()) @@ -356,5 +358,7 @@ 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) + self._make_api_call( + self.conversation_feedback_url(token), 'POST', {'feedback': feedback}, timeout=timeout + ) return True 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..cef238c --- /dev/null +++ b/kindwise/tests/async_api/test_crop.py @@ -0,0 +1,44 @@ +import pytest + +from kindwise.async_api.crop_health import AsyncCropHealthApi, CropIdentification +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server, run_test_available_details + + +@pytest.mark.asyncio +def test_requests_to_crop_server(api_key): + run_async_test_requests_to_server( + AsyncCropHealthApi(api_key=api_key), + 'crop', + IMAGE_DIR / 'potato.late_blight.jpg', + CropIdentification, + model_name='disease', + ) + + +def test_available_details(api_key): + expected_view_names = { + 'common_names', + 'description', + 'eppo_code', + 'eppo_regulation_status', + 'gbif_id', + 'image', + 'images', + 'severity', + 'spreading', + 'symptoms', + 'taxonomy', + 'treatment', + 'type', + 'wiki_description', + 'wiki_url', + } + expected_license = {'wiki_description', 'image', 'images'} + expected_localized = {'common_names', 'wiki_url', 'wiki_description'} + + run_test_available_details( + expected_view_names, + expected_license, + expected_localized, + AsyncCropHealthApi(api_key=api_key).available_details(), + ) diff --git a/kindwise/tests/async_api/test_insect.py b/kindwise/tests/async_api/test_insect.py new file mode 100644 index 0000000..49dfbad --- /dev/null +++ b/kindwise/tests/async_api/test_insect.py @@ -0,0 +1,36 @@ +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, run_test_available_details + + +@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 + ) + + +def test_available_details(api_key): + expected_view_names = { + 'common_names', + 'danger', + 'danger_description', + 'description', + 'gbif_id', + 'image', + 'images', + 'inaturalist_id', + 'rank', + 'red_list', + 'role', + 'synonyms', + 'taxonomy', + 'url', + } + expected_license = {'description', 'image', 'images'} + expected_localized = {'common_names', 'url', 'description'} + + run_test_available_details( + expected_view_names, expected_license, expected_localized, AsyncInsectApi(api_key=api_key).available_details() + ) diff --git a/kindwise/tests/async_api/test_mushroom.py b/kindwise/tests/async_api/test_mushroom.py new file mode 100644 index 0000000..8ba3002 --- /dev/null +++ b/kindwise/tests/async_api/test_mushroom.py @@ -0,0 +1,35 @@ +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, run_test_available_details + + +@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 + ) + + +def test_available_details(api_key): + expected_view_names = { + 'common_names', + 'url', + 'description', + 'edibility', + 'psychoactive', + 'characteristic', + 'look_alike', + 'taxonomy', + 'rank', + 'gbif_id', + 'inaturalist_id', + 'image', + 'images', + } + expected_license = {'description', 'image', 'images'} + expected_localized = {'common_names', 'url', 'description'} + + run_test_available_details( + expected_view_names, expected_license, expected_localized, AsyncMushroomApi(api_key=api_key).available_details() + ) diff --git a/kindwise/tests/conftest.py b/kindwise/tests/conftest.py index 49b0fc7..d8bfd95 100644 --- a/kindwise/tests/conftest.py +++ b/kindwise/tests/conftest.py @@ -180,6 +180,87 @@ 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, respx_mock, identification_dict, identification): self.api = api diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..0987682 --- /dev/null +++ b/manage.py @@ -0,0 +1,47 @@ +import click +from kindwise import PlantApi, InsectApi, MushroomApi + +BACKENDS = {'plant': PlantApi, 'insect': InsectApi, 'mushroom': MushroomApi} + + +@click.command() +@click.argument('image_path', type=click.Path(exists=True)) +@click.argument('backend', type=click.Choice(BACKENDS.keys()), default='plant') +@click.option('--api_key', envvar='KINDWISE_API_KEY', help='Your Kindwise API key') +def identify(backend, image_path, api_key): + """ + Create an identification by specifying the path to an image and the desired backend. + + \b + BACKEND: Choose from 'plant', 'insect', or 'mushroom' + IMAGE_PATH: Path to the image file for identification + """ + if backend not in BACKENDS: + click.echo(f"Invalid backend: {backend}. Please choose from 'plant', 'insect', or 'mushroom'.") + return + + api_class = BACKENDS[backend] + + try: + api = api_class(api_key=api_key) + except ValueError as e: + click.echo(f"Error initializing API: {e}") + return + + click.echo(f"Identifying image using {backend} backend...") + try: + identification = api.identify(image_path) + click.echo(f"Identification result: {identification}") + except Exception as e: + click.echo(f"Error during identification: {e}") + + +@click.group() +def cli(): + pass + + +cli.add_command(identify) + +if __name__ == '__main__': + cli() diff --git a/poetry.lock b/poetry.lock index f591b08..bd0c01d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,18 @@ # 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" @@ -20,6 +33,19 @@ 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" @@ -1062,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" @@ -1095,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" @@ -1302,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" @@ -1476,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.13\""} +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" @@ -1523,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 = "2770a1ddbfed87bb03331706cd63f1e1020dc64339d4c03c1624b1ca6994bf70" +content-hash = "50a83b6501c296e3ac84bf7e8db53293b98724d79b02d8d6769795b09ee06411" diff --git a/pyproject.toml b/pyproject.toml index 4a1b469..c313498 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ 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 = [ @@ -26,17 +27,22 @@ 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"] From e767e53b4eda40c8c874b5ba765dab61401751d6 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 18:58:39 +0100 Subject: [PATCH 08/24] feat: add check that code has been ran through generation pipeline in github workflow --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87c1dbe..89f0baf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,10 +16,13 @@ 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: Check generated code + run: | + python generate/generate_sync.py + git diff --exit-code - name: Lint with black run: | black . --check From 8b548c14ddc5446a81d8171c62b7bf5f72f2c3ff Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 19:16:15 +0100 Subject: [PATCH 09/24] docs: add docstring for abstract methods --- kindwise/async_api/core.py | 15 +++++++++++---- kindwise/core.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/kindwise/async_api/core.py b/kindwise/async_api/core.py index 07b2d75..28db977 100644 --- a/kindwise/async_api/core.py +++ b/kindwise/async_api/core.py @@ -26,15 +26,21 @@ def __init__(self, api_key: str): @property @abc.abstractmethod - def identification_url(self): ... + def identification_url(self): + "Url for identification endpoint" + ... @property @abc.abstractmethod - def usage_info_url(self): ... + def usage_info_url(self): + "Url for usage info endpoint" + ... @property @abc.abstractmethod - def kb_api_url(self): ... + def kb_api_url(self): + "Url for knowledge base endpoint" + ... def feedback_url(self, token: str): return f'{self.identification_url}/{token}/feedback' @@ -275,7 +281,8 @@ async def feedback( @property @abc.abstractmethod - def views_path(self) -> Path: ... + def views_path(self) -> Path: + ... def available_details(self) -> list[dict[str, any]]: with open(self.views_path) as f: diff --git a/kindwise/core.py b/kindwise/core.py index 493d1f7..82ba532 100644 --- a/kindwise/core.py +++ b/kindwise/core.py @@ -26,15 +26,21 @@ def __init__(self, api_key: str): @property @abc.abstractmethod - def identification_url(self): ... + def identification_url(self): + "Url for identification endpoint" + ... @property @abc.abstractmethod - def usage_info_url(self): ... + def usage_info_url(self): + "Url for usage info endpoint" + ... @property @abc.abstractmethod - def kb_api_url(self): ... + def kb_api_url(self): + "Url for knowledge base endpoint" + ... def feedback_url(self, token: str): return f'{self.identification_url}/{token}/feedback' @@ -275,7 +281,8 @@ def feedback( @property @abc.abstractmethod - def views_path(self) -> Path: ... + def views_path(self) -> Path: + ... def available_details(self) -> list[dict[str, any]]: with open(self.views_path) as f: From f79adde9060ffe0a7ef5d54354b7e4e0a1d5d1f6 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Thu, 8 Jan 2026 19:20:56 +0100 Subject: [PATCH 10/24] fix: add black formatting to generate_sync script --- generate/generate_sync.py | 8 +++++++- kindwise/core.py | 8 ++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/generate/generate_sync.py b/generate/generate_sync.py index 04057dc..b473dba 100644 --- a/generate/generate_sync.py +++ b/generate/generate_sync.py @@ -1,5 +1,6 @@ import unasync import os +import subprocess from pathlib import Path @@ -42,8 +43,13 @@ def main(): filepaths_to_process = [os.path.abspath(p) for p in filepaths] unasync.unasync_files(filepaths_to_process, rules) + generated_files = [] for path in filepaths: - post_process(path.parent.parent / path.name) + dest_path = path.parent.parent / path.name + post_process(dest_path) + generated_files.append(str(dest_path)) + + subprocess.run(["black"] + generated_files) if __name__ == "__main__": diff --git a/kindwise/core.py b/kindwise/core.py index 82ba532..0fba0a2 100644 --- a/kindwise/core.py +++ b/kindwise/core.py @@ -349,9 +349,7 @@ def ask_question( 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: + 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) return Conversation.from_dict(response.json()) @@ -365,7 +363,5 @@ 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 - ) + self._make_api_call(self.conversation_feedback_url(token), 'POST', {'feedback': feedback}, timeout=timeout) return True From 243bd306b703d775c62a81263ef9265e3bce4086 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:33:51 +0100 Subject: [PATCH 11/24] docs: correct interneal docs --- README_internal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README_internal.md b/README_internal.md index 521b82e..5ddcc9c 100644 --- a/README_internal.md +++ b/README_internal.md @@ -14,7 +14,7 @@ pre-commit install --hook-type commit-msg Do not directly modify files under `kindwise/sync` directory. The ground truth code is located in `kindwise/async_api` directory. Use the following command to generate sync code from async code: ```bash -poetry run python generate/generate_sync_code.py +python generate/generate_sync_code.py # or make generate-sync ``` From f2d29cb0fb263e2229f1d20aa8cde72c93a77d5d Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:35:15 +0100 Subject: [PATCH 12/24] fix: remove manage file --- manage.py | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 manage.py diff --git a/manage.py b/manage.py deleted file mode 100644 index 0987682..0000000 --- a/manage.py +++ /dev/null @@ -1,47 +0,0 @@ -import click -from kindwise import PlantApi, InsectApi, MushroomApi - -BACKENDS = {'plant': PlantApi, 'insect': InsectApi, 'mushroom': MushroomApi} - - -@click.command() -@click.argument('image_path', type=click.Path(exists=True)) -@click.argument('backend', type=click.Choice(BACKENDS.keys()), default='plant') -@click.option('--api_key', envvar='KINDWISE_API_KEY', help='Your Kindwise API key') -def identify(backend, image_path, api_key): - """ - Create an identification by specifying the path to an image and the desired backend. - - \b - BACKEND: Choose from 'plant', 'insect', or 'mushroom' - IMAGE_PATH: Path to the image file for identification - """ - if backend not in BACKENDS: - click.echo(f"Invalid backend: {backend}. Please choose from 'plant', 'insect', or 'mushroom'.") - return - - api_class = BACKENDS[backend] - - try: - api = api_class(api_key=api_key) - except ValueError as e: - click.echo(f"Error initializing API: {e}") - return - - click.echo(f"Identifying image using {backend} backend...") - try: - identification = api.identify(image_path) - click.echo(f"Identification result: {identification}") - except Exception as e: - click.echo(f"Error during identification: {e}") - - -@click.group() -def cli(): - pass - - -cli.add_command(identify) - -if __name__ == '__main__': - cli() From 9dc1868f3fbae9e2ab78f4ce4c90ae46154c062f Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:45:32 +0100 Subject: [PATCH 13/24] feat: remove generated files and ignore them by git --- .gitignore | 5 + kindwise/core.py | 367 ------------------------------------ kindwise/crop_health.py | 78 -------- kindwise/insect.py | 156 ---------------- kindwise/mushroom.py | 156 ---------------- kindwise/plant.py | 403 ---------------------------------------- 6 files changed, 5 insertions(+), 1160 deletions(-) delete mode 100644 kindwise/core.py delete mode 100644 kindwise/crop_health.py delete mode 100644 kindwise/insect.py delete mode 100644 kindwise/mushroom.py delete mode 100644 kindwise/plant.py 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/kindwise/core.py b/kindwise/core.py deleted file mode 100644 index 0fba0a2..0000000 --- a/kindwise/core.py +++ /dev/null @@ -1,367 +0,0 @@ -import abc -import base64 -import enum -import io -import json -from datetime import datetime -from pathlib import Path, PurePath -from typing import Any, BinaryIO, Generic, TypeVar - -import pathlib -import httpx -from PIL import Image - -from kindwise.models import Conversation, Identification, SearchResult, UsageInfo - -IdentificationType = TypeVar('IdentificationType') -KBType = TypeVar('KBType') - - -class KindwiseApi(abc.ABC, Generic[IdentificationType, KBType]): - identification_class = Identification - default_kb_type = None - - def __init__(self, api_key: str): - self.api_key = api_key - - @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): - return f'{self.identification_url}/{token}/feedback' - - def conversation_url(self, token: str): - return f'{self.identification_url}/{token}/conversation' - - 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): - headers = { - 'Content-Type': 'application/json', - 'Api-Key': self.api_key, - } - with httpx.Client() as client: - response = 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: - if not isinstance(image, str) or not image.startswith(('http://', 'https://')): - return None - with httpx.Client() as client: - response = client.get(image) - if not response.is_success: - return None - return io.BytesIO(response.content) - - if _img := 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 - return io.BytesIO(pathlib.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)') - image.seek(0) - return io.BytesIO(image.read()) - if isinstance(image, Image.Image): - buffer = io.BytesIO() - image.save(buffer, format='JPEG') - return buffer - # str | bytes: - - def is_base64(): - try: - byte_image = image if isinstance(image, bytes) else image.encode('ascii') - return base64.b64encode(base64.b64decode(byte_image)) == byte_image - except Exception: - return False - - if is_base64(): - return io.BytesIO(base64.b64decode(image)) - sb_bytes = bytes(image, 'ascii') if isinstance(image, str) else image - 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) - - def resize_image(file) -> bytes: - img = Image.open(file) - if max(img.size) <= max_image_size: - resized_image = img - else: - aspect_ratio = img.width / img.height - new_width = max_image_size if aspect_ratio >= 1 else int(max_image_size * aspect_ratio) - new_height = int(new_width / aspect_ratio) - resized_image = img.resize((new_width, new_height)) - output_buffer = io.BytesIO() - if resized_image.mode != 'RGB': - resized_image = resized_image.convert('RGB') - resized_image.save(output_buffer, format='JPEG') - resized_image_bytes = output_buffer.getvalue() - output_buffer.close() - return resized_image_bytes - - img = buffer.getvalue() if max_image_size is None else resize_image(buffer) - buffer.close() - return base64.b64encode(img).decode('ascii') - - def _build_payload( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - extra_post_params: dict[str, Any] = None, - **kwargs, - ): - if not isinstance(image, list): - image = [image] - - payload = { - 'images': [self._encode_image(img, max_image_size) for img in image], - 'similar_images': similar_images, - } - if latitude_longitude is not None: - payload['latitude'], payload['longitude'] = latitude_longitude - if custom_id is not None: - payload['custom_id'] = custom_id - if date_time is not None: - if isinstance(date_time, datetime): - payload['datetime'] = date_time.isoformat() - elif isinstance(date_time, str): - # check if ISO format is valid - payload['datetime'] = datetime.fromisoformat(date_time).isoformat() - elif isinstance(date_time, float): - payload['datetime'] = datetime.fromtimestamp(date_time).isoformat() - else: - raise ValueError(f'Invalid date_time format {date_time=} {type(date_time)=}') - if extra_post_params is not None: - if 'suggestion_filter' in extra_post_params and isinstance(extra_post_params['suggestion_filter'], str): - extra_post_params['suggestion_filter'] = {'classification': extra_post_params['suggestion_filter']} - payload.update(extra_post_params) - return payload - - def identify( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - as_dict: bool = False, - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - extra_get_params: str | dict[str | Any] = None, - extra_post_params: dict[str, Any] = None, - timeout: float = 60.0, - **kwargs, - ) -> IdentificationType | dict: - payload = self._build_payload( - image, - similar_images=similar_images, - latitude_longitude=latitude_longitude, - custom_id=custom_id, - date_time=date_time, - max_image_size=max_image_size, - extra_post_params=extra_post_params, - **kwargs, - ) - query = self._build_query( - 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) - data = response.json() - return data if as_dict else self.identification_class.from_dict(data) - - def _build_query( - self, - details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - extra_get_params: str | dict[str, str] = None, - limit: int = None, - query: str = None, - **kwargs, - ): - if isinstance(details, str): - details = [details] - details_query = '' if details is None else f'details={",".join(details)}&' - if isinstance(language, str): - language = [language] - language_query = '' if language is None else f'language={",".join(language)}&' - if extra_get_params is None: - extra_get_params = '' - else: - if isinstance(extra_get_params, dict): - 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 = '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}' - if query.endswith('&'): - query = query[:-1] - return '' if query == '?' else query - - def get_identification( - self, - token: str | int, - details: str | list[str] = None, - language: str | list[str] = None, - extra_get_params: str | dict[str, str] = None, - as_dict: bool = False, - timeout: float = 60.0, - ) -> 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) - data = response.json() - return data if as_dict else self.identification_class.from_dict(data) - - 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) - 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) - data = response.json() - return data if as_dict else UsageInfo.from_dict(data) - - def feedback( - self, - identification: IdentificationType | str | int, - comment: str | None = None, - rating: int | None = None, - timeout: float = 60.0, - ) -> bool: - token = identification.access_token if isinstance(identification, Identification) else identification - if comment is None and rating is None: - raise ValueError('Either comment or rating must be provided') - data = {} - if comment is not None: - data['comment'] = comment - if rating is not None: - data['rating'] = rating - self._make_api_call(self.feedback_url(token), 'POST', data, timeout=timeout) - return True - - @property - @abc.abstractmethod - def views_path(self) -> Path: - ... - - def available_details(self) -> list[dict[str, any]]: - with open(self.views_path) as f: - return json.load(f) - - def search( - self, - query: str, - limit: int = None, - language: str = None, - kb_type: KBType | str = None, - as_dict=False, - timeout: float = 60.0, - ) -> SearchResult | dict: - if not query: - raise ValueError('Query parameter q must be provided') - if isinstance(limit, int) and limit < 1: - 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.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( - self, - access_token: str, - details: str | list[str], - language: str = None, - kb_type: KBType | str = None, - timeout: float = 60.0, - ) -> dict: - 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}/{access_token}{self._build_query(language=language, details=details)}' - response = 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( - self, - identification: IdentificationType | str | int, - question: str, - model: str = None, - app_name: str = None, - prompt: str = None, - temperature: float = None, - as_dict: bool = False, - timeout: float = 60.0, - ) -> Conversation: - token = identification.access_token if isinstance(identification, Identification) else identification - data = {'question': 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) - 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: - token = identification.access_token if isinstance(identification, Identification) else identification - response = 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: - token = identification.access_token if isinstance(identification, Identification) else identification - self._make_api_call(self.conversation_url(token), 'DELETE', timeout=timeout) - return True - - 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) - return True diff --git a/kindwise/crop_health.py b/kindwise/crop_health.py deleted file mode 100644 index 680f5c2..0000000 --- a/kindwise/crop_health.py +++ /dev/null @@ -1,78 +0,0 @@ -import enum -from dataclasses import dataclass -from pathlib import Path - -from kindwise import settings -from kindwise.core import KindwiseApi -from kindwise.models import Identification, ResultEvaluation, ClassificationWithScientificName, Conversation - - -@dataclass -class CropResult: - is_plant: ResultEvaluation - crop: ClassificationWithScientificName - disease: ClassificationWithScientificName | None - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_plant=ResultEvaluation.from_dict(data['is_plant']), - crop=ClassificationWithScientificName.from_dict(data['crop']), - disease=ClassificationWithScientificName.from_dict(data['disease']) if 'disease' in data else None, - ) - - -@dataclass -class CropIdentification(Identification): - result: CropResult | None - - @classmethod - def get_result_class(cls): - return CropResult - - -class CropHealthKBType(str, enum.Enum): - CROP = 'crop' - - -class CropHealthApi(KindwiseApi[CropIdentification, CropHealthKBType]): - host = 'https://crop.kindwise.com' - default_kb_type = CropHealthKBType.CROP - identification_class = CropIdentification - - def __init__(self, api_key: str = None): - api_key = settings.CROP_HEALTH_API_KEY if api_key is None else api_key - if api_key is None: - raise ValueError( - 'API key is required, set it in init method of class or in .env file under "CROP_HEALTH_API_KEY" key' - ) - super().__init__(api_key) - - @property - def identification_url(self): - return f'{self.host}/api/v1/identification' - - @property - def usage_info_url(self): - return f'{self.host}/api/v1/usage_info' - - @property - def views_path(self) -> Path: - 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( - self, - identification: CropIdentification | str | int, - question: str, - model: str = None, - app_name: str = None, - prompt: str = None, - temperature: float = None, - as_dict: bool = False, - timeout=60.0, - ) -> Conversation: - raise NotImplementedError('Asking questions is currently not supported by crop.health.') diff --git a/kindwise/insect.py b/kindwise/insect.py deleted file mode 100644 index 4b941dd..0000000 --- a/kindwise/insect.py +++ /dev/null @@ -1,156 +0,0 @@ -import enum -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path, PurePath -from typing import BinaryIO - -from PIL import Image - -from kindwise import settings -from kindwise.core import KindwiseApi -from kindwise.models import ( - Identification, - Conversation, - ResultEvaluation, - Classification, - Input, - IdentificationStatus, - Feedback, -) - - -class InsectKBType(str, enum.Enum): - INSECT = 'insect' - - -@dataclass -class InsectResult: - is_insect: ResultEvaluation - classification: Classification - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_insect=ResultEvaluation.from_dict(data['is_insect']), - classification=Classification.from_dict(data['classification']), - ) - - -@dataclass -class InsectIdentification(Identification): - result: InsectResult | None - input: Input - - @classmethod - def from_dict(cls, data: dict) -> 'InsectIdentification': - return cls( - access_token=data['access_token'], - model_version=data['model_version'], - custom_id=data['custom_id'], - input=Input.from_dict(data['input']), - result=None if 'result' not in data else InsectResult.from_dict(data['result']), - status=IdentificationStatus(data['status']), - sla_compliant_client=data['sla_compliant_client'], - sla_compliant_system=data['sla_compliant_system'], - created=datetime.fromtimestamp(data['created']), - completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), - feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, - ) - - -class InsectApi(KindwiseApi[InsectIdentification, InsectKBType]): - host = 'https://insect.kindwise.com' - default_kb_type = InsectKBType.INSECT - - def __init__(self, api_key: str = None): - api_key = settings.INSECT_API_KEY if api_key is None else api_key - if api_key is None: - raise ValueError( - 'API key is required, set it in init method of class or in .env file under "INSECT_API_KEY" key' - ) - super().__init__(api_key) - - @property - def identification_url(self): - return f'{self.host}/api/v1/identification' - - @property - def usage_info_url(self): - return f'{self.host}/api/v1/usage_info' - - @property - def kb_api_url(self): - return f'{self.host}/api/v1/kb' - - def identify( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, - timeout=60.0, - ) -> InsectIdentification | dict: - identification = super().identify( - image=image, - details=details, - language=language, - asynchronous=asynchronous, - similar_images=similar_images, - latitude_longitude=latitude_longitude, - custom_id=custom_id, - date_time=date_time, - max_image_size=max_image_size, - as_dict=True, - extra_get_params=extra_get_params, - extra_post_params=extra_post_params, - timeout=timeout, - ) - if as_dict: - return identification - return InsectIdentification.from_dict(identification) - - def get_identification( - self, - token: str | int, - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - timeout=60.0, - ) -> InsectIdentification | dict: - identification = super().get_identification( - token=token, - details=details, - language=language, - as_dict=True, - extra_get_params=extra_get_params, - timeout=timeout, - ) - return identification if as_dict else InsectIdentification.from_dict(identification) - - @property - def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / 'views.insect.json' - - def ask_question( - self, - identification: Identification | str | int, - question: str, - model: str = None, - app_name: str = None, - prompt: str = None, - temperature: float = None, - as_dict: bool = False, - timeout=60.0, - ) -> Conversation: - raise NotImplementedError('Asking questions is currently not supported by insect.id') diff --git a/kindwise/mushroom.py b/kindwise/mushroom.py deleted file mode 100644 index 094a612..0000000 --- a/kindwise/mushroom.py +++ /dev/null @@ -1,156 +0,0 @@ -import enum -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path, PurePath -from typing import BinaryIO - -from PIL import Image - -from kindwise import settings -from kindwise.core import KindwiseApi -from kindwise.models import ( - Identification, - Conversation, - ResultEvaluation, - Classification, - Input, - IdentificationStatus, - Feedback, -) - - -class MushroomKBType(str, enum.Enum): - MUSHROOM = 'mushroom' - - -@dataclass -class MushroomResult: - is_mushroom: ResultEvaluation - classification: Classification - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_mushroom=ResultEvaluation.from_dict(data['is_mushroom']), - classification=Classification.from_dict(data['classification']), - ) - - -@dataclass -class MushroomIdentification(Identification): - result: MushroomResult | None - input: Input - - @classmethod - def from_dict(cls, data: dict) -> 'MushroomIdentification': - return cls( - access_token=data['access_token'], - model_version=data['model_version'], - custom_id=data['custom_id'], - input=Input.from_dict(data['input']), - result=None if 'result' not in data else MushroomResult.from_dict(data['result']), - status=IdentificationStatus(data['status']), - sla_compliant_client=data['sla_compliant_client'], - sla_compliant_system=data['sla_compliant_system'], - created=datetime.fromtimestamp(data['created']), - completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), - feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, - ) - - -class MushroomApi(KindwiseApi[Identification, MushroomKBType]): - host = 'https://mushroom.kindwise.com' - default_kb_type = MushroomKBType.MUSHROOM - - def __init__(self, api_key: str = None): - api_key = settings.MUSHROOM_API_KEY if api_key is None else api_key - if api_key is None: - raise ValueError( - 'API key is required, set it in init method of class or in .env file under "MUSHROOM_API_KEY" key' - ) - super().__init__(api_key) - - @property - def identification_url(self): - return f'{self.host}/api/v1/identification' - - @property - def usage_info_url(self): - return f'{self.host}/api/v1/usage_info' - - @property - def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / 'views.mushroom.json' - - @property - def kb_api_url(self): - return f'{self.host}/api/v1/kb' - - def identify( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, - timeout=60.0, - ) -> MushroomIdentification | dict: - identification = super().identify( - image=image, - details=details, - language=language, - asynchronous=asynchronous, - similar_images=similar_images, - latitude_longitude=latitude_longitude, - custom_id=custom_id, - date_time=date_time, - max_image_size=max_image_size, - as_dict=True, - extra_get_params=extra_get_params, - extra_post_params=extra_post_params, - timeout=timeout, - ) - if as_dict: - return identification - return MushroomIdentification.from_dict(identification) - - def get_identification( - self, - token: str | int, - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - timeout=60.0, - ) -> MushroomIdentification | dict: - identification = super().get_identification( - token=token, - details=details, - language=language, - as_dict=True, - extra_get_params=extra_get_params, - timeout=timeout, - ) - return identification if as_dict else MushroomIdentification.from_dict(identification) - - def ask_question( - self, - identification: Identification | str | int, - question: str, - model: str = None, - app_name: str = None, - prompt: str = None, - temperature: float = None, - as_dict: bool = False, - timeout=60.0, - ) -> Conversation: - raise NotImplementedError('Asking questions is currently not supported by mushroom.id') diff --git a/kindwise/plant.py b/kindwise/plant.py deleted file mode 100644 index 3579b00..0000000 --- a/kindwise/plant.py +++ /dev/null @@ -1,403 +0,0 @@ -import enum -import json -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path, PurePath -from typing import BinaryIO - -from PIL import Image - -from kindwise import settings -from kindwise.core import KindwiseApi -from kindwise.models import ( - ClassificationLevel, - Identification, - Input, - IdentificationStatus, - Feedback, - ResultEvaluation, - Classification, - Suggestion, -) - - -class PlantKBType(str, enum.Enum): - PLANTS = 'plants' - DISEASES = 'diseases' - - -@dataclass -class PlantResult: - is_plant: ResultEvaluation - is_healthy: ResultEvaluation | None - classification: Classification - disease: Classification | None - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_plant=ResultEvaluation.from_dict(data['is_plant']), - is_healthy=ResultEvaluation.from_dict(data['is_healthy']) if 'is_healthy' in data else None, - classification=Classification.from_dict(data['classification']), - disease=Classification.from_dict(data['disease']) if 'disease' in data else None, - ) - - -@dataclass -class PlantInput(Input): - classification_level: ClassificationLevel | None - classification_raw: bool - - @classmethod - def from_dict(cls, data: dict) -> 'PlantInput': - return cls( - images=data['images'], - datetime=datetime.fromisoformat(data['datetime']), - 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_raw=data.get('classification_raw', False), - ) - - -@dataclass -class PlantIdentification(Identification): - result: PlantResult | None - input: PlantInput - - @classmethod - def from_dict(cls, data: dict) -> 'PlantIdentification': - return cls( - access_token=data['access_token'], - model_version=data['model_version'], - custom_id=data['custom_id'], - input=PlantInput.from_dict(data['input']), - result=None if 'result' not in data else PlantResult.from_dict(data['result']), - status=IdentificationStatus(data['status']), - sla_compliant_client=data['sla_compliant_client'], - sla_compliant_system=data['sla_compliant_system'], - created=datetime.fromtimestamp(data['created']), - completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), - feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, - ) - - -@dataclass -class TaxaSpecificSuggestion: - genus: list[Suggestion] - species: list[Suggestion] - infraspecies: list[Suggestion] | None - - @classmethod - 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']] - ), - ) - - -@dataclass -class RawClassification: - suggestions: TaxaSpecificSuggestion - - @classmethod - def from_dict(cls, data: dict): - return cls( - suggestions=TaxaSpecificSuggestion.from_dict(data['suggestions']), - ) - - -@dataclass -class RawPlantResult: - is_plant: ResultEvaluation - is_healthy: ResultEvaluation | None - classification: RawClassification - disease: Classification | None - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_plant=ResultEvaluation.from_dict(data['is_plant']), - is_healthy=ResultEvaluation.from_dict(data['is_healthy']) if 'is_healthy' in data else None, - classification=RawClassification.from_dict(data['classification']), - disease=Classification.from_dict(data['disease']) if 'disease' in data else None, - ) - - -@dataclass -class RawPlantIdentification(Identification): - result: RawPlantResult | None - - @classmethod - def from_dict(cls, data: dict) -> 'RawPlantIdentification': - return cls( - access_token=data['access_token'], - model_version=data['model_version'], - custom_id=data['custom_id'], - input=Input.from_dict(data['input']), - result=None if 'result' not in data else RawPlantResult.from_dict(data['result']), - status=IdentificationStatus(data['status']), - sla_compliant_client=data['sla_compliant_client'], - sla_compliant_system=data['sla_compliant_system'], - created=datetime.fromtimestamp(data['created']), - completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), - feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, - ) - - -@dataclass -class HealthAssessmentResult: - is_plant: ResultEvaluation - is_healthy: ResultEvaluation - disease: Classification - - @classmethod - def from_dict(cls, data: dict): - return cls( - is_plant=ResultEvaluation.from_dict(data['is_plant']), - is_healthy=ResultEvaluation.from_dict(data['is_healthy']), - disease=Classification.from_dict(data['disease']), - ) - - -@dataclass -class HealthAssessment(Identification): - result: HealthAssessmentResult | None - - @classmethod - def from_dict(cls, data: dict) -> 'HealthAssessment': - return cls( - access_token=data['access_token'], - model_version=data['model_version'], - custom_id=data['custom_id'], - input=Input.from_dict(data['input']), - result=None if 'result' not in data else HealthAssessmentResult.from_dict(data['result']), - status=data['status'], - sla_compliant_client=data['sla_compliant_client'], - sla_compliant_system=data['sla_compliant_system'], - created=datetime.fromtimestamp(data['created']), - completed=None if data['completed'] is None else datetime.fromtimestamp(data['completed']), - feedback=Feedback.from_dict(data['feedback']) if 'feedback' in data else None, - ) - - -class PlantApi(KindwiseApi[PlantIdentification, PlantKBType]): - host = 'https://plant.id' - default_kb_type = PlantKBType.PLANTS - - def __init__(self, api_key: str = None): - api_key = settings.PLANT_API_KEY if api_key is None else api_key - if api_key is None: - raise ValueError( - 'API key is required, set it in init method of class or in .env file under "PLANT_API_KEY" key' - ) - super().__init__(api_key) - - @property - def identification_url(self): - return f'{self.host}/api/v3/identification' - - @property - def usage_info_url(self): - return f'{self.host}/api/v3/usage_info' - - @property - def kb_api_url(self): - return f'{self.host}/api/v3/kb' - - @property - def health_assessment_url(self): - return f'{self.host}/api/v3/health_assessment' - - def _build_payload( - self, - *args, - health: str = None, - classification_level: str | ClassificationLevel = None, - classification_raw: bool = False, - **kwargs, - ): - payload = super()._build_payload(*args, **kwargs) - if health is not None: - payload['health'] = health - if classification_level is not None: - if not isinstance(classification_level, ClassificationLevel): - classification_level = ClassificationLevel(classification_level) - payload['classification_level'] = classification_level.value - if classification_raw: - payload['classification_raw'] = classification_raw - return payload - - @staticmethod - def _build_details(details: str | list[str] = None, disease_details: str | list[str] = None): - if isinstance(details, str): - details = details.split(',') - if disease_details is not None: - disease_details = disease_details.split(',') if isinstance(disease_details, str) else disease_details - details = [] if details is None else details - details = list(dict.fromkeys(details + disease_details)) - return details - - def identify( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - health: str = None, - classification_level: str | ClassificationLevel = None, - classification_raw: bool = False, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - extra_post_params: str | dict[str, dict[str, str]] | dict[str, str] = None, - timeout=60.0, - ) -> PlantIdentification | RawPlantIdentification | HealthAssessment | dict: - identification = super().identify( - image=image, - details=self._build_details(details, disease_details), - language=language, - asynchronous=asynchronous, - similar_images=similar_images, - latitude_longitude=latitude_longitude, - health=health, - custom_id=custom_id, - date_time=date_time, - max_image_size=max_image_size, - classification_level=classification_level, - classification_raw=classification_raw, - as_dict=True, - extra_get_params=extra_get_params, - extra_post_params=extra_post_params, - timeout=timeout, - ) - if as_dict: - return identification - if classification_raw: - return RawPlantIdentification.from_dict(identification) - if health == 'only': - return HealthAssessment.from_dict(identification) - return PlantIdentification.from_dict(identification) - - def get_identification( - self, - token: str | int, - details: str | list[str] = None, - disease_details: str | list[str] = None, - language: str | list[str] = None, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - timeout=60.0, - ) -> PlantIdentification | dict: - identification = super().get_identification( - token=token, - details=self._build_details(details, disease_details), - language=language, - as_dict=True, - extra_get_params=extra_get_params, - timeout=timeout, - ) # todo might be RawPlantIdentification - return identification if as_dict else PlantIdentification.from_dict(identification) - - def _build_query( - self, - full_disease_list: bool = False, - **kwargs, - ): - query = super()._build_query(**kwargs) - disease_query = 'full_disease_list=true' if full_disease_list else '' - if disease_query == '': - return query - - if query == '': - return f'?{disease_query}' - return f'{query}&{disease_query}' - - def health_assessment( - self, - image: PurePath | str | bytes | BinaryIO | Image.Image | list[str | PurePath | bytes | BinaryIO | Image.Image], - details: str | list[str] = None, - language: str | list[str] = None, - asynchronous: bool = False, - similar_images: bool = True, - latitude_longitude: tuple[float, float] = None, - full_disease_list: bool = False, - custom_id: int | None = None, - date_time: datetime | str | float | None = None, - max_image_size: int | None = 1500, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - extra_post_params: str = None, - timeout=60.0, - ) -> HealthAssessment | dict: - query = self._build_query( - details=details, - language=language, - asynchronous=asynchronous, - extra_get_params=extra_get_params, - full_disease_list=full_disease_list, - ) - url = f'{self.health_assessment_url}{query}' - payload = self._build_payload( - image, - similar_images=similar_images, - latitude_longitude=latitude_longitude, - custom_id=custom_id, - date_time=date_time, - 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.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( - self, - token: str | int, - details: str | list[str] = None, - language: str | list[str] = None, - full_disease_list: bool = False, - as_dict: bool = False, - extra_get_params: str | dict[str, str] = None, - timeout=60.0, - ) -> HealthAssessment | dict: - query = self._build_query( - 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.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( - self, - identification: HealthAssessment | str | int, - timeout=60.0, - ) -> bool: - return self.delete_identification(identification, timeout=timeout) - - @property - def views_path(self) -> Path: - return settings.APP_DIR / 'resources' / 'views.plant.json' - - @classmethod - def available_disease_details(cls) -> list[dict[str, any]]: - with open(settings.APP_DIR / 'resources' / 'views.plant.disease.json') as f: - return json.load(f) From f4d4c590f3a3ec776436ab0650ee95dcbb3abddf Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:47:38 +0100 Subject: [PATCH 14/24] fix: remove black formatting from generate_sync script as there is no need now for generated files to be black formatted --- generate/generate_sync.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/generate/generate_sync.py b/generate/generate_sync.py index b473dba..126b24f 100644 --- a/generate/generate_sync.py +++ b/generate/generate_sync.py @@ -1,6 +1,5 @@ import unasync import os -import subprocess from pathlib import Path @@ -49,8 +48,6 @@ def main(): post_process(dest_path) generated_files.append(str(dest_path)) - subprocess.run(["black"] + generated_files) - if __name__ == "__main__": main() From 408aa158565aa040f4385f9b147facd3d5673613 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:50:40 +0100 Subject: [PATCH 15/24] feat: include generate sync script in build --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c313498..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"}, ] @@ -45,7 +46,7 @@ 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] From 7769caf53cedc9e6340dcd3a2cb9e3aaddb76234 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:51:15 +0100 Subject: [PATCH 16/24] docs: update internal readme with info about deployment --- README_internal.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README_internal.md b/README_internal.md index 5ddcc9c..f240b36 100644 --- a/README_internal.md +++ b/README_internal.md @@ -11,7 +11,7 @@ pre-commit install --hook-type commit-msg ## Developmnet -Do not directly modify files under `kindwise/sync` directory. The ground truth code is located in `kindwise/async_api` directory. Use the following command to generate sync code from async code: +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 @@ -25,3 +25,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 From acfd91b1ea30db85a9698299727649b723383bcc Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:53:17 +0100 Subject: [PATCH 17/24] fix: do not check if generated files are black compatabile as they are not included in repo now --- .github/workflows/test.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89f0baf..9c66ddf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,10 +19,9 @@ jobs: pip install poetry==2.2.1 poetry config virtualenvs.create false poetry -vv install - - name: Check generated code + - name: generate sync code run: | - python generate/generate_sync.py - git diff --exit-code + make generate-sync - name: Lint with black run: | black . --check From d659cd230ba771e8fa32c055c88b9dc84c3f511d Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 10:56:47 +0100 Subject: [PATCH 18/24] fix: be able to import async api clinets directly from kindwise module --- kindwise/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) 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 From 1dbc0809571fb71b9f3fbdd10a53d7c910c89840 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 11:59:07 +0100 Subject: [PATCH 19/24] fix: await test method in test_crop in async_api tests --- kindwise/tests/async_api/test_crop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kindwise/tests/async_api/test_crop.py b/kindwise/tests/async_api/test_crop.py index cef238c..c514a7c 100644 --- a/kindwise/tests/async_api/test_crop.py +++ b/kindwise/tests/async_api/test_crop.py @@ -5,8 +5,8 @@ @pytest.mark.asyncio -def test_requests_to_crop_server(api_key): - run_async_test_requests_to_server( +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', From 1f19256a5e860cfac44921327e2ae957ba656d83 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 11:59:51 +0100 Subject: [PATCH 20/24] fix: do not duplicate test for availabel details, keep this test only in sync test version --- kindwise/tests/async_api/test_crop.py | 31 +---------------------- kindwise/tests/async_api/test_insect.py | 27 +------------------- kindwise/tests/async_api/test_mushroom.py | 26 +------------------ 3 files changed, 3 insertions(+), 81 deletions(-) diff --git a/kindwise/tests/async_api/test_crop.py b/kindwise/tests/async_api/test_crop.py index c514a7c..64cefcd 100644 --- a/kindwise/tests/async_api/test_crop.py +++ b/kindwise/tests/async_api/test_crop.py @@ -1,7 +1,7 @@ import pytest from kindwise.async_api.crop_health import AsyncCropHealthApi, CropIdentification -from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server, run_test_available_details +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server @pytest.mark.asyncio @@ -13,32 +13,3 @@ async def test_requests_to_crop_server(api_key): CropIdentification, model_name='disease', ) - - -def test_available_details(api_key): - expected_view_names = { - 'common_names', - 'description', - 'eppo_code', - 'eppo_regulation_status', - 'gbif_id', - 'image', - 'images', - 'severity', - 'spreading', - 'symptoms', - 'taxonomy', - 'treatment', - 'type', - 'wiki_description', - 'wiki_url', - } - expected_license = {'wiki_description', 'image', 'images'} - expected_localized = {'common_names', 'wiki_url', 'wiki_description'} - - run_test_available_details( - expected_view_names, - expected_license, - expected_localized, - AsyncCropHealthApi(api_key=api_key).available_details(), - ) diff --git a/kindwise/tests/async_api/test_insect.py b/kindwise/tests/async_api/test_insect.py index 49dfbad..01a8973 100644 --- a/kindwise/tests/async_api/test_insect.py +++ b/kindwise/tests/async_api/test_insect.py @@ -1,7 +1,7 @@ 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, run_test_available_details +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server @pytest.mark.asyncio @@ -9,28 +9,3 @@ 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 ) - - -def test_available_details(api_key): - expected_view_names = { - 'common_names', - 'danger', - 'danger_description', - 'description', - 'gbif_id', - 'image', - 'images', - 'inaturalist_id', - 'rank', - 'red_list', - 'role', - 'synonyms', - 'taxonomy', - 'url', - } - expected_license = {'description', 'image', 'images'} - expected_localized = {'common_names', 'url', 'description'} - - run_test_available_details( - expected_view_names, expected_license, expected_localized, AsyncInsectApi(api_key=api_key).available_details() - ) diff --git a/kindwise/tests/async_api/test_mushroom.py b/kindwise/tests/async_api/test_mushroom.py index 8ba3002..5bc51f1 100644 --- a/kindwise/tests/async_api/test_mushroom.py +++ b/kindwise/tests/async_api/test_mushroom.py @@ -1,7 +1,7 @@ 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, run_test_available_details +from kindwise.tests.conftest import IMAGE_DIR, run_async_test_requests_to_server @pytest.mark.asyncio @@ -9,27 +9,3 @@ 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 ) - - -def test_available_details(api_key): - expected_view_names = { - 'common_names', - 'url', - 'description', - 'edibility', - 'psychoactive', - 'characteristic', - 'look_alike', - 'taxonomy', - 'rank', - 'gbif_id', - 'inaturalist_id', - 'image', - 'images', - } - expected_license = {'description', 'image', 'images'} - expected_localized = {'common_names', 'url', 'description'} - - run_test_available_details( - expected_view_names, expected_license, expected_localized, AsyncMushroomApi(api_key=api_key).available_details() - ) From 687a9d8961ba591e219542e37d892499cdf9c4f9 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 12:00:07 +0100 Subject: [PATCH 21/24] feat: tests for async plant client --- kindwise/tests/async_api/test_plant.py | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 kindwise/tests/async_api/test_plant.py 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) From 471ed60af998abe392efc37914626d64155980da Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 12:00:27 +0100 Subject: [PATCH 22/24] refactor: do not name unused variable --- kindwise/tests/test_plant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}' From b6cf29bf9fb21477e2811ff62f57856e63a3fece Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 12:52:47 +0100 Subject: [PATCH 23/24] docs: correct way to run async code in readme --- README.md | 45 +++++++++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 8f78251..ba67305 100644 --- a/README.md +++ b/README.md @@ -560,26 +560,31 @@ print(router.identify(image_path).simple) 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 -# 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 +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 From 37977bcf524d18615e4efe6c984366625bb66450 Mon Sep 17 00:00:00 2001 From: Simon Plhak Date: Mon, 12 Jan 2026 12:58:38 +0100 Subject: [PATCH 24/24] docs: include generate-sync script in setup part --- README_internal.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README_internal.md b/README_internal.md index f240b36..fb42490 100644 --- a/README_internal.md +++ b/README_internal.md @@ -7,6 +7,7 @@ pipx install poetry==2.2.1 poetry install --extras router pre-commit install pre-commit install --hook-type commit-msg +make generate-sync ``` ## Developmnet