feat: add Pl@ntNet identification service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Stephan Kerkman 2026-05-30 17:34:48 +02:00
parent 2ceed3b083
commit b884852f05
2 changed files with 173 additions and 0 deletions

View file

@ -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]
]

View file

@ -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