spora-sdk 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spora_sdk-2.0.0/PKG-INFO +88 -0
- spora_sdk-2.0.0/README.md +60 -0
- spora_sdk-2.0.0/setup.cfg +4 -0
- spora_sdk-2.0.0/setup.py +23 -0
- spora_sdk-2.0.0/spora.py +284 -0
- spora_sdk-2.0.0/spora_sdk.egg-info/PKG-INFO +88 -0
- spora_sdk-2.0.0/spora_sdk.egg-info/SOURCES.txt +8 -0
- spora_sdk-2.0.0/spora_sdk.egg-info/dependency_links.txt +1 -0
- spora_sdk-2.0.0/spora_sdk.egg-info/requires.txt +1 -0
- spora_sdk-2.0.0/spora_sdk.egg-info/top_level.txt +1 -0
spora_sdk-2.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spora-sdk
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python SDK for the Spora agronomic intelligence API
|
|
5
|
+
Home-page: https://spora.engineer
|
|
6
|
+
Author: Spora
|
|
7
|
+
Author-email: hello.spora@yahoo.com
|
|
8
|
+
Project-URL: Documentation, https://spora.engineer/docs
|
|
9
|
+
Keywords: agronomy,agriculture,api,plants,crops
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: requests>=2.28.0
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: classifier
|
|
20
|
+
Dynamic: description
|
|
21
|
+
Dynamic: description-content-type
|
|
22
|
+
Dynamic: home-page
|
|
23
|
+
Dynamic: keywords
|
|
24
|
+
Dynamic: project-url
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
# Spora Python SDK
|
|
30
|
+
|
|
31
|
+
Official Python client for the [Spora agronomic intelligence API](https://spora.engineer).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install spora-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from spora import Spora
|
|
43
|
+
|
|
44
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
45
|
+
|
|
46
|
+
# Search species
|
|
47
|
+
species = client.search_species("Rosa", limit=5)
|
|
48
|
+
|
|
49
|
+
# Get crop requirements
|
|
50
|
+
crop = client.get_crop("solanum_lycopersicum")
|
|
51
|
+
print(crop["temp_optimal_min"], crop["temp_optimal_max"])
|
|
52
|
+
|
|
53
|
+
# AI question (paid key)
|
|
54
|
+
answer = client.ask(
|
|
55
|
+
"When should I irrigate my tomatoes?",
|
|
56
|
+
crop="solanum_lycopersicum",
|
|
57
|
+
lat=45.4, lon=11.0,
|
|
58
|
+
tone="concise",
|
|
59
|
+
)
|
|
60
|
+
print(answer["response"])
|
|
61
|
+
|
|
62
|
+
# Batch
|
|
63
|
+
results = client.batch([
|
|
64
|
+
("weather", {"lat": 45.4, "lon": 11.0, "days": 3}),
|
|
65
|
+
("should_water", {"lat": 45.4, "lon": 11.0, "crop": "vitis_vinifera"}),
|
|
66
|
+
])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Error handling
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from spora import Spora, SporaError
|
|
73
|
+
|
|
74
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = client.ask("Best cover crop for clay soil?")
|
|
78
|
+
except SporaError as e:
|
|
79
|
+
print(e.status, e.error)
|
|
80
|
+
|
|
81
|
+
print(client.rate_limit.remaining, "/", client.rate_limit.limit)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Links
|
|
85
|
+
|
|
86
|
+
- [Documentation](https://spora.engineer/docs/sdk)
|
|
87
|
+
- [API Reference](https://spora.engineer/docs)
|
|
88
|
+
- [Get an API key](https://spora.engineer/request)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Spora Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Spora agronomic intelligence API](https://spora.engineer).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install spora-sdk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from spora import Spora
|
|
15
|
+
|
|
16
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
17
|
+
|
|
18
|
+
# Search species
|
|
19
|
+
species = client.search_species("Rosa", limit=5)
|
|
20
|
+
|
|
21
|
+
# Get crop requirements
|
|
22
|
+
crop = client.get_crop("solanum_lycopersicum")
|
|
23
|
+
print(crop["temp_optimal_min"], crop["temp_optimal_max"])
|
|
24
|
+
|
|
25
|
+
# AI question (paid key)
|
|
26
|
+
answer = client.ask(
|
|
27
|
+
"When should I irrigate my tomatoes?",
|
|
28
|
+
crop="solanum_lycopersicum",
|
|
29
|
+
lat=45.4, lon=11.0,
|
|
30
|
+
tone="concise",
|
|
31
|
+
)
|
|
32
|
+
print(answer["response"])
|
|
33
|
+
|
|
34
|
+
# Batch
|
|
35
|
+
results = client.batch([
|
|
36
|
+
("weather", {"lat": 45.4, "lon": 11.0, "days": 3}),
|
|
37
|
+
("should_water", {"lat": 45.4, "lon": 11.0, "crop": "vitis_vinifera"}),
|
|
38
|
+
])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Error handling
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from spora import Spora, SporaError
|
|
45
|
+
|
|
46
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
result = client.ask("Best cover crop for clay soil?")
|
|
50
|
+
except SporaError as e:
|
|
51
|
+
print(e.status, e.error)
|
|
52
|
+
|
|
53
|
+
print(client.rate_limit.remaining, "/", client.rate_limit.limit)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Links
|
|
57
|
+
|
|
58
|
+
- [Documentation](https://spora.engineer/docs/sdk)
|
|
59
|
+
- [API Reference](https://spora.engineer/docs)
|
|
60
|
+
- [Get an API key](https://spora.engineer/request)
|
spora_sdk-2.0.0/setup.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from setuptools import setup
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="spora-sdk",
|
|
5
|
+
version="2.0.0",
|
|
6
|
+
description="Official Python SDK for the Spora agronomic intelligence API",
|
|
7
|
+
long_description=open("README.md").read() if __import__("os").path.exists("README.md") else "",
|
|
8
|
+
long_description_content_type="text/markdown",
|
|
9
|
+
py_modules=["spora"],
|
|
10
|
+
python_requires=">=3.9",
|
|
11
|
+
install_requires=["requests>=2.28.0"],
|
|
12
|
+
author="Spora",
|
|
13
|
+
author_email="hello.spora@yahoo.com",
|
|
14
|
+
url="https://spora.engineer",
|
|
15
|
+
project_urls={"Documentation": "https://spora.engineer/docs"},
|
|
16
|
+
classifiers=[
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Topic :: Scientific/Engineering",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
],
|
|
22
|
+
keywords=["agronomy", "agriculture", "api", "plants", "crops"],
|
|
23
|
+
)
|
spora_sdk-2.0.0/spora.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Spora Python SDK — official client for the Spora agronomic intelligence API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SporaError(Exception):
|
|
10
|
+
def __init__(self, status: int, error: str):
|
|
11
|
+
self.status = status
|
|
12
|
+
self.error = error
|
|
13
|
+
super().__init__(f"[{status}] {error}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RateLimit:
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.limit: int = 0
|
|
19
|
+
self.remaining: int = 0
|
|
20
|
+
self.reset: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Spora:
|
|
24
|
+
"""
|
|
25
|
+
Spora API client.
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
from spora import Spora
|
|
29
|
+
client = Spora(api_key="spk_live_...")
|
|
30
|
+
|
|
31
|
+
# Free endpoints (no paid plan required)
|
|
32
|
+
species = client.search_species("Rosa", limit=5)
|
|
33
|
+
crop = client.get_crop("solanum_lycopersicum")
|
|
34
|
+
|
|
35
|
+
# Paid endpoints
|
|
36
|
+
result = client.ask("When should I irrigate my tomatoes in Sicily?")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
BASE_URL = "https://api.spora.engineer"
|
|
40
|
+
|
|
41
|
+
def __init__(self, api_key: str, base_url: str = BASE_URL, max_retries: int = 2):
|
|
42
|
+
self.api_key = api_key
|
|
43
|
+
self.base_url = base_url.rstrip("/")
|
|
44
|
+
self.max_retries = max_retries
|
|
45
|
+
self.rate_limit = RateLimit()
|
|
46
|
+
self._session = requests.Session()
|
|
47
|
+
self._session.headers.update({
|
|
48
|
+
"X-Api-Key": api_key,
|
|
49
|
+
"Content-Type": "application/json",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
# ── Internal ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def _post(self, path: str, body: dict) -> Any:
|
|
55
|
+
url = f"{self.base_url}{path}"
|
|
56
|
+
for attempt in range(self.max_retries + 1):
|
|
57
|
+
r = self._session.post(url, json=body)
|
|
58
|
+
self._update_rate_limit(r)
|
|
59
|
+
if r.status_code == 429:
|
|
60
|
+
retry_after = int(r.headers.get("Retry-After", 60))
|
|
61
|
+
if attempt < self.max_retries:
|
|
62
|
+
time.sleep(retry_after)
|
|
63
|
+
continue
|
|
64
|
+
raise SporaError(429, "rate_limit_exceeded")
|
|
65
|
+
if r.status_code >= 400:
|
|
66
|
+
raise SporaError(r.status_code, r.json().get("error", "unknown_error"))
|
|
67
|
+
return r.json()
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
def _get(self, path: str, params: Optional[dict] = None) -> Any:
|
|
71
|
+
url = f"{self.base_url}{path}"
|
|
72
|
+
r = self._session.get(url, params={k: v for k, v in (params or {}).items() if v is not None})
|
|
73
|
+
self._update_rate_limit(r)
|
|
74
|
+
if r.status_code >= 400:
|
|
75
|
+
raise SporaError(r.status_code, r.json().get("error", "unknown_error"))
|
|
76
|
+
return r.json()
|
|
77
|
+
|
|
78
|
+
def _update_rate_limit(self, r: requests.Response):
|
|
79
|
+
self.rate_limit.limit = int(r.headers.get("X-RateLimit-Limit", 0))
|
|
80
|
+
self.rate_limit.remaining = int(r.headers.get("X-RateLimit-Remaining", 0))
|
|
81
|
+
self.rate_limit.reset = int(r.headers.get("X-RateLimit-Reset", 0))
|
|
82
|
+
|
|
83
|
+
# ── Free: Species ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def search_species(self, q: str, *, limit: int = 10, fields: Optional[list[str]] = None) -> list:
|
|
86
|
+
return self._get("/species", {"q": q, "limit": limit, "fields": ",".join(fields) if fields else None})
|
|
87
|
+
|
|
88
|
+
def get_species(self, id: str, *, fields: Optional[list[str]] = None) -> dict:
|
|
89
|
+
return self._get(f"/species/{id}", {"fields": ",".join(fields) if fields else None})
|
|
90
|
+
|
|
91
|
+
def get_species_by_family(self, family: str, *, limit: int = 20) -> list:
|
|
92
|
+
return self._get("/species", {"family": family, "limit": limit})
|
|
93
|
+
|
|
94
|
+
# ── Free: Crops ───────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def search_crops(self, q: Optional[str] = None, *, category: Optional[str] = None,
|
|
97
|
+
limit: int = 10, fields: Optional[list[str]] = None) -> list:
|
|
98
|
+
return self._get("/crops", {"q": q, "category": category, "limit": limit,
|
|
99
|
+
"fields": ",".join(fields) if fields else None})
|
|
100
|
+
|
|
101
|
+
def get_crop(self, id: str, *, fields: Optional[list[str]] = None) -> dict:
|
|
102
|
+
return self._get(f"/crops/{id}", {"fields": ",".join(fields) if fields else None})
|
|
103
|
+
|
|
104
|
+
# ── Free: Flowers ─────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def search_flowers(self, q: Optional[str] = None, *, month: Optional[int] = None,
|
|
107
|
+
type: Optional[str] = None, pollinator: Optional[str] = None,
|
|
108
|
+
limit: int = 30, fields: Optional[list[str]] = None) -> list:
|
|
109
|
+
return self._get("/flowers", {"q": q, "month": month, "type": type,
|
|
110
|
+
"pollinator": pollinator, "limit": limit,
|
|
111
|
+
"fields": ",".join(fields) if fields else None})
|
|
112
|
+
|
|
113
|
+
def get_flower(self, id: str) -> dict:
|
|
114
|
+
return self._get(f"/flowers/{id}")
|
|
115
|
+
|
|
116
|
+
# ── Free: Shrooms ─────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def search_shrooms(self, q: Optional[str] = None, *, edibility: Optional[str] = None,
|
|
119
|
+
habitat: Optional[str] = None, season: Optional[str] = None,
|
|
120
|
+
limit: int = 20, fields: Optional[list[str]] = None) -> list:
|
|
121
|
+
return self._get("/shrooms", {"q": q, "edibility": edibility, "habitat": habitat,
|
|
122
|
+
"season": season, "limit": limit,
|
|
123
|
+
"fields": ",".join(fields) if fields else None})
|
|
124
|
+
|
|
125
|
+
def get_shroom(self, id: str) -> dict:
|
|
126
|
+
return self._get(f"/shrooms/{id}")
|
|
127
|
+
|
|
128
|
+
# ── Paid: Unified plant search ────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def search_plants(self, q: str, *, limit: int = 10, fields: Optional[list[str]] = None) -> list:
|
|
131
|
+
return self._get("/plants", {"q": q, "limit": limit,
|
|
132
|
+
"fields": ",".join(fields) if fields else None})
|
|
133
|
+
|
|
134
|
+
def get_plant(self, id: str, *, fields: Optional[list[str]] = None) -> dict:
|
|
135
|
+
return self._get(f"/plants/{id}", {"fields": ",".join(fields) if fields else None})
|
|
136
|
+
|
|
137
|
+
# ── Paid: Diseases & Pests ────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def search_diseases(self, q: str, *, limit: int = 10, fields: Optional[list[str]] = None) -> list:
|
|
140
|
+
return self._get("/diseases", {"q": q, "limit": limit,
|
|
141
|
+
"fields": ",".join(fields) if fields else None})
|
|
142
|
+
|
|
143
|
+
def get_disease(self, id: str) -> dict:
|
|
144
|
+
return self._get(f"/diseases/{id}")
|
|
145
|
+
|
|
146
|
+
def search_pests(self, q: str, *, limit: int = 10) -> list:
|
|
147
|
+
return self._get("/pests", {"q": q, "limit": limit})
|
|
148
|
+
|
|
149
|
+
# ── Paid: Companions ──────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
def search_companions(self, q: Optional[str] = None, *, utility: Optional[str] = None,
|
|
152
|
+
layer: Optional[str] = None, nitrogen_fixing: Optional[bool] = None,
|
|
153
|
+
limit: int = 10, fields: Optional[list[str]] = None) -> list:
|
|
154
|
+
return self._get("/companions", {"q": q, "utility": utility, "layer": layer,
|
|
155
|
+
"nitrogen_fixing": str(nitrogen_fixing).lower() if nitrogen_fixing is not None else None,
|
|
156
|
+
"limit": limit, "fields": ",".join(fields) if fields else None})
|
|
157
|
+
|
|
158
|
+
# ── Paid: Agronomic data ──────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def get_nutrition(self, country: str) -> dict:
|
|
161
|
+
return self._get(f"/nutrition/{country}")
|
|
162
|
+
|
|
163
|
+
def get_irrigation(self, country: str) -> dict:
|
|
164
|
+
return self._get(f"/irrigation/{country}")
|
|
165
|
+
|
|
166
|
+
def get_harvest(self, location: str) -> dict:
|
|
167
|
+
return self._get(f"/harvest/{location}")
|
|
168
|
+
|
|
169
|
+
def get_climate_zone(self, *, lat: Optional[float] = None, lon: Optional[float] = None,
|
|
170
|
+
country: Optional[str] = None) -> dict:
|
|
171
|
+
return self._get("/climate_zones", {"lat": lat, "lon": lon, "country": country})
|
|
172
|
+
|
|
173
|
+
# ── Paid: Weather ─────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
def weather(self, lat: float, lon: float, *, days: int = 7, hourly: bool = False) -> dict:
|
|
176
|
+
return self._post("/weather", {"lat": lat, "lon": lon, "days": days, "hourly": hourly})
|
|
177
|
+
|
|
178
|
+
def weather_history(self, lat: float, lon: float, start_date: str, end_date: str) -> dict:
|
|
179
|
+
return self._post("/weather_history", {"lat": lat, "lon": lon,
|
|
180
|
+
"start_date": start_date, "end_date": end_date})
|
|
181
|
+
|
|
182
|
+
def should_water(self, lat: float, lon: float, crop: str, *,
|
|
183
|
+
soil_moisture_pct: Optional[float] = None) -> dict:
|
|
184
|
+
body: dict = {"lat": lat, "lon": lon, "crop": crop}
|
|
185
|
+
if soil_moisture_pct is not None:
|
|
186
|
+
body["soil_moisture_pct"] = soil_moisture_pct
|
|
187
|
+
return self._post("/should_water", body)
|
|
188
|
+
|
|
189
|
+
# ── Paid: AI endpoints ────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def ask(self, question: str, *, crop: Optional[str] = None,
|
|
192
|
+
lat: Optional[float] = None, lon: Optional[float] = None,
|
|
193
|
+
image_base64: Optional[str] = None,
|
|
194
|
+
tone: str = "expanded") -> dict:
|
|
195
|
+
body: dict = {"question": question, "tone": tone}
|
|
196
|
+
if crop: body["crop"] = crop
|
|
197
|
+
if lat is not None: body["lat"] = lat
|
|
198
|
+
if lon is not None: body["lon"] = lon
|
|
199
|
+
if image_base64: body["image_base64"] = image_base64
|
|
200
|
+
return self._post("/ask", body)
|
|
201
|
+
|
|
202
|
+
def identify(self, image_base64: str, *, context: Optional[str] = None) -> dict:
|
|
203
|
+
body: dict = {"image_base64": image_base64}
|
|
204
|
+
if context: body["context"] = context
|
|
205
|
+
return self._post("/identify", body)
|
|
206
|
+
|
|
207
|
+
def vision_ask(self, image_base64: str, question: str, *, context: Optional[str] = None) -> dict:
|
|
208
|
+
body: dict = {"image_base64": image_base64, "question": question}
|
|
209
|
+
if context: body["context"] = context
|
|
210
|
+
return self._post("/vision/ask", body)
|
|
211
|
+
|
|
212
|
+
def diagnose(self, *, image_base64: Optional[str] = None, crop: Optional[str] = None,
|
|
213
|
+
symptoms: Optional[str] = None, lat: Optional[float] = None,
|
|
214
|
+
lon: Optional[float] = None) -> dict:
|
|
215
|
+
body = {k: v for k, v in {"image_base64": image_base64, "crop": crop,
|
|
216
|
+
"symptoms": symptoms, "lat": lat, "lon": lon}.items() if v is not None}
|
|
217
|
+
return self._post("/diagnose", body)
|
|
218
|
+
|
|
219
|
+
def recommend_crops(self, lat: float, lon: float, *, soil_type: Optional[str] = None,
|
|
220
|
+
water_availability: Optional[str] = None,
|
|
221
|
+
cycle_days_max: Optional[int] = None,
|
|
222
|
+
category: Optional[str] = None, tone: str = "expanded") -> dict:
|
|
223
|
+
body: dict = {"lat": lat, "lon": lon, "tone": tone}
|
|
224
|
+
if soil_type: body["soil_type"] = soil_type
|
|
225
|
+
if water_availability: body["water_availability"] = water_availability
|
|
226
|
+
if cycle_days_max: body["cycle_days_max"] = cycle_days_max
|
|
227
|
+
if category: body["category"] = category
|
|
228
|
+
return self._post("/recommend_crops", body)
|
|
229
|
+
|
|
230
|
+
def plan_companion(self, crop_id: str, *,
|
|
231
|
+
goals: Optional[list[str]] = None, tone: str = "expanded") -> dict:
|
|
232
|
+
body: dict = {"crop_id": crop_id, "tone": tone}
|
|
233
|
+
if goals: body["goals"] = goals
|
|
234
|
+
return self._post("/plan_companion", body)
|
|
235
|
+
|
|
236
|
+
def scouting_report(self, lat: float, lon: float, crop: str, *,
|
|
237
|
+
symptoms: Optional[str] = None, tone: str = "expanded") -> dict:
|
|
238
|
+
body: dict = {"lat": lat, "lon": lon, "crop": crop, "tone": tone}
|
|
239
|
+
if symptoms: body["symptoms"] = symptoms
|
|
240
|
+
return self._post("/scouting_report", body)
|
|
241
|
+
|
|
242
|
+
# ── Paid: Watch Rules ─────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
def create_watch_rule(self, name: str, condition: dict, webhook_url: str,
|
|
245
|
+
webhook_secret: str, *, description: Optional[str] = None,
|
|
246
|
+
trigger_mode: str = "scheduled", lat: Optional[float] = None,
|
|
247
|
+
lon: Optional[float] = None, crop: Optional[str] = None,
|
|
248
|
+
eval_interval_minutes: int = 15,
|
|
249
|
+
cooldown_minutes: int = 60) -> dict:
|
|
250
|
+
body: dict = {"name": name, "condition": condition, "webhook_url": webhook_url,
|
|
251
|
+
"webhook_secret": webhook_secret, "trigger_mode": trigger_mode,
|
|
252
|
+
"eval_interval_minutes": eval_interval_minutes,
|
|
253
|
+
"cooldown_minutes": cooldown_minutes}
|
|
254
|
+
if description: body["description"] = description
|
|
255
|
+
if lat is not None: body["lat"] = lat
|
|
256
|
+
if lon is not None: body["lon"] = lon
|
|
257
|
+
if crop: body["crop"] = crop
|
|
258
|
+
return self._post("/watch", body)
|
|
259
|
+
|
|
260
|
+
def list_watch_rules(self) -> list:
|
|
261
|
+
return self._get("/watch")
|
|
262
|
+
|
|
263
|
+
def push_event(self, rule_id: str, payload: dict) -> dict:
|
|
264
|
+
return self._post(f"/watch/ingest/{rule_id}", payload)
|
|
265
|
+
|
|
266
|
+
# ── Paid: Approvals ───────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def request_approval(self, action: str, description: str, *,
|
|
269
|
+
risk_level: str = "high", payload: Optional[dict] = None,
|
|
270
|
+
callback_url: Optional[str] = None,
|
|
271
|
+
ttl_minutes: int = 30) -> dict:
|
|
272
|
+
body: dict = {"action": action, "description": description,
|
|
273
|
+
"risk_level": risk_level, "ttl_minutes": ttl_minutes}
|
|
274
|
+
if payload: body["payload"] = payload
|
|
275
|
+
if callback_url: body["callback_url"] = callback_url
|
|
276
|
+
return self._post("/approvals", body)
|
|
277
|
+
|
|
278
|
+
def get_approval(self, approval_id: str) -> dict:
|
|
279
|
+
return self._get(f"/approvals/{approval_id}")
|
|
280
|
+
|
|
281
|
+
# ── Batch ─────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
def batch(self, calls: list[tuple[str, dict]]) -> dict:
|
|
284
|
+
return self._post("/batch", {"calls": [{"method": m, "params": p} for m, p in calls]})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spora-sdk
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Official Python SDK for the Spora agronomic intelligence API
|
|
5
|
+
Home-page: https://spora.engineer
|
|
6
|
+
Author: Spora
|
|
7
|
+
Author-email: hello.spora@yahoo.com
|
|
8
|
+
Project-URL: Documentation, https://spora.engineer/docs
|
|
9
|
+
Keywords: agronomy,agriculture,api,plants,crops
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: requests>=2.28.0
|
|
17
|
+
Dynamic: author
|
|
18
|
+
Dynamic: author-email
|
|
19
|
+
Dynamic: classifier
|
|
20
|
+
Dynamic: description
|
|
21
|
+
Dynamic: description-content-type
|
|
22
|
+
Dynamic: home-page
|
|
23
|
+
Dynamic: keywords
|
|
24
|
+
Dynamic: project-url
|
|
25
|
+
Dynamic: requires-dist
|
|
26
|
+
Dynamic: requires-python
|
|
27
|
+
Dynamic: summary
|
|
28
|
+
|
|
29
|
+
# Spora Python SDK
|
|
30
|
+
|
|
31
|
+
Official Python client for the [Spora agronomic intelligence API](https://spora.engineer).
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install spora-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from spora import Spora
|
|
43
|
+
|
|
44
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
45
|
+
|
|
46
|
+
# Search species
|
|
47
|
+
species = client.search_species("Rosa", limit=5)
|
|
48
|
+
|
|
49
|
+
# Get crop requirements
|
|
50
|
+
crop = client.get_crop("solanum_lycopersicum")
|
|
51
|
+
print(crop["temp_optimal_min"], crop["temp_optimal_max"])
|
|
52
|
+
|
|
53
|
+
# AI question (paid key)
|
|
54
|
+
answer = client.ask(
|
|
55
|
+
"When should I irrigate my tomatoes?",
|
|
56
|
+
crop="solanum_lycopersicum",
|
|
57
|
+
lat=45.4, lon=11.0,
|
|
58
|
+
tone="concise",
|
|
59
|
+
)
|
|
60
|
+
print(answer["response"])
|
|
61
|
+
|
|
62
|
+
# Batch
|
|
63
|
+
results = client.batch([
|
|
64
|
+
("weather", {"lat": 45.4, "lon": 11.0, "days": 3}),
|
|
65
|
+
("should_water", {"lat": 45.4, "lon": 11.0, "crop": "vitis_vinifera"}),
|
|
66
|
+
])
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Error handling
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from spora import Spora, SporaError
|
|
73
|
+
|
|
74
|
+
client = Spora(api_key="spk_live_your_key_here")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = client.ask("Best cover crop for clay soil?")
|
|
78
|
+
except SporaError as e:
|
|
79
|
+
print(e.status, e.error)
|
|
80
|
+
|
|
81
|
+
print(client.rate_limit.remaining, "/", client.rate_limit.limit)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Links
|
|
85
|
+
|
|
86
|
+
- [Documentation](https://spora.engineer/docs/sdk)
|
|
87
|
+
- [API Reference](https://spora.engineer/docs)
|
|
88
|
+
- [Get an API key](https://spora.engineer/request)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
spora
|