guidelinely 1.0.3__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.
@@ -0,0 +1,60 @@
1
+ """Guidelinely - Python client for the Guidelinely Environmental Guidelines API.
2
+
3
+ Provides programmatic access to environmental guideline calculations and searches
4
+ for chemical parameters in various media (water, soil, sediment).
5
+ """
6
+
7
+ from guidelinely.auth import get_api_key
8
+ from guidelinely.client import (
9
+ calculate_batch,
10
+ calculate_guidelines,
11
+ get_stats,
12
+ health_check,
13
+ list_media,
14
+ list_parameters,
15
+ list_sources,
16
+ readiness_check,
17
+ search_parameters,
18
+ )
19
+ from guidelinely.exceptions import (
20
+ GuidelinelyAPIError,
21
+ GuidelinelyError,
22
+ GuidelinelyTimeoutError,
23
+ )
24
+ from guidelinely.models import (
25
+ CalculationResponse,
26
+ GuidelineResponse,
27
+ SourceDocument,
28
+ SourceResponse,
29
+ StatsResponse,
30
+ )
31
+
32
+ try:
33
+ from guidelinely._version import __version__
34
+ except ImportError:
35
+ __version__ = "0.0.0.dev0" # Fallback for editable installs without build
36
+
37
+ __all__ = [
38
+ # Client functions
39
+ "health_check",
40
+ "readiness_check",
41
+ "list_parameters",
42
+ "search_parameters",
43
+ "list_media",
44
+ "list_sources",
45
+ "get_stats",
46
+ "calculate_guidelines",
47
+ "calculate_batch",
48
+ # Models
49
+ "GuidelineResponse",
50
+ "CalculationResponse",
51
+ "SourceResponse",
52
+ "SourceDocument",
53
+ "StatsResponse",
54
+ # Exceptions
55
+ "GuidelinelyError",
56
+ "GuidelinelyAPIError",
57
+ "GuidelinelyTimeoutError",
58
+ # Auth
59
+ "get_api_key",
60
+ ]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '1.0.3'
32
+ __version_tuple__ = version_tuple = (1, 0, 3)
33
+
34
+ __commit_id__ = commit_id = None
guidelinely/auth.py ADDED
@@ -0,0 +1,29 @@
1
+ """Authentication helpers for the Guidelinely API.
2
+
3
+ The main API endpoints (metadata) do not require authentication.
4
+ Calculation endpoints optionally accept an API key.
5
+ """
6
+
7
+ import os
8
+ from typing import Optional
9
+
10
+ __all__ = ["get_api_key"]
11
+
12
+
13
+ def get_api_key(api_key: Optional[str] = None) -> Optional[str]:
14
+ """Get API key from argument or GUIDELINELY_API_KEY environment variable.
15
+
16
+ Args:
17
+ api_key: Optional API key string. If not provided, will check environment.
18
+
19
+ Returns:
20
+ API key string or None if not available.
21
+ """
22
+ if api_key is not None:
23
+ return api_key
24
+
25
+ env_key = os.getenv("GUIDELINELY_API_KEY")
26
+ if env_key:
27
+ return env_key
28
+
29
+ return None
guidelinely/cache.py ADDED
@@ -0,0 +1,46 @@
1
+ """Client-side caching for Guidelinely API calculation endpoints.
2
+
3
+ Uses diskcache for persistent, SQLite-backed caching that survives between runs.
4
+
5
+ The cache directory can be configured via the GUIDELINELY_CACHE_DIR environment
6
+ variable. If not set, defaults to ~/.guidelinely_cache.
7
+ """
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any, Optional, cast
12
+
13
+ from diskcache import Cache # type: ignore[import-untyped]
14
+
15
+ __all__ = ["get_cached", "set_cached", "cache", "CACHE_DIR"]
16
+
17
+ # Cache directory: configurable via environment variable, defaults to user's home directory
18
+ _default_cache_dir = Path.home() / ".guidelinely_cache"
19
+ CACHE_DIR = Path(os.getenv("GUIDELINELY_CACHE_DIR", str(_default_cache_dir)))
20
+ cache = Cache(directory=str(CACHE_DIR))
21
+
22
+
23
+ def get_cached(key_data: dict[str, Any]) -> Optional[dict[str, Any]]:
24
+ """Retrieve cached response for given request data.
25
+
26
+ Args:
27
+ key_data: Cache key (typically dict of request parameters)
28
+
29
+ Returns:
30
+ Cached response data if found, None otherwise
31
+ """
32
+ result = cache.get(key_data)
33
+ if result is None:
34
+ return None
35
+ return cast(dict[str, Any], result)
36
+
37
+
38
+ def set_cached(key_data: dict[str, Any], value: dict[str, Any], ttl: int = 24 * 3600) -> None:
39
+ """Store response in cache for given request data with TTL.
40
+
41
+ Args:
42
+ key_data: Cache key (typically dict of request parameters)
43
+ value: Response data to cache
44
+ ttl: Time to live in seconds (default: 24 hours)
45
+ """
46
+ cache.set(key_data, value, expire=ttl)
guidelinely/client.py ADDED
@@ -0,0 +1,469 @@
1
+ """Python client for the Guidelinely Environmental Guidelines API.
2
+
3
+ Provides programmatic access to environmental guideline calculations and searches
4
+ for chemical parameters in various media (water, soil, sediment).
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Optional, Union, cast
9
+
10
+ import httpx
11
+
12
+ from guidelinely.auth import get_api_key
13
+ from guidelinely.cache import get_cached, set_cached
14
+ from guidelinely.exceptions import GuidelinelyAPIError, GuidelinelyTimeoutError
15
+ from guidelinely.models import (
16
+ CalculationResponse,
17
+ SourceResponse,
18
+ StatsResponse,
19
+ )
20
+
21
+ # Module logger for debugging API calls
22
+ logger = logging.getLogger("guidelinely")
23
+
24
+ # API base URL
25
+ GUIDELINELY_API_BASE = "https://guidelines.1681248.com/api/v1"
26
+
27
+ # Default timeout for HTTP requests (in seconds)
28
+ DEFAULT_TIMEOUT = 30.0
29
+
30
+ # Package version for User-Agent header (duplicated from __init__.py to avoid circular import)
31
+ __version__ = "0.1.0"
32
+
33
+ # User-Agent header for API request identification
34
+ USER_AGENT = f"guidelinely-python/{__version__}"
35
+
36
+
37
+ def _handle_error(response: httpx.Response) -> None:
38
+ """Extract error message from API response and raise appropriate exception.
39
+
40
+ Args:
41
+ response: HTTP response object with non-2xx status code.
42
+
43
+ Raises:
44
+ GuidelinelyAPIError: Always raised with extracted message and status code.
45
+ """
46
+ try:
47
+ error_body = response.json()
48
+ message = error_body.get("detail") or error_body.get("message") or "API request failed"
49
+ except Exception:
50
+ message = "API request failed"
51
+ raise GuidelinelyAPIError(message, response.status_code)
52
+
53
+
54
+ def health_check() -> dict[str, Any]:
55
+ """Check if the API service is running.
56
+
57
+ Lightweight health check that returns 200 OK if the service is running.
58
+ Does not check dependencies.
59
+
60
+ Returns:
61
+ Dictionary with health status.
62
+
63
+ Raises:
64
+ GuidelinelyAPIError: If the API returns an error response.
65
+ GuidelinelyTimeoutError: If the request times out.
66
+
67
+ Example:
68
+ status = health_check()
69
+ print(status)
70
+ """
71
+ logger.debug("Performing health check")
72
+ try:
73
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
74
+ response = client.get(f"{GUIDELINELY_API_BASE}/health")
75
+ logger.debug(f"Health check response: {response.status_code}")
76
+ if response.status_code != 200:
77
+ _handle_error(response)
78
+ return cast(dict[str, Any], response.json())
79
+ except httpx.TimeoutException as e:
80
+ logger.warning(f"Health check timed out: {e}")
81
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
82
+
83
+
84
+ def readiness_check() -> dict[str, Any]:
85
+ """Check if the API is ready to handle requests.
86
+
87
+ Readiness check that verifies the service can handle requests
88
+ (database is accessible).
89
+
90
+ Returns:
91
+ Dictionary with readiness status.
92
+
93
+ Raises:
94
+ GuidelinelyAPIError: If the API returns an error response.
95
+ GuidelinelyTimeoutError: If the request times out.
96
+
97
+ Example:
98
+ status = readiness_check()
99
+ print(status)
100
+ """
101
+ logger.debug("Performing readiness check")
102
+ try:
103
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
104
+ response = client.get(f"{GUIDELINELY_API_BASE}/ready")
105
+ logger.debug(f"Readiness check response: {response.status_code}")
106
+ if response.status_code != 200:
107
+ _handle_error(response)
108
+ return cast(dict[str, Any], response.json())
109
+ except httpx.TimeoutException as e:
110
+ logger.warning(f"Readiness check timed out: {e}")
111
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
112
+
113
+
114
+ def list_parameters() -> list[str]:
115
+ """List all available chemical parameters.
116
+
117
+ Get complete list of all available chemical parameters in the database.
118
+
119
+ Returns:
120
+ List of parameter names.
121
+
122
+ Raises:
123
+ GuidelinelyAPIError: If the API returns an error response.
124
+ GuidelinelyTimeoutError: If the request times out.
125
+
126
+ Example:
127
+ params = list_parameters()
128
+ print(params[:5]) # ['Aluminum', 'Ammonia', 'Arsenic', 'Cadmium', 'Copper']
129
+ """
130
+ logger.debug("Listing all parameters")
131
+ try:
132
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
133
+ response = client.get(f"{GUIDELINELY_API_BASE}/parameters")
134
+ logger.debug(f"List parameters response: {response.status_code}")
135
+ if response.status_code != 200:
136
+ _handle_error(response)
137
+ return cast(list[str], response.json())
138
+ except httpx.TimeoutException as e:
139
+ logger.warning(f"List parameters timed out: {e}")
140
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
141
+
142
+
143
+ def search_parameters(q: str = "", media: Optional[list[str]] = None) -> list[str]:
144
+ """Search for chemical parameters.
145
+
146
+ Search for chemical parameters using case-insensitive substring matching.
147
+
148
+ Args:
149
+ q: Search query string. Empty string returns all parameters.
150
+ media: Optional list of media types to filter by (e.g., ["surface_water", "soil"]).
151
+
152
+ Returns:
153
+ List of matching parameter names.
154
+
155
+ Raises:
156
+ GuidelinelyAPIError: If the API returns an error response.
157
+ GuidelinelyTimeoutError: If the request times out.
158
+
159
+ Example:
160
+ # Find all ammonia-related parameters
161
+ ammonia = search_parameters("ammon")
162
+ print(ammonia)
163
+
164
+ # Find copper in surface water
165
+ copper_sw = search_parameters("copper", media=["surface_water"])
166
+ """
167
+ logger.debug(f"Searching parameters with q={q!r}, media={media!r}")
168
+ try:
169
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
170
+ params: dict[str, Any] = {"q": q}
171
+ if media:
172
+ params["media"] = media
173
+
174
+ response = client.get(
175
+ f"{GUIDELINELY_API_BASE}/parameters/search",
176
+ params=params,
177
+ )
178
+ logger.debug(f"Search parameters response: {response.status_code}")
179
+ if response.status_code != 200:
180
+ _handle_error(response)
181
+ return cast(list[str], response.json())
182
+ except httpx.TimeoutException as e:
183
+ logger.warning(f"Search parameters timed out: {e}")
184
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
185
+
186
+
187
+ def list_media() -> dict[str, str]:
188
+ """List all environmental media types.
189
+
190
+ Get list of all available environmental media types (water, soil, sediment, etc.).
191
+
192
+ Returns:
193
+ Dictionary mapping enum names to display names.
194
+
195
+ Raises:
196
+ GuidelinelyAPIError: If the API returns an error response.
197
+ GuidelinelyTimeoutError: If the request times out.
198
+
199
+ Example:
200
+ media = list_media()
201
+ print(media)
202
+ """
203
+ logger.debug("Listing all media types")
204
+ try:
205
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
206
+ response = client.get(f"{GUIDELINELY_API_BASE}/media")
207
+ logger.debug(f"List media response: {response.status_code}")
208
+ if response.status_code != 200:
209
+ _handle_error(response)
210
+ return cast(dict[str, str], response.json())
211
+ except httpx.TimeoutException as e:
212
+ logger.warning(f"List media timed out: {e}")
213
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
214
+
215
+
216
+ def list_sources() -> list[SourceResponse]:
217
+ """List all guideline sources and documents.
218
+
219
+ Get list of all guideline sources and their associated documents.
220
+
221
+ Returns:
222
+ List of SourceResponse objects with nested document information.
223
+
224
+ Raises:
225
+ GuidelinelyAPIError: If the API returns an error response.
226
+ GuidelinelyTimeoutError: If the request times out.
227
+
228
+ Example:
229
+ sources = list_sources()
230
+ print(sources[0].name) # e.g., 'CCME'
231
+ print(sources[0].documents[0].title)
232
+ """
233
+ logger.debug("Listing all sources")
234
+ try:
235
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
236
+ response = client.get(f"{GUIDELINELY_API_BASE}/sources")
237
+ logger.debug(f"List sources response: {response.status_code}")
238
+ if response.status_code != 200:
239
+ _handle_error(response)
240
+ return [SourceResponse(**source) for source in response.json()]
241
+ except httpx.TimeoutException as e:
242
+ logger.warning(f"List sources timed out: {e}")
243
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
244
+
245
+
246
+ def get_stats() -> StatsResponse:
247
+ """Get database statistics.
248
+
249
+ Get statistics about the guideline database (counts of sources, documents, etc.).
250
+
251
+ Returns:
252
+ StatsResponse with total_parameters, total_guidelines, total_sources, total_documents.
253
+
254
+ Raises:
255
+ GuidelinelyAPIError: If the API returns an error response.
256
+ GuidelinelyTimeoutError: If the request times out.
257
+
258
+ Example:
259
+ stats = get_stats()
260
+ print(f"Total parameters: {stats.total_parameters}")
261
+ print(f"Total guidelines: {stats.total_guidelines}")
262
+ """
263
+ logger.debug("Getting database statistics")
264
+ try:
265
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
266
+ response = client.get(f"{GUIDELINELY_API_BASE}/stats")
267
+ logger.debug(f"Get stats response: {response.status_code}")
268
+ if response.status_code != 200:
269
+ _handle_error(response)
270
+ return StatsResponse(**response.json())
271
+ except httpx.TimeoutException as e:
272
+ logger.warning(f"Get stats timed out: {e}")
273
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
274
+
275
+
276
+ def calculate_guidelines(
277
+ parameter: str,
278
+ media: str,
279
+ context: Optional[dict[str, str]] = None,
280
+ target_unit: Optional[str] = None,
281
+ api_key: Optional[str] = None,
282
+ ) -> CalculationResponse:
283
+ """Calculate guidelines for a parameter.
284
+
285
+ Calculate guideline values for a specific parameter in a given media type
286
+ with environmental context.
287
+
288
+ Args:
289
+ parameter: Chemical parameter name (e.g., "Aluminum", "Copper").
290
+ media: Media type (e.g., "surface_water", "soil", "sediment").
291
+ context: Dictionary of environmental parameters as strings with units.
292
+ For water: pH ("7.0 1"), hardness ("100 mg/L"), temperature ("20 °C"),
293
+ chloride ("50 mg/L")
294
+ For soil: pH ("6.5 1"), organic_matter ("3.5 %"),
295
+ cation_exchange_capacity ("15 meq/100g")
296
+ target_unit: Optional unit to convert result to (e.g., "mg/L", "μg/L").
297
+ api_key: Optional API key. If None, will use GUIDELINELY_API_KEY environment variable.
298
+
299
+ Returns:
300
+ CalculationResponse with results, context, and total_count.
301
+
302
+ Raises:
303
+ GuidelinelyAPIError: If the API returns an error response.
304
+ GuidelinelyTimeoutError: If the request times out.
305
+
306
+ Example:
307
+ >>> import os
308
+ >>> os.environ["GUIDELINELY_API_KEY"] = "your_key"
309
+ >>>
310
+ >>> result = calculate_guidelines(
311
+ ... parameter="Aluminum",
312
+ ... media="surface_water",
313
+ ... context={"pH": "7.0 1", "hardness": "100 mg/L"}
314
+ ... )
315
+ >>>
316
+ >>> print(f"Total: {result.total_count}")
317
+ >>> for guideline in result.results:
318
+ ... print(f"{guideline.parameter}: {guideline.value} ({guideline.source})")
319
+ """
320
+ key = get_api_key(api_key)
321
+ logger.debug(f"Calculating guidelines for {parameter} in {media}")
322
+
323
+ # Prepare cache key (excludes api_key for security - keys should not be stored on disk)
324
+ cache_key = {
325
+ "endpoint": "calculate",
326
+ "parameter": parameter,
327
+ "media": media,
328
+ "context": context,
329
+ "target_unit": target_unit,
330
+ }
331
+
332
+ # Check cache first
333
+ cached_response = get_cached(cache_key)
334
+ if cached_response:
335
+ logger.debug("Returning cached response")
336
+ return CalculationResponse(**cached_response)
337
+
338
+ body: dict[str, Any] = {
339
+ "parameter": parameter,
340
+ "media": media,
341
+ }
342
+
343
+ if context:
344
+ body["context"] = context
345
+ if target_unit:
346
+ body["target_unit"] = target_unit
347
+
348
+ headers = {"User-Agent": USER_AGENT}
349
+ if key:
350
+ headers["X-API-KEY"] = key
351
+
352
+ try:
353
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
354
+ response = client.post(
355
+ f"{GUIDELINELY_API_BASE}/calculate",
356
+ json=body,
357
+ headers=headers,
358
+ )
359
+ logger.debug(f"Calculate response: {response.status_code}")
360
+ if response.status_code != 200:
361
+ _handle_error(response)
362
+
363
+ data = response.json()
364
+ # Cache the response
365
+ set_cached(cache_key, data)
366
+ return CalculationResponse(**data)
367
+ except httpx.TimeoutException as e:
368
+ logger.warning(f"Calculate guidelines timed out: {e}")
369
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
370
+
371
+
372
+ def calculate_batch(
373
+ parameters: list[Union[str, dict[str, Any]]],
374
+ media: str,
375
+ context: Optional[dict[str, str]] = None,
376
+ api_key: Optional[str] = None,
377
+ ) -> CalculationResponse:
378
+ """Batch calculate guidelines for multiple parameters.
379
+
380
+ Calculate guideline values for multiple parameters in a given media type
381
+ with shared environmental context. More efficient than multiple individual calls.
382
+
383
+ Args:
384
+ parameters: List of parameter names (strings), or list mixing strings and dicts
385
+ with 'name' and 'target_unit' fields. Maximum 50 parameters.
386
+ media: Media type (e.g., "surface_water", "soil").
387
+ context: Dictionary of environmental parameters as strings with units.
388
+ api_key: Optional API key. If None, will use GUIDELINELY_API_KEY environment variable.
389
+
390
+ Returns:
391
+ CalculationResponse with results, context, and total_count.
392
+
393
+ Raises:
394
+ ValueError: If more than 50 parameters provided.
395
+ GuidelinelyAPIError: If the API returns an error response.
396
+ GuidelinelyTimeoutError: If the request times out.
397
+
398
+ Example:
399
+ >>> import os
400
+ >>> os.environ["GUIDELINELY_API_KEY"] = "your_key"
401
+ >>>
402
+ >>> # Calculate multiple metals in surface water
403
+ >>> results = calculate_batch(
404
+ ... parameters=["Aluminum", "Copper", "Lead"],
405
+ ... media="surface_water",
406
+ ... context={"pH": "7.0 1", "hardness": "100 mg/L"}
407
+ ... )
408
+ >>>
409
+ >>> # With per-parameter unit conversion
410
+ >>> results = calculate_batch(
411
+ ... parameters=[
412
+ ... "Aluminum",
413
+ ... {"name": "Copper", "target_unit": "μg/L"},
414
+ ... {"name": "Lead", "target_unit": "mg/L"}
415
+ ... ],
416
+ ... media="surface_water",
417
+ ... context={"pH": "7.5 1", "hardness": "150 mg/L", "temperature": "15 °C"}
418
+ ... )
419
+ """
420
+ if len(parameters) > 50:
421
+ raise ValueError("Maximum 50 parameters per batch request")
422
+
423
+ key = get_api_key(api_key)
424
+ logger.debug(f"Batch calculating {len(parameters)} parameters in {media}")
425
+
426
+ # Prepare cache key (excludes api_key for security - keys should not be stored on disk)
427
+ cache_key = {
428
+ "endpoint": "calculate/batch",
429
+ "parameters": parameters,
430
+ "media": media,
431
+ "context": context,
432
+ }
433
+
434
+ # Check cache first
435
+ cached_response = get_cached(cache_key)
436
+ if cached_response:
437
+ logger.debug("Returning cached response")
438
+ return CalculationResponse(**cached_response)
439
+
440
+ body: dict[str, Any] = {
441
+ "parameters": parameters,
442
+ "media": media,
443
+ }
444
+
445
+ if context:
446
+ body["context"] = context
447
+
448
+ headers = {"User-Agent": USER_AGENT}
449
+ if key:
450
+ headers["X-API-KEY"] = key
451
+
452
+ try:
453
+ with httpx.Client(timeout=DEFAULT_TIMEOUT, headers={"User-Agent": USER_AGENT}) as client:
454
+ response = client.post(
455
+ f"{GUIDELINELY_API_BASE}/calculate/batch",
456
+ json=body,
457
+ headers=headers,
458
+ )
459
+ logger.debug(f"Batch calculate response: {response.status_code}")
460
+ if response.status_code != 200:
461
+ _handle_error(response)
462
+
463
+ data = response.json()
464
+ # Cache the response
465
+ set_cached(cache_key, data)
466
+ return CalculationResponse(**data)
467
+ except httpx.TimeoutException as e:
468
+ logger.warning(f"Batch calculate timed out: {e}")
469
+ raise GuidelinelyTimeoutError(f"Request timed out: {e}") from e
@@ -0,0 +1,82 @@
1
+ """Custom exceptions for the Guidelinely API client.
2
+
3
+ Provides structured error handling for API communication failures,
4
+ allowing callers to catch specific exception types and access
5
+ error details like HTTP status codes.
6
+ """
7
+
8
+ __all__ = ["GuidelinelyError", "GuidelinelyAPIError", "GuidelinelyTimeoutError"]
9
+
10
+
11
+ class GuidelinelyError(Exception):
12
+ """Base exception for all Guidelinely client errors.
13
+
14
+ All exceptions raised by the guidelinely package inherit from this class,
15
+ making it easy to catch all library-specific errors.
16
+
17
+ Example:
18
+ try:
19
+ result = calculate_guidelines(...)
20
+ except GuidelinelyError as e:
21
+ print(f"Guidelinely error: {e}")
22
+ """
23
+
24
+ pass
25
+
26
+
27
+ class GuidelinelyAPIError(GuidelinelyError):
28
+ """Raised when the API returns an error response.
29
+
30
+ Provides access to both the error message and HTTP status code
31
+ for detailed error handling.
32
+
33
+ Attributes:
34
+ message: Human-readable error description from the API.
35
+ status_code: HTTP status code returned by the API.
36
+
37
+ Example:
38
+ try:
39
+ result = calculate_guidelines(...)
40
+ except GuidelinelyAPIError as e:
41
+ if e.status_code == 404:
42
+ print("Parameter not found")
43
+ elif e.status_code == 401:
44
+ print("Invalid API key")
45
+ else:
46
+ print(f"API error {e.status_code}: {e.message}")
47
+ """
48
+
49
+ def __init__(self, message: str, status_code: int) -> None:
50
+ """Initialize the API error.
51
+
52
+ Args:
53
+ message: Error message from the API response.
54
+ status_code: HTTP status code from the response.
55
+ """
56
+ super().__init__(message)
57
+ self.message = message
58
+ self.status_code = status_code
59
+
60
+ def __str__(self) -> str:
61
+ """Return formatted error string with status code."""
62
+ return f"[{self.status_code}] {self.message}"
63
+
64
+ def __repr__(self) -> str:
65
+ """Return detailed representation for debugging."""
66
+ return f"GuidelinelyAPIError(message={self.message!r}, status_code={self.status_code})"
67
+
68
+
69
+ class GuidelinelyTimeoutError(GuidelinelyError):
70
+ """Raised when an API request times out.
71
+
72
+ This exception wraps httpx.TimeoutException to provide a consistent
73
+ exception hierarchy for the library.
74
+
75
+ Example:
76
+ try:
77
+ result = calculate_guidelines(...)
78
+ except GuidelinelyTimeoutError:
79
+ print("Request timed out, please try again")
80
+ """
81
+
82
+ pass
guidelinely/models.py ADDED
@@ -0,0 +1,138 @@
1
+ """Pydantic models for Guidelinely API requests and responses."""
2
+
3
+ from typing import Any, Optional, Union
4
+
5
+ from pydantic import BaseModel, model_validator
6
+
7
+ __all__ = [
8
+ # Response models
9
+ "GuidelineResponse",
10
+ "CalculationResponse",
11
+ "SourceResponse",
12
+ "SourceDocument",
13
+ "StatsResponse",
14
+ "MediaResponse",
15
+ # Request models
16
+ "CalculateRequest",
17
+ "BatchCalculateRequest",
18
+ "ParameterWithUnit",
19
+ "SearchParametersRequest",
20
+ ]
21
+
22
+
23
+ class GuidelineResponse(BaseModel):
24
+ """Single guideline result with calculated or static value.
25
+
26
+ Represents a guideline value in PostgreSQL unitrange format (e.g., '[10 μg/L,100 μg/L]').
27
+ """
28
+
29
+ id: int
30
+ parameter: str
31
+ parameter_specification: str
32
+ media: str
33
+ value: str # PostgreSQL unitrange format: '[10 μg/L,100 μg/L]', '(,87.0 μg/L]', '[5.0 mg/L,)'
34
+ lower: Optional[float] = None # Parsed lower bound or None if unbounded
35
+ upper: Optional[float] = None # Parsed upper bound or None if unbounded
36
+ unit: str
37
+ is_calculated: bool
38
+ source: str
39
+ basis: Optional[str] = None
40
+ receptor: str
41
+ exposure_duration: str
42
+ guideline_type: Optional[str] = None
43
+ notes: Optional[str] = None
44
+ reference_id: Optional[int] = None
45
+ document_id: Optional[int] = None
46
+ created_at: Optional[str] = None
47
+ updated_at: Optional[str] = None
48
+ document_abbreviation: str
49
+ source_abbreviation: str
50
+
51
+
52
+ class CalculationResponse(BaseModel):
53
+ """Response from calculation endpoints (/calculate and /calculate/batch)."""
54
+
55
+ results: list[GuidelineResponse]
56
+ context: dict[str, Any] # Environmental context used for calculation
57
+ total_count: int
58
+
59
+
60
+ class CalculateRequest(BaseModel):
61
+ """Request body for single parameter calculation."""
62
+
63
+ parameter: str
64
+ media: str
65
+ context: Optional[dict[str, str]] = None # Environmental parameters as strings with units
66
+ target_unit: Optional[str] = None # Optional unit conversion
67
+
68
+
69
+ class ParameterWithUnit(BaseModel):
70
+ """Parameter specification with optional target unit for batch requests."""
71
+
72
+ name: str
73
+ target_unit: Optional[str] = None
74
+
75
+
76
+ class BatchCalculateRequest(BaseModel):
77
+ """Request body for batch parameter calculation."""
78
+
79
+ parameters: list[Union[str, ParameterWithUnit]] # Mix of strings and objects
80
+ media: str
81
+ context: Optional[dict[str, str]] = None
82
+
83
+ @model_validator(mode="after")
84
+ def validate_parameter_count(self) -> "BatchCalculateRequest":
85
+ """Ensure batch doesn't exceed 50 parameters.
86
+
87
+ This validation runs automatically when the model is instantiated,
88
+ raising ValueError if more than 50 parameters are provided.
89
+
90
+ Returns:
91
+ The validated model instance.
92
+
93
+ Raises:
94
+ ValueError: If parameters list contains more than 50 items.
95
+ """
96
+ if len(self.parameters) > 50:
97
+ raise ValueError("Maximum 50 parameters per batch request")
98
+ return self
99
+
100
+
101
+ class SearchParametersRequest(BaseModel):
102
+ """Request body for parameter search (optional media filter)."""
103
+
104
+ media: Optional[list[str]] = None
105
+
106
+
107
+ class MediaResponse(BaseModel):
108
+ """Response from /media endpoint mapping enum names to display names."""
109
+
110
+ # Dynamic keys, so use dict directly in practice
111
+ pass
112
+
113
+
114
+ class SourceDocument(BaseModel):
115
+ """Nested document information within a source."""
116
+
117
+ id: int
118
+ title: str
119
+ url: Optional[str] = None
120
+ year: Optional[int] = None
121
+
122
+
123
+ class SourceResponse(BaseModel):
124
+ """Guideline source with nested documents."""
125
+
126
+ id: int
127
+ name: str
128
+ abbreviation: str
129
+ documents: list[SourceDocument]
130
+
131
+
132
+ class StatsResponse(BaseModel):
133
+ """Database statistics response."""
134
+
135
+ total_parameters: int
136
+ total_guidelines: int
137
+ total_sources: int
138
+ total_documents: int
@@ -0,0 +1,338 @@
1
+ Metadata-Version: 2.4
2
+ Name: guidelinely
3
+ Version: 1.0.3
4
+ Summary: Python client for the Guidelinely Environmental Guidelines API
5
+ Author-email: Michael Davison <michael.davison@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mpdavison/guidelinely-python
8
+ Project-URL: Documentation, https://guidelines.1681248.com/docs
9
+ Project-URL: Repository, https://github.com/mpdavison/guidelinely-python
10
+ Project-URL: Bug Tracker, https://github.com/mpdavison/guidelinely-python/issues
11
+ Keywords: environmental,guidelines,api,water quality,soil,sediment
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: pydantic>=2.0.0
25
+ Requires-Dist: diskcache>=5.6.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
28
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
30
+ Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
31
+ Requires-Dist: responses>=0.23.0; extra == "dev"
32
+ Requires-Dist: black>=23.0.0; extra == "dev"
33
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
34
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
35
+ Requires-Dist: pre-commit>=3.0.0; extra == "dev"
36
+ Dynamic: license-file
37
+
38
+ # Guidelinely Python Client
39
+
40
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
41
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
42
+
43
+ Python client library for the [Guidelinely API](https://guidelines.1681248.com/docs) - an environmental guideline calculation and search API.
44
+
45
+ Calculate context-dependent environmental guideline values for chemical parameters (Aluminum, Copper, Lead, etc.) in various media (water, soil, sediment) based on environmental conditions (pH, hardness, temperature, etc.).
46
+
47
+ This Python client mirrors the functionality of the [R client](https://github.com/mpdavison/envguidelines).
48
+
49
+ ## Features
50
+
51
+
52
+ - ✅ **Metadata Queries**: List parameters, search, explore media types and sources
53
+ - ✅ **Single Calculations**: Calculate guidelines for individual parameters
54
+ - ✅ **Batch Calculations**: Efficiently calculate multiple parameters (up to 50)
55
+ - ✅ **Context-Aware**: Support for pH, hardness, temperature, and other environmental factors
56
+ - ✅ **Unit Conversion**: Optional target unit specification
57
+ - ✅ **Type Safety**: Full Pydantic model support for request/response validation
58
+ - ✅ **Comprehensive Tests**: Mock-based test suite with responses library
59
+
60
+ ## Installation
61
+
62
+ ```bash
63
+ # Clone the repository
64
+ git clone https://github.com/mpdavison/guidelinely-python.git
65
+ cd guidelinely-python
66
+
67
+ # Install in development mode
68
+ pip install -e .
69
+
70
+ # Or install with dev dependencies
71
+ pip install -e ".[dev]"
72
+ ```
73
+
74
+ ## Quick Start
75
+
76
+ ```python
77
+ from guidelinely import calculate_guidelines
78
+
79
+ # Calculate aluminum guidelines in surface water
80
+ result = calculate_guidelines(
81
+ parameter="Aluminum",
82
+ media="surface_water",
83
+ context={
84
+ "pH": "7.0 1", # pH 7.0 (dimensionless)
85
+ "hardness": "100 mg/L" # Water hardness as CaCO3
86
+ }
87
+ )
88
+
89
+ print(f"Found {result.total_count} guidelines")
90
+ for guideline in result.results:
91
+ print(f"{guideline.parameter}: {guideline.value} ({guideline.source})")
92
+ ```
93
+
94
+ ## API Key
95
+
96
+ Calculation endpoints optionally accept an API key. Set it as an environment variable:
97
+
98
+ ```bash
99
+ export GUIDELINELY_API_KEY="your_api_key_here"
100
+ ```
101
+
102
+ Metadata endpoints (list parameters, search, etc.) work without authentication.
103
+
104
+ ## Usage Examples
105
+
106
+ ### Basic Metadata Queries
107
+
108
+ ```python
109
+ from guidelinely import (
110
+ health_check,
111
+ list_parameters,
112
+ search_parameters,
113
+ list_media,
114
+ list_sources,
115
+ get_stats,
116
+ )
117
+
118
+ # Check API health
119
+ status = health_check()
120
+
121
+ # List all chemical parameters
122
+ params = list_parameters()
123
+ print(f"Available parameters: {len(params)}")
124
+
125
+ # Search for ammonia-related parameters
126
+ ammonia = search_parameters("ammon")
127
+ print(ammonia) # ['Ammonia', 'Ammonium', ...]
128
+
129
+ # Get available media types
130
+ media = list_media()
131
+ # {'surface_water': 'Surface Water', 'soil': 'Soil', ...}
132
+
133
+ # View guideline sources
134
+ sources = list_sources()
135
+
136
+ # Database statistics
137
+ stats = get_stats()
138
+ ```
139
+
140
+ ### Single Parameter Calculation
141
+
142
+ ```python
143
+ from guidelinely import calculate_guidelines
144
+ import os
145
+
146
+ os.environ["GUIDELINELY_API_KEY"] = "your_key"
147
+
148
+ result = calculate_guidelines(
149
+ parameter="Copper",
150
+ media="surface_water",
151
+ context={
152
+ "pH": "7.5 1",
153
+ "hardness": "150 mg/L",
154
+ "temperature": "15 °C",
155
+ "chloride": "75 mg/L"
156
+ },
157
+ target_unit="μg/L" # Optional unit conversion
158
+ )
159
+
160
+ # Filter results
161
+ chronic_aquatic = [
162
+ g for g in result.results
163
+ if g.receptor == "Aquatic Life" and g.exposure_duration == "chronic"
164
+ ]
165
+ ```
166
+
167
+ ### Batch Calculations
168
+
169
+ ```python
170
+ from guidelinely import calculate_batch
171
+
172
+ # Calculate multiple parameters at once (more efficient)
173
+ result = calculate_batch(
174
+ parameters=["Aluminum", "Copper", "Lead", "Zinc", "Cadmium"],
175
+ media="surface_water",
176
+ context={
177
+ "pH": "7.5 1",
178
+ "hardness": "150 mg/L",
179
+ "temperature": "15 °C"
180
+ }
181
+ )
182
+
183
+ print(f"Total: {result.total_count} guidelines")
184
+
185
+ # With per-parameter unit conversion
186
+ result = calculate_batch(
187
+ parameters=[
188
+ "Aluminum",
189
+ {"name": "Copper", "target_unit": "μg/L"},
190
+ {"name": "Lead", "target_unit": "mg/L"}
191
+ ],
192
+ media="surface_water",
193
+ context={"pH": "7.0 1", "hardness": "100 mg/L"}
194
+ )
195
+ ```
196
+
197
+ ### Soil Calculations
198
+
199
+ ```python
200
+ result = calculate_guidelines(
201
+ parameter="Lead",
202
+ media="soil",
203
+ context={
204
+ "pH": "6.5 1",
205
+ "organic_matter": "3.5 %",
206
+ "cation_exchange_capacity": "15 meq/100g"
207
+ }
208
+ )
209
+ ```
210
+
211
+ ## Environmental Context Parameters
212
+
213
+ All context parameters **must be strings with units** (Pint format):
214
+
215
+ ### Water (surface_water, groundwater)
216
+ ```python
217
+ context = {
218
+ "pH": "7.0 1", # Dimensionless - use "1" as unit
219
+ "hardness": "100 mg/L", # mg/L as CaCO3
220
+ "temperature": "20 °C",
221
+ "chloride": "50 mg/L"
222
+ }
223
+ ```
224
+
225
+ ### Soil
226
+ ```python
227
+ context = {
228
+ "pH": "6.5 1",
229
+ "organic_matter": "3.5 %",
230
+ "cation_exchange_capacity": "15 meq/100g"
231
+ }
232
+ ```
233
+
234
+ ### Sediment
235
+ ```python
236
+ context = {
237
+ "pH": "7.0 1",
238
+ "organic_matter": "2.5 %",
239
+ "grain_size": "0.5 mm"
240
+ }
241
+ ```
242
+
243
+ ## Data Models
244
+
245
+ The library uses Pydantic for type-safe data handling:
246
+
247
+ ```python
248
+ from guidelinely.models import GuidelineResponse, CalculationResponse
249
+
250
+ # All API responses are strongly typed
251
+ result: CalculationResponse = calculate_guidelines(...)
252
+
253
+ # Access typed fields
254
+ for guideline in result.results:
255
+ guideline.parameter # str
256
+ guideline.value # str (PostgreSQL unitrange format)
257
+ guideline.lower # Optional[float]
258
+ guideline.upper # Optional[float]
259
+ guideline.unit # str
260
+ guideline.is_calculated # bool
261
+ ```
262
+
263
+ ## Guideline Value Format
264
+
265
+ Guidelines use PostgreSQL `unitrange` format:
266
+ - `[10 μg/L,100 μg/L]` - Range from 10 to 100 μg/L
267
+ - `(,87.0 μg/L]` - Upper limit only (≤87.0 μg/L)
268
+ - `[5.0 mg/L,)` - Lower limit only (≥5.0 mg/L)
269
+
270
+ Parsed into `lower`, `upper`, and `unit` fields.
271
+
272
+ ## Development
273
+
274
+ ```bash
275
+ # Install development dependencies
276
+ pip install -e ".[dev]"
277
+
278
+ # Run tests
279
+ pytest
280
+
281
+ # Run tests with coverage
282
+ pytest --cov=guidelinely --cov-report=html
283
+
284
+ # Format code
285
+ black guidelinely/ tests/ examples/
286
+
287
+ # Type checking
288
+ mypy guidelinely/
289
+
290
+ # Linting
291
+ ruff check guidelinely/ tests/
292
+ ```
293
+
294
+ ## API Reference
295
+
296
+ ### Client Functions
297
+
298
+ - `health_check()` - Service health check
299
+ - `readiness_check()` - Database readiness check
300
+ - `list_parameters()` - List all chemical parameters
301
+ - `search_parameters(q, media)` - Search parameters
302
+ - `list_media()` - List media types
303
+ - `list_sources()` - List guideline sources
304
+ - `get_stats()` - Database statistics
305
+ - `calculate_guidelines(parameter, media, context, target_unit, api_key)` - Calculate single parameter
306
+ - `calculate_batch(parameters, media, context, api_key)` - Batch calculate (max 50 parameters)
307
+
308
+ ### Models
309
+
310
+ - `GuidelineResponse` - Single guideline result
311
+ - `CalculationResponse` - Calculation endpoint response
312
+ - `CalculateRequest` - Single calculation request body
313
+ - `BatchCalculateRequest` - Batch calculation request body
314
+
315
+ ## Examples
316
+
317
+ See the `examples/` directory for complete working examples:
318
+
319
+ 1. `01_basic_metadata.py` - Basic metadata queries
320
+ 2. `02_calculate_single_parameter.py` - Single parameter calculations
321
+ 3. `03_batch_calculations.py` - Batch calculations
322
+ 4. `04_soil_calculations.py` - Soil-specific calculations
323
+ 5. `05_advanced_workflow.py` - Advanced filtering and analysis
324
+
325
+ ## Resources
326
+
327
+ - **API Documentation**: https://guidelines.1681248.com/docs
328
+ - **OpenAPI Spec**: https://guidelines.1681248.com/openapi.json
329
+ - **R Client**: https://github.com/mpdavison/envguidelines
330
+ - **Issue Tracker**: https://github.com/mpdavison/guidelinely-python/issues
331
+
332
+ ## License
333
+
334
+ MIT License - see [LICENSE](LICENSE) file for details.
335
+
336
+ ## Contributing
337
+
338
+ Contributions are welcome! Please feel free to submit a Pull Request.
@@ -0,0 +1,12 @@
1
+ guidelinely/__init__.py,sha256=IjusaUQolmVzQl5pajYzz7WTJrxpha2leVwM2NbLqgU,1395
2
+ guidelinely/_version.py,sha256=l8k828IdTfzXAlmx4oT8GsiIf2eeMAlFDALjoYk-jrU,704
3
+ guidelinely/auth.py,sha256=hGrmMGQ9Ye9555yOJglrHOGzS3clU7z9QTg-gRYNOVo,704
4
+ guidelinely/cache.py,sha256=V9bXeyT9_FR1ijRFXu_7R7rcZwmtBjE8TJx-ZWxniDM,1537
5
+ guidelinely/client.py,sha256=nBXvIKHD0xYskbtjun5w7wYO5vHQK8gkxtJZx2XclkE,16808
6
+ guidelinely/exceptions.py,sha256=d_HaNw7nqzyv-w2hRNMj2YXx2qJoOj_NkHft7fZED8k,2508
7
+ guidelinely/models.py,sha256=Z8q1fptPa3SNuSRtwtQV8CIiIEz8cmTKRi99m_4w4A4,3817
8
+ guidelinely-1.0.3.dist-info/licenses/LICENSE,sha256=0zH1WeuuqGNzsoBXBmOfaW_hqqVAUefUNoWYm88CdwM,1081
9
+ guidelinely-1.0.3.dist-info/METADATA,sha256=QB6DnTRf5OGQrCdngZn0aAS3yHKE1tvCHo-5nhIXErY,9397
10
+ guidelinely-1.0.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ guidelinely-1.0.3.dist-info/top_level.txt,sha256=zrK-evc6akaqjoD3VabEnbc1xmz_RSDDKofUnQ7COlU,12
12
+ guidelinely-1.0.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Guidelinely Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ guidelinely