From b884852f0565cd66e761475d5d2bc7f1c27a7693 Mon Sep 17 00:00:00 2001 From: Stephan Kerkman Date: Sat, 30 May 2026 17:34:48 +0200 Subject: [PATCH] feat: add Pl@ntNet identification service Co-Authored-By: Claude Sonnet 4.6 --- plants/services/plantnet.py | 66 +++++++++++++++++++++ plants/tests/test_plantnet.py | 107 ++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 plants/services/plantnet.py create mode 100644 plants/tests/test_plantnet.py diff --git a/plants/services/plantnet.py b/plants/services/plantnet.py new file mode 100644 index 0000000..507405d --- /dev/null +++ b/plants/services/plantnet.py @@ -0,0 +1,66 @@ +import requests +from django.conf import settings + +_API_BASE = 'https://my-api.plantnet.org/v2/identify/all' +_TIMEOUT = 15 + + +class PlantNetError(Exception): + pass + + +def identify(images): + """ + Call Pl@ntNet identify API. + + images: list of (image_bytes, organ) tuples + organ: 'flower' | 'leaf' | 'fruit' | 'bark' | 'habit' | 'auto' + + Returns list of match dicts (up to 5), each: + scientific_name, common_names, score, gbif_id, family, genus + + Raises PlantNetError on any failure. + """ + if not settings.PLANTNET_API_KEY: + raise PlantNetError('API key not configured.') + + files = [ + ('images', (f'image{i}.jpg', img_bytes, 'image/jpeg')) + for i, (img_bytes, _) in enumerate(images) + ] + data = [('organs', organ) for _, organ in images] + + try: + resp = requests.post( + _API_BASE, + params={'api-key': settings.PLANTNET_API_KEY, 'lang': 'en'}, + files=files, + data=data, + timeout=_TIMEOUT, + ) + except requests.Timeout: + raise PlantNetError('Pl@ntNet timed out — try again.') + + if resp.status_code == 429: + raise PlantNetError('Daily limit reached (500/day).') + + try: + resp.raise_for_status() + except requests.HTTPError: + raise PlantNetError(f'Pl@ntNet API error ({resp.status_code}).') + + results = resp.json().get('results', []) + if not results: + raise PlantNetError('No match found — try a different photo or organ.') + + return [ + { + 'scientific_name': r['species']['scientificNameWithoutAuthor'], + 'common_names': r['species'].get('commonNames', []), + 'score': r['score'], + 'gbif_id': (r.get('gbif') or {}).get('id'), + 'family': r['species'].get('family', {}).get('scientificNameWithoutAuthor', ''), + 'genus': r['species'].get('genus', {}).get('scientificNameWithoutAuthor', ''), + } + for r in results[:5] + ] diff --git a/plants/tests/test_plantnet.py b/plants/tests/test_plantnet.py new file mode 100644 index 0000000..3de7814 --- /dev/null +++ b/plants/tests/test_plantnet.py @@ -0,0 +1,107 @@ +import pytest +import requests as req_lib +from unittest.mock import patch, MagicMock +from plants.services.plantnet import identify, PlantNetError + + +def _mock_response(results=None, status_code=200): + mock = MagicMock() + mock.status_code = status_code + mock.json.return_value = {'results': results or []} + if status_code == 429: + mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock) + elif status_code >= 400: + mock.raise_for_status.side_effect = req_lib.HTTPError(response=mock) + else: + mock.raise_for_status = MagicMock() + return mock + + +def _api_results(): + return [ + { + 'score': 0.94, + 'species': { + 'scientificNameWithoutAuthor': 'Liatris spicata', + 'commonNames': ['Blazing star', 'Pronkster'], + 'family': {'scientificNameWithoutAuthor': 'Asteraceae'}, + 'genus': {'scientificNameWithoutAuthor': 'Liatris'}, + }, + 'gbif': {'id': '2889739'}, + }, + { + 'score': 0.04, + 'species': { + 'scientificNameWithoutAuthor': 'Liatris pycnostachya', + 'commonNames': ['Prairie blazing star'], + 'family': {'scientificNameWithoutAuthor': 'Asteraceae'}, + 'genus': {'scientificNameWithoutAuthor': 'Liatris'}, + }, + 'gbif': {'id': '1234567'}, + }, + ] + + +def test_identify_returns_empty_without_api_key(settings): + settings.PLANTNET_API_KEY = '' + with pytest.raises(PlantNetError, match='API key'): + identify([(b'fake', 'flower')]) + + +def test_identify_parses_results(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(_api_results())): + results = identify([(b'fake', 'flower')]) + + assert len(results) == 2 + assert results[0]['scientific_name'] == 'Liatris spicata' + assert results[0]['score'] == pytest.approx(0.94) + assert 'Blazing star' in results[0]['common_names'] + assert results[0]['family'] == 'Asteraceae' + assert results[0]['genus'] == 'Liatris' + assert results[0]['gbif_id'] == '2889739' + + +def test_identify_raises_on_no_results(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response([])): + with pytest.raises(PlantNetError, match='No match'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_quota_exceeded(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(status_code=429)): + with pytest.raises(PlantNetError, match='Daily limit'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_timeout(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + side_effect=req_lib.Timeout): + with pytest.raises(PlantNetError, match='timed out'): + identify([(b'fake', 'flower')]) + + +def test_identify_raises_on_other_http_error(settings): + settings.PLANTNET_API_KEY = 'test-key' + with patch('plants.services.plantnet.requests.post', + return_value=_mock_response(status_code=500)): + with pytest.raises(PlantNetError): + identify([(b'fake', 'flower')]) + + +def test_identify_sends_correct_organs(settings): + settings.PLANTNET_API_KEY = 'test-key' + mock_post = MagicMock(return_value=_mock_response(_api_results())) + with patch('plants.services.plantnet.requests.post', mock_post): + identify([(b'img1', 'flower'), (b'img2', 'leaf')]) + + call_kwargs = mock_post.call_args + data = call_kwargs[1]['data'] + assert ('organs', 'flower') in data + assert ('organs', 'leaf') in data