muriel 0.2.0__py3-none-any.whl
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.
- muriel/__init__.py +75 -0
- muriel/agent.py +355 -0
- muriel/client.py +164 -0
- muriel/clients.py +94 -0
- muriel/config.py +46 -0
- muriel/endpoints/__init__.py +1 -0
- muriel/endpoints/contaminants.py +437 -0
- muriel/endpoints/facilities.py +82 -0
- muriel/endpoints/requirements.py +39 -0
- muriel/endpoints/results.py +142 -0
- muriel/endpoints/sampling_points.py +86 -0
- muriel/endpoints/systems.py +34 -0
- muriel/endpoints/violations.py +45 -0
- muriel/exceptions.py +65 -0
- muriel/models.py +133 -0
- muriel/py.typed +0 -0
- muriel/utils.py +111 -0
- muriel-0.2.0.dist-info/METADATA +341 -0
- muriel-0.2.0.dist-info/RECORD +22 -0
- muriel-0.2.0.dist-info/WHEEL +5 -0
- muriel-0.2.0.dist-info/licenses/LICENSE +21 -0
- muriel-0.2.0.dist-info/top_level.txt +1 -0
muriel/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Muriel Python SDK - Official SDK for the Muriel® Water Quality API."""
|
|
2
|
+
|
|
3
|
+
from .client import MurielClient
|
|
4
|
+
from .agent import Agent, query
|
|
5
|
+
from .clients import RequirementsClient, SamplesClient, SystemsClient
|
|
6
|
+
from .endpoints.results import get_results
|
|
7
|
+
from .endpoints.facilities import (
|
|
8
|
+
get_state_facilities,
|
|
9
|
+
get_facility_geocoding,
|
|
10
|
+
)
|
|
11
|
+
from .endpoints.sampling_points import (
|
|
12
|
+
get_state_sampling_points,
|
|
13
|
+
get_sampling_point_geocoding,
|
|
14
|
+
)
|
|
15
|
+
from .endpoints.systems import get_systems
|
|
16
|
+
from .endpoints.requirements import get_requirements
|
|
17
|
+
from .endpoints.violations import get_violations
|
|
18
|
+
from .endpoints.contaminants import (
|
|
19
|
+
get_latest,
|
|
20
|
+
get_lead,
|
|
21
|
+
get_pfas,
|
|
22
|
+
get_coliform,
|
|
23
|
+
get_nitrate,
|
|
24
|
+
get_dbps,
|
|
25
|
+
get_arsenic,
|
|
26
|
+
get_manganese,
|
|
27
|
+
get_vocs,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
MurielAPIError,
|
|
31
|
+
MurielAuthenticationError,
|
|
32
|
+
MurielValidationError,
|
|
33
|
+
MurielNotFoundError,
|
|
34
|
+
MurielRateLimitError,
|
|
35
|
+
MurielServerError,
|
|
36
|
+
MurielCreditsError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.2.0"
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Client
|
|
42
|
+
"MurielClient",
|
|
43
|
+
"Agent",
|
|
44
|
+
"query",
|
|
45
|
+
"RequirementsClient",
|
|
46
|
+
"SystemsClient",
|
|
47
|
+
"SamplesClient",
|
|
48
|
+
# Functions
|
|
49
|
+
"get_results",
|
|
50
|
+
"get_requirements",
|
|
51
|
+
"get_violations",
|
|
52
|
+
"get_systems",
|
|
53
|
+
"get_state_facilities",
|
|
54
|
+
"get_facility_geocoding",
|
|
55
|
+
"get_state_sampling_points",
|
|
56
|
+
"get_sampling_point_geocoding",
|
|
57
|
+
# Contaminant convenience functions
|
|
58
|
+
"get_latest",
|
|
59
|
+
"get_lead",
|
|
60
|
+
"get_pfas",
|
|
61
|
+
"get_coliform",
|
|
62
|
+
"get_nitrate",
|
|
63
|
+
"get_dbps",
|
|
64
|
+
"get_arsenic",
|
|
65
|
+
"get_manganese",
|
|
66
|
+
"get_vocs",
|
|
67
|
+
# Exceptions
|
|
68
|
+
"MurielAPIError",
|
|
69
|
+
"MurielAuthenticationError",
|
|
70
|
+
"MurielValidationError",
|
|
71
|
+
"MurielNotFoundError",
|
|
72
|
+
"MurielRateLimitError",
|
|
73
|
+
"MurielServerError",
|
|
74
|
+
"MurielCreditsError",
|
|
75
|
+
]
|
muriel/agent.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent Mode for natural language water quality queries.
|
|
3
|
+
|
|
4
|
+
Calls POST /api/v1/agent/query on the Muriel API. All LLM reasoning
|
|
5
|
+
happens server-side — users only need a Muriel API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from typing import Any, Dict, List, Optional, Sequence, Type, TypeVar, Union
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from .config import DEFAULT_TIMEOUT, checked_base_url, get_api_key, get_base_url
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
MurielAPIError,
|
|
18
|
+
MurielAuthenticationError,
|
|
19
|
+
MurielRateLimitError,
|
|
20
|
+
MurielServerError,
|
|
21
|
+
MurielValidationError,
|
|
22
|
+
)
|
|
23
|
+
from .models import AgentQueryResponse
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T", bound=BaseModel)
|
|
26
|
+
|
|
27
|
+
# What `schema=` accepts: a Pydantic model, a list of field names, or a JSON
|
|
28
|
+
# Schema dict. The API only ever sees a JSON Schema dict.
|
|
29
|
+
SchemaArg = Union[Type[BaseModel], Sequence[str], Dict[str, Any]]
|
|
30
|
+
|
|
31
|
+
# Nine characters: a state code (or two digits for EPA-region tribal systems),
|
|
32
|
+
# then seven characters that are nearly always digits.
|
|
33
|
+
_PWSID_RE = re.compile(r"^(?:[A-Z]{2}|\d{2})(?=[A-Z0-9]*\d)[A-Z0-9]{7}$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _fields_schema(fields: Sequence[str]) -> Dict[str, Any]:
|
|
37
|
+
"""JSON Schema for an object with exactly these fields, all required."""
|
|
38
|
+
names = [str(f).strip() for f in fields]
|
|
39
|
+
if not names or any(not n for n in names):
|
|
40
|
+
raise MurielValidationError("schema= needs at least one non-empty field name.")
|
|
41
|
+
if len(set(names)) != len(names):
|
|
42
|
+
raise MurielValidationError("schema= lists the same field twice.")
|
|
43
|
+
return {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {n: {} for n in names},
|
|
46
|
+
"required": names,
|
|
47
|
+
"additionalProperties": False,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _normalise_since(since: Union[str, date, datetime, None]) -> Optional[str]:
|
|
52
|
+
"""ISO date string, or None. Accepts a date, a datetime, or 'YYYY-MM-DD'."""
|
|
53
|
+
if since is None:
|
|
54
|
+
return None
|
|
55
|
+
if isinstance(since, datetime):
|
|
56
|
+
return since.date().isoformat()
|
|
57
|
+
if isinstance(since, date):
|
|
58
|
+
return since.isoformat()
|
|
59
|
+
text = str(since).strip()
|
|
60
|
+
try:
|
|
61
|
+
return date.fromisoformat(text[:10]).isoformat()
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise MurielValidationError(
|
|
64
|
+
f"since= must be a date or 'YYYY-MM-DD', got {since!r}."
|
|
65
|
+
) from None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Agent:
|
|
69
|
+
"""
|
|
70
|
+
Natural language interface to the Muriel water quality database.
|
|
71
|
+
|
|
72
|
+
Sends queries to the Muriel API's server-side agent endpoint.
|
|
73
|
+
Only a Muriel API key is required — no separate LLM key needed.
|
|
74
|
+
|
|
75
|
+
Usage::
|
|
76
|
+
|
|
77
|
+
from muriel import Agent
|
|
78
|
+
|
|
79
|
+
agent = Agent() # reads MURIEL_API_KEY from environment
|
|
80
|
+
response = agent.query("Show me PFAS levels in Illinois")
|
|
81
|
+
print(response.summary)
|
|
82
|
+
print(response.results)
|
|
83
|
+
|
|
84
|
+
Pass a Pydantic model as ``schema`` to receive a typed, validated object::
|
|
85
|
+
|
|
86
|
+
from pydantic import BaseModel
|
|
87
|
+
from muriel import Agent
|
|
88
|
+
|
|
89
|
+
class PFASResult(BaseModel):
|
|
90
|
+
pwsid: str
|
|
91
|
+
analyte: str
|
|
92
|
+
concentration: float
|
|
93
|
+
|
|
94
|
+
agent = Agent()
|
|
95
|
+
result = agent.query("PFAS in IL", schema=PFASResult)
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
api_key: Optional[str] = None,
|
|
101
|
+
base_url: Optional[str] = None,
|
|
102
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
103
|
+
):
|
|
104
|
+
"""
|
|
105
|
+
Initialise the Agent.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
api_key: Muriel API key. Defaults to the ``MURIEL_API_KEY``
|
|
109
|
+
environment variable.
|
|
110
|
+
base_url: Override the API base URL (default: https://api.murielai.io).
|
|
111
|
+
timeout: HTTP timeout in seconds (default: 30). Agent queries
|
|
112
|
+
can take several seconds — increase if you see timeouts.
|
|
113
|
+
"""
|
|
114
|
+
self._api_key = api_key or get_api_key()
|
|
115
|
+
if not self._api_key:
|
|
116
|
+
raise MurielAuthenticationError(
|
|
117
|
+
"API key is required. Pass it as api_key= or set the "
|
|
118
|
+
"MURIEL_API_KEY environment variable."
|
|
119
|
+
)
|
|
120
|
+
self._base_url = checked_base_url(base_url or get_base_url())
|
|
121
|
+
self._timeout = timeout
|
|
122
|
+
self._session = requests.Session()
|
|
123
|
+
self._session.headers.update({
|
|
124
|
+
"X-API-Key": self._api_key,
|
|
125
|
+
"Content-Type": "application/json",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
# ── Public interface ───────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def query(
|
|
131
|
+
self,
|
|
132
|
+
prompt: str,
|
|
133
|
+
*,
|
|
134
|
+
pwsid: Optional[str] = None,
|
|
135
|
+
since: Union[str, date, datetime, None] = None,
|
|
136
|
+
schema: Optional[SchemaArg] = None,
|
|
137
|
+
max_records: int = 1000,
|
|
138
|
+
) -> Union[AgentQueryResponse, Dict[str, Any], T]:
|
|
139
|
+
"""
|
|
140
|
+
Query water quality data using natural language.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
prompt: Plain-English description of the data you want.
|
|
144
|
+
pwsid: Optional Public Water System ID (e.g. ``"IL0316000"``).
|
|
145
|
+
Pins the question to one system, so the agent does
|
|
146
|
+
not have to work out which system a name means.
|
|
147
|
+
since: Optional date (``date``, ``datetime`` or
|
|
148
|
+
``"YYYY-MM-DD"``). Only results and events on or
|
|
149
|
+
after this date -- what an app polling for new
|
|
150
|
+
activity needs.
|
|
151
|
+
schema: Optional shape for the answer. One of:
|
|
152
|
+
|
|
153
|
+
* a list of field names, e.g.
|
|
154
|
+
``["event_type", "num_events", "pwsid", "insight"]``
|
|
155
|
+
-- returns a plain ``dict`` with exactly those keys;
|
|
156
|
+
* a Pydantic model -- returns an instance of it;
|
|
157
|
+
* a JSON Schema ``dict`` -- returns a plain ``dict``.
|
|
158
|
+
max_records: Cap on the number of records returned (default 1000,
|
|
159
|
+
max 10000).
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
``AgentQueryResponse`` with ``results``, ``count``, ``summary``,
|
|
163
|
+
``execution_log``, and an optional ``warning`` when no schema is
|
|
164
|
+
given. With a schema, the shaped answer: a ``dict`` for a field
|
|
165
|
+
list or JSON Schema, a model instance for a Pydantic model.
|
|
166
|
+
|
|
167
|
+
Example::
|
|
168
|
+
|
|
169
|
+
import muriel
|
|
170
|
+
|
|
171
|
+
muriel.query(
|
|
172
|
+
"Get the latest events for Chicago Water",
|
|
173
|
+
api_key="YOUR_MURIEL_API_KEY",
|
|
174
|
+
since="2026-09-29",
|
|
175
|
+
schema=["event_type", "num_events", "pwsid", "insight"],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
Raises:
|
|
179
|
+
MurielAuthenticationError: Invalid or missing API key.
|
|
180
|
+
MurielRateLimitError: Credit balance exhausted.
|
|
181
|
+
MurielServerError: Server-side error.
|
|
182
|
+
MurielAPIError: Any other failure.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
185
|
+
raise MurielValidationError("prompt must be a non-empty string.")
|
|
186
|
+
|
|
187
|
+
payload: Dict[str, Any] = {
|
|
188
|
+
"prompt": prompt,
|
|
189
|
+
"max_records": max_records,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if pwsid is not None:
|
|
193
|
+
pwsid = str(pwsid).strip().upper()
|
|
194
|
+
if not _PWSID_RE.match(pwsid):
|
|
195
|
+
raise MurielValidationError(
|
|
196
|
+
f"pwsid= should look like 'IL0316000' (state code, then the "
|
|
197
|
+
f"system number), got {pwsid!r}."
|
|
198
|
+
)
|
|
199
|
+
payload["pwsid"] = pwsid
|
|
200
|
+
since_iso = _normalise_since(since)
|
|
201
|
+
if since_iso is not None:
|
|
202
|
+
payload["since"] = since_iso
|
|
203
|
+
|
|
204
|
+
model: Optional[Type[BaseModel]] = None
|
|
205
|
+
fields: Optional[List[str]] = None
|
|
206
|
+
if schema is not None:
|
|
207
|
+
if isinstance(schema, type) and issubclass(schema, BaseModel):
|
|
208
|
+
model = schema
|
|
209
|
+
payload["schema"] = schema.model_json_schema()
|
|
210
|
+
elif isinstance(schema, dict):
|
|
211
|
+
payload["schema"] = schema
|
|
212
|
+
elif isinstance(schema, (list, tuple)):
|
|
213
|
+
payload["schema"] = _fields_schema(schema)
|
|
214
|
+
fields = list(payload["schema"]["required"])
|
|
215
|
+
else:
|
|
216
|
+
raise MurielValidationError(
|
|
217
|
+
"schema= must be a list of field names, a Pydantic model, "
|
|
218
|
+
"or a JSON Schema dict."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
data = self._post("/api/v1/agent/query", payload)
|
|
222
|
+
|
|
223
|
+
if schema is not None:
|
|
224
|
+
# The API has already shaped the results; take the first shaped
|
|
225
|
+
# object, as the Pydantic path always has.
|
|
226
|
+
results = data.get("results", [])
|
|
227
|
+
first = results[0] if isinstance(results, list) and results else results
|
|
228
|
+
if model is not None:
|
|
229
|
+
return model.model_validate(first)
|
|
230
|
+
first = dict(first or {})
|
|
231
|
+
if fields is not None:
|
|
232
|
+
missing = [f for f in fields if f not in first]
|
|
233
|
+
if missing:
|
|
234
|
+
raise MurielAPIError(
|
|
235
|
+
f"The answer is missing requested field(s): {', '.join(missing)}."
|
|
236
|
+
)
|
|
237
|
+
return {f: first[f] for f in fields}
|
|
238
|
+
return first
|
|
239
|
+
|
|
240
|
+
return AgentQueryResponse(
|
|
241
|
+
results=data.get("results", []),
|
|
242
|
+
count=data.get("count", 0),
|
|
243
|
+
summary=data.get("summary", ""),
|
|
244
|
+
execution_log=data.get("execution_log", []),
|
|
245
|
+
warning=data.get("warning"),
|
|
246
|
+
documents=data.get("documents") or [],
|
|
247
|
+
credits=data.get("credits"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
"""Close the underlying HTTP session."""
|
|
252
|
+
self._session.close()
|
|
253
|
+
|
|
254
|
+
def __enter__(self) -> "Agent":
|
|
255
|
+
return self
|
|
256
|
+
|
|
257
|
+
def __exit__(self, *_: Any) -> None:
|
|
258
|
+
self.close()
|
|
259
|
+
|
|
260
|
+
# ── Internal ───────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
def _post(self, endpoint: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
263
|
+
url = f"{self._base_url}{endpoint}"
|
|
264
|
+
try:
|
|
265
|
+
# No redirects: the key travels in X-API-Key, which requests would
|
|
266
|
+
# forward to whatever host a redirect names.
|
|
267
|
+
response = self._session.post(url, json=payload, timeout=self._timeout,
|
|
268
|
+
allow_redirects=False)
|
|
269
|
+
except requests.exceptions.Timeout:
|
|
270
|
+
raise MurielAPIError(
|
|
271
|
+
f"Agent query timed out after {self._timeout}s. "
|
|
272
|
+
"Try increasing timeout= on the Agent constructor."
|
|
273
|
+
)
|
|
274
|
+
except requests.exceptions.ConnectionError:
|
|
275
|
+
raise MurielAPIError(f"Failed to connect to {self._base_url}")
|
|
276
|
+
except requests.exceptions.RequestException as e:
|
|
277
|
+
raise MurielAPIError(f"Request failed: {e}")
|
|
278
|
+
|
|
279
|
+
if 300 <= response.status_code < 400:
|
|
280
|
+
raise MurielAPIError(
|
|
281
|
+
f"Unexpected redirect ({response.status_code}); not followed, so your "
|
|
282
|
+
"API key was not sent elsewhere. Check MURIEL_BASE_URL.",
|
|
283
|
+
status_code=response.status_code,
|
|
284
|
+
)
|
|
285
|
+
if response.status_code == 200:
|
|
286
|
+
return response.json()
|
|
287
|
+
elif response.status_code == 401:
|
|
288
|
+
raise MurielAuthenticationError(
|
|
289
|
+
"Invalid API key.",
|
|
290
|
+
status_code=401,
|
|
291
|
+
response=response.json() if response.content else None,
|
|
292
|
+
)
|
|
293
|
+
elif response.status_code == 400:
|
|
294
|
+
error = response.json() if response.content else {}
|
|
295
|
+
raise MurielValidationError(
|
|
296
|
+
error.get("detail", "Validation error"),
|
|
297
|
+
status_code=400,
|
|
298
|
+
response=error,
|
|
299
|
+
)
|
|
300
|
+
elif response.status_code == 402:
|
|
301
|
+
raise MurielCreditsError(
|
|
302
|
+
detail_of(response, "Out of credits. Email info@murielai.io to add more."),
|
|
303
|
+
status_code=402,
|
|
304
|
+
response=response.json() if response.content else None,
|
|
305
|
+
)
|
|
306
|
+
elif response.status_code == 429:
|
|
307
|
+
raise MurielRateLimitError(
|
|
308
|
+
detail_of(response, "Rate limit exceeded. Wait and retry."),
|
|
309
|
+
status_code=429,
|
|
310
|
+
response=response.json() if response.content else None,
|
|
311
|
+
)
|
|
312
|
+
elif response.status_code >= 500:
|
|
313
|
+
raise MurielServerError(
|
|
314
|
+
detail_of(response, "Server error during agent query."),
|
|
315
|
+
status_code=response.status_code,
|
|
316
|
+
response=response.json() if response.content else None,
|
|
317
|
+
)
|
|
318
|
+
else:
|
|
319
|
+
raise MurielAPIError(
|
|
320
|
+
f"Unexpected status {response.status_code}",
|
|
321
|
+
status_code=response.status_code,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# ── Module-level convenience ───────────────────────────────────────────────────
|
|
326
|
+
|
|
327
|
+
# One Agent per API key, so repeated calls reuse a connection. `None` is the
|
|
328
|
+
# key read from MURIEL_API_KEY.
|
|
329
|
+
_agents: Dict[Optional[str], Agent] = {}
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def query(
|
|
333
|
+
prompt: str,
|
|
334
|
+
*,
|
|
335
|
+
api_key: Optional[str] = None,
|
|
336
|
+
pwsid: Optional[str] = None,
|
|
337
|
+
since: Union[str, date, datetime, None] = None,
|
|
338
|
+
schema: Optional[SchemaArg] = None,
|
|
339
|
+
max_records: int = 1000,
|
|
340
|
+
) -> Union[AgentQueryResponse, Dict[str, Any], BaseModel]:
|
|
341
|
+
"""``muriel.query(...)``: ``Agent.query`` without building an Agent.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
api_key: Muriel API key. Defaults to the ``MURIEL_API_KEY``
|
|
345
|
+
environment variable. All other arguments are as for
|
|
346
|
+
``Agent.query``.
|
|
347
|
+
|
|
348
|
+
Build an ``Agent`` yourself to set a base URL or timeout.
|
|
349
|
+
"""
|
|
350
|
+
agent = _agents.get(api_key)
|
|
351
|
+
if agent is None:
|
|
352
|
+
agent = _agents[api_key] = Agent(api_key=api_key)
|
|
353
|
+
return agent.query(
|
|
354
|
+
prompt, pwsid=pwsid, since=since, schema=schema, max_records=max_records
|
|
355
|
+
)
|
muriel/client.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Core client for the Muriel API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, checked_base_url, get_api_key, get_base_url
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
MurielAPIError,
|
|
9
|
+
MurielAuthenticationError,
|
|
10
|
+
MurielValidationError,
|
|
11
|
+
MurielNotFoundError,
|
|
12
|
+
MurielRateLimitError,
|
|
13
|
+
MurielServerError,
|
|
14
|
+
MurielCreditsError,
|
|
15
|
+
detail_of,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MurielClient:
|
|
20
|
+
"""Base client for making requests to the Muriel API."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
api_key: Optional[str] = None,
|
|
25
|
+
base_url: Optional[str] = None,
|
|
26
|
+
timeout: int = DEFAULT_TIMEOUT,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize the Muriel API client.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
api_key: Your Muriel API key. If not provided, will look for MURIEL_API_KEY env var.
|
|
33
|
+
base_url: Base URL for the API (default: MURIEL_BASE_URL, else https://api.murielai.io)
|
|
34
|
+
timeout: Request timeout in seconds (default: 30)
|
|
35
|
+
"""
|
|
36
|
+
self.api_key = api_key or get_api_key()
|
|
37
|
+
if not self.api_key:
|
|
38
|
+
raise MurielAuthenticationError(
|
|
39
|
+
"API key is required. Pass it as api_key parameter or set MURIEL_API_KEY environment variable."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.base_url = checked_base_url(base_url or get_base_url())
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self.session = requests.Session()
|
|
45
|
+
self.session.headers.update({"X-API-Key": self.api_key})
|
|
46
|
+
|
|
47
|
+
def _make_request(
|
|
48
|
+
self,
|
|
49
|
+
method: str,
|
|
50
|
+
endpoint: str,
|
|
51
|
+
params: Optional[Dict[str, Any]] = None,
|
|
52
|
+
json: Optional[Dict[str, Any]] = None,
|
|
53
|
+
) -> Dict[str, Any]:
|
|
54
|
+
"""
|
|
55
|
+
Make an HTTP request to the API.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
method: HTTP method (GET, POST, etc.)
|
|
59
|
+
endpoint: API endpoint path
|
|
60
|
+
params: Query parameters
|
|
61
|
+
json: JSON body data
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Response data as dictionary
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
MurielAPIError: For various API errors
|
|
68
|
+
"""
|
|
69
|
+
url = f"{self.base_url}{endpoint}"
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
response = self.session.request(
|
|
73
|
+
method=method,
|
|
74
|
+
url=url,
|
|
75
|
+
params=params,
|
|
76
|
+
json=json,
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
# Never follow a redirect: requests strips Authorization on a
|
|
79
|
+
# cross-host redirect but NOT a custom X-API-Key header, so a
|
|
80
|
+
# redirect would hand the key to whatever host it points at.
|
|
81
|
+
allow_redirects=False,
|
|
82
|
+
)
|
|
83
|
+
if 300 <= response.status_code < 400:
|
|
84
|
+
raise MurielAPIError(
|
|
85
|
+
f"Unexpected redirect ({response.status_code}) to "
|
|
86
|
+
f"{response.headers.get('Location', '?')}; not followed, so your API "
|
|
87
|
+
"key was not sent there. Check MURIEL_BASE_URL.",
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Handle different status codes
|
|
92
|
+
if response.status_code == 200:
|
|
93
|
+
return response.json()
|
|
94
|
+
elif response.status_code == 201:
|
|
95
|
+
return response.json()
|
|
96
|
+
elif response.status_code == 401:
|
|
97
|
+
raise MurielAuthenticationError(
|
|
98
|
+
"Invalid API key",
|
|
99
|
+
status_code=response.status_code,
|
|
100
|
+
response=response.json() if response.content else None,
|
|
101
|
+
)
|
|
102
|
+
elif response.status_code == 400:
|
|
103
|
+
error_data = response.json() if response.content else {}
|
|
104
|
+
raise MurielValidationError(
|
|
105
|
+
error_data.get("detail", "Validation error"),
|
|
106
|
+
status_code=response.status_code,
|
|
107
|
+
response=error_data,
|
|
108
|
+
)
|
|
109
|
+
elif response.status_code == 402:
|
|
110
|
+
raise MurielCreditsError(
|
|
111
|
+
detail_of(response, "Out of credits. Email info@murielai.io to add more."),
|
|
112
|
+
status_code=402,
|
|
113
|
+
response=response.json() if response.content else None,
|
|
114
|
+
)
|
|
115
|
+
elif response.status_code == 404:
|
|
116
|
+
raise MurielNotFoundError(
|
|
117
|
+
detail_of(response, "Resource not found"),
|
|
118
|
+
status_code=response.status_code,
|
|
119
|
+
response=response.json() if response.content else None,
|
|
120
|
+
)
|
|
121
|
+
elif response.status_code == 429:
|
|
122
|
+
raise MurielRateLimitError(
|
|
123
|
+
detail_of(response, "Rate limit exceeded"),
|
|
124
|
+
status_code=response.status_code,
|
|
125
|
+
response=response.json() if response.content else None,
|
|
126
|
+
)
|
|
127
|
+
elif response.status_code >= 500:
|
|
128
|
+
raise MurielServerError(
|
|
129
|
+
detail_of(response, "Server error"),
|
|
130
|
+
status_code=response.status_code,
|
|
131
|
+
response=response.json() if response.content else None,
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
response.raise_for_status()
|
|
135
|
+
return response.json()
|
|
136
|
+
|
|
137
|
+
except requests.exceptions.Timeout:
|
|
138
|
+
raise MurielAPIError(f"Request timed out after {self.timeout} seconds")
|
|
139
|
+
except requests.exceptions.ConnectionError:
|
|
140
|
+
raise MurielAPIError(f"Failed to connect to {self.base_url}")
|
|
141
|
+
except requests.exceptions.RequestException as e:
|
|
142
|
+
if not isinstance(e, MurielAPIError):
|
|
143
|
+
raise MurielAPIError(f"Request failed: {str(e)}")
|
|
144
|
+
raise
|
|
145
|
+
|
|
146
|
+
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
147
|
+
"""Make a GET request."""
|
|
148
|
+
return self._make_request("GET", endpoint, params=params)
|
|
149
|
+
|
|
150
|
+
def post(self, endpoint: str, json: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
151
|
+
"""Make a POST request."""
|
|
152
|
+
return self._make_request("POST", endpoint, json=json)
|
|
153
|
+
|
|
154
|
+
def close(self):
|
|
155
|
+
"""Close the HTTP session."""
|
|
156
|
+
self.session.close()
|
|
157
|
+
|
|
158
|
+
def __enter__(self):
|
|
159
|
+
"""Context manager entry."""
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
163
|
+
"""Context manager exit."""
|
|
164
|
+
self.close()
|
muriel/clients.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Object-oriented clients matching the API documentation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from .config import get_api_key
|
|
8
|
+
from .endpoints.requirements import get_requirements as _get_requirements
|
|
9
|
+
from .endpoints.results import get_results as _get_results
|
|
10
|
+
from .endpoints.systems import get_systems as _get_systems
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _BaseClient:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
api_key: Optional[str] = None,
|
|
17
|
+
token: Optional[str] = None,
|
|
18
|
+
base_url: Optional[str] = None,
|
|
19
|
+
):
|
|
20
|
+
resolved_key = api_key or token or get_api_key()
|
|
21
|
+
if not resolved_key:
|
|
22
|
+
raise ValueError("api_key or token is required, or set MURIEL_API_KEY")
|
|
23
|
+
self.api_key = resolved_key
|
|
24
|
+
self.base_url = base_url
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RequirementsClient(_BaseClient):
|
|
28
|
+
"""Client for sampling requirements endpoints."""
|
|
29
|
+
|
|
30
|
+
def get_requirements(
|
|
31
|
+
self,
|
|
32
|
+
pwsid: Optional[str] = None,
|
|
33
|
+
state: Optional[str] = None,
|
|
34
|
+
limit: int = 100,
|
|
35
|
+
system_princ: Optional[str] = None,
|
|
36
|
+
) -> Dict[str, Any]:
|
|
37
|
+
return _get_requirements(
|
|
38
|
+
pwsid=pwsid,
|
|
39
|
+
state=state,
|
|
40
|
+
api_key=self.api_key,
|
|
41
|
+
limit=limit,
|
|
42
|
+
system_princ=system_princ,
|
|
43
|
+
base_url=self.base_url,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class SystemsClient(_BaseClient):
|
|
48
|
+
"""Client for public water system metadata."""
|
|
49
|
+
|
|
50
|
+
def get_systems(
|
|
51
|
+
self,
|
|
52
|
+
state: str,
|
|
53
|
+
limit: int = 100,
|
|
54
|
+
system_princ: Optional[str] = None,
|
|
55
|
+
system_statu: Optional[str] = None,
|
|
56
|
+
) -> Dict[str, Any]:
|
|
57
|
+
return _get_systems(
|
|
58
|
+
state=state,
|
|
59
|
+
api_key=self.api_key,
|
|
60
|
+
limit=limit,
|
|
61
|
+
system_princ=system_princ,
|
|
62
|
+
system_statu=system_statu,
|
|
63
|
+
base_url=self.base_url,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SamplesClient(_BaseClient):
|
|
68
|
+
"""Client for sample results; wraps get_results() for messages-style parameters."""
|
|
69
|
+
|
|
70
|
+
def get_samples(
|
|
71
|
+
self,
|
|
72
|
+
pwsid: Optional[str] = None,
|
|
73
|
+
state: Optional[str] = None,
|
|
74
|
+
analyte: Optional[str] = None,
|
|
75
|
+
sample_date_start: Optional[str] = None,
|
|
76
|
+
sample_date_end: Optional[str] = None,
|
|
77
|
+
compliance_status: Optional[str] = None,
|
|
78
|
+
limit: int = 100,
|
|
79
|
+
**kwargs: Any,
|
|
80
|
+
) -> List[Dict[str, Any]]:
|
|
81
|
+
if compliance_status is not None:
|
|
82
|
+
kwargs["compliance_status"] = compliance_status
|
|
83
|
+
|
|
84
|
+
return _get_results(
|
|
85
|
+
pwsid=pwsid,
|
|
86
|
+
state=state,
|
|
87
|
+
analyte=analyte,
|
|
88
|
+
start_date=sample_date_start,
|
|
89
|
+
end_date=sample_date_end,
|
|
90
|
+
api_key=self.api_key,
|
|
91
|
+
num=limit,
|
|
92
|
+
base_url=self.base_url,
|
|
93
|
+
**kwargs,
|
|
94
|
+
)
|