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.
@@ -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)
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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,8 @@
1
+ README.md
2
+ setup.py
3
+ spora.py
4
+ spora_sdk.egg-info/PKG-INFO
5
+ spora_sdk.egg-info/SOURCES.txt
6
+ spora_sdk.egg-info/dependency_links.txt
7
+ spora_sdk.egg-info/requires.txt
8
+ spora_sdk.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ requests>=2.28.0
@@ -0,0 +1 @@
1
+ spora