sgu-client 0.1.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.
sgu_client/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """SGU Client - Python library for accessing SGU groundwater data."""
2
+
3
+ from .config import SGUConfig
4
+ from .exceptions import (
5
+ SGUAPIError,
6
+ SGUClientError,
7
+ SGUConnectionError,
8
+ SGUTimeoutError,
9
+ SGUValidationError,
10
+ )
11
+ from .sgu_client import SGUClient
12
+
13
+ __version__ = "0.1.0"
14
+ __all__ = [
15
+ "SGUAPIError",
16
+ "SGUClient",
17
+ "SGUClientError",
18
+ "SGUConfig",
19
+ "SGUConnectionError",
20
+ "SGUTimeoutError",
21
+ "SGUValidationError",
22
+ ]
@@ -0,0 +1,5 @@
1
+ """Client module for SGU API."""
2
+
3
+ from .base import BaseClient
4
+
5
+ __all__ = ["BaseClient"]
@@ -0,0 +1,200 @@
1
+ """Base HTTP client for SGU API."""
2
+
3
+ import logging
4
+ from typing import Any
5
+ from urllib.parse import urljoin
6
+
7
+ import requests
8
+ from requests.adapters import HTTPAdapter
9
+ from urllib3.util.retry import Retry
10
+
11
+ from sgu_client.config import SGUConfig
12
+ from sgu_client.exceptions import SGUAPIError, SGUConnectionError, SGUTimeoutError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class BaseClient:
18
+ """Base HTTP client with common functionality for SGU API."""
19
+
20
+ def __init__(self, config: SGUConfig | None = None):
21
+ """Initialize the base client.
22
+
23
+ Args:
24
+ config: Configuration object. If None, uses default config.
25
+ """
26
+ self.config = config or SGUConfig()
27
+ self._session = self._create_session()
28
+
29
+ if self.config.debug:
30
+ logging.basicConfig(level=logging.DEBUG)
31
+
32
+ def _create_session(self) -> requests.Session:
33
+ """Create and configure a requests session."""
34
+ session = requests.Session()
35
+
36
+ # Set headers
37
+ session.headers.update(
38
+ {
39
+ "User-Agent": self.config.user_agent,
40
+ "Accept": "application/json",
41
+ }
42
+ )
43
+
44
+ # Configure retry strategy
45
+ retry_strategy = Retry(
46
+ total=self.config.max_retries,
47
+ backoff_factor=1,
48
+ status_forcelist=[429, 500, 502, 503, 504],
49
+ )
50
+
51
+ adapter = HTTPAdapter(max_retries=retry_strategy)
52
+ session.mount("http://", adapter)
53
+ session.mount("https://", adapter)
54
+
55
+ return session
56
+
57
+ def _make_request(
58
+ self,
59
+ method: str,
60
+ endpoint: str,
61
+ params: dict[str, Any] | None = None,
62
+ data: dict[str, Any] | None = None,
63
+ base_url: str | None = None,
64
+ **kwargs,
65
+ ) -> dict[str, Any]:
66
+ """Make an HTTP request to the SGU API.
67
+
68
+ Args:
69
+ method: HTTP method (GET, POST, etc.)
70
+ endpoint: API endpoint path
71
+ params: Query parameters
72
+ data: Request body data
73
+ base_url: Optional override for base URL (for different API endpoints)
74
+ **kwargs: Additional arguments passed to requests
75
+
76
+ Returns:
77
+ JSON response data
78
+
79
+ Raises:
80
+ SGUConnectionError: If connection fails
81
+ SGUTimeoutError: If request times out
82
+ SGUAPIError: If API returns an error
83
+ """
84
+ url = urljoin(base_url or self.config.base_url, endpoint)
85
+
86
+ try:
87
+ if self.config.debug:
88
+ logger.debug(f"Making {method} request to {url}")
89
+ if params:
90
+ logger.debug(f"Query params: {params}")
91
+ if data:
92
+ logger.debug(f"Request data: {data}")
93
+
94
+ response = self._session.request(
95
+ method=method,
96
+ url=url,
97
+ params=params,
98
+ json=data,
99
+ timeout=self.config.timeout,
100
+ **kwargs,
101
+ )
102
+
103
+ if self.config.debug:
104
+ logger.debug(f"Response status: {response.status_code}")
105
+
106
+ # Check for HTTP errors
107
+ if not response.ok:
108
+ try:
109
+ error_data = response.json()
110
+ except ValueError:
111
+ error_data = {"error": response.text}
112
+
113
+ raise SGUAPIError(
114
+ f"API request failed with status {response.status_code}",
115
+ status_code=response.status_code,
116
+ response_data=error_data,
117
+ )
118
+
119
+ return response.json()
120
+
121
+ except requests.exceptions.ReadTimeout as e:
122
+ raise SGUTimeoutError(f"Read timeout after {self.config.timeout}s") from e
123
+ except requests.exceptions.ConnectTimeout as e:
124
+ raise SGUTimeoutError(
125
+ f"Connection timeout after {self.config.timeout}s"
126
+ ) from e
127
+ except requests.exceptions.ConnectionError as e:
128
+ # Check if this is a timeout wrapped in MaxRetryError
129
+ if "Read timed out" in str(e) or "ReadTimeoutError" in str(e):
130
+ raise SGUTimeoutError(
131
+ f"Read timeout after {self.config.timeout}s"
132
+ ) from e
133
+ raise SGUConnectionError(f"Connection failed: {e}") from e
134
+ except requests.exceptions.RequestException as e:
135
+ raise SGUAPIError(f"Request failed: {e}") from e
136
+
137
+ def get(
138
+ self,
139
+ endpoint: str,
140
+ params: dict[str, Any] | None = None,
141
+ base_url: str | None = None,
142
+ **kwargs,
143
+ ) -> dict[str, Any]:
144
+ """Make a GET request.
145
+
146
+ Args:
147
+ endpoint: API endpoint path
148
+ params: Query parameters
149
+ base_url: Optional override for base URL
150
+ **kwargs: Additional arguments passed to requests
151
+
152
+ Returns:
153
+ JSON response data
154
+
155
+ Raises:
156
+ SGUConnectionError: If connection fails
157
+ SGUTimeoutError: If request times out
158
+ SGUAPIError: If API returns an error
159
+ """
160
+ return self._make_request(
161
+ "GET", endpoint, params=params, base_url=base_url, **kwargs
162
+ )
163
+
164
+ def post(
165
+ self, endpoint: str, data: dict[str, Any] | None = None, **kwargs
166
+ ) -> dict[str, Any]:
167
+ """Make a POST request.
168
+
169
+ Args:
170
+ endpoint: API endpoint path
171
+ data: Request body data
172
+ **kwargs: Additional arguments passed to requests
173
+
174
+ Returns:
175
+ JSON response data
176
+
177
+ Raises:
178
+ SGUConnectionError: If connection fails
179
+ SGUTimeoutError: If request times out
180
+ SGUAPIError: If API returns an error
181
+ """
182
+ return self._make_request("POST", endpoint, data=data, **kwargs)
183
+
184
+ def __enter__(self):
185
+ """Context manager entry.
186
+
187
+ Returns:
188
+ The client instance for use in with statements
189
+ """
190
+ return self
191
+
192
+ def __exit__(self, exc_type, exc_val, exc_tb):
193
+ """Context manager exit - clean up resources.
194
+
195
+ Args:
196
+ exc_type: Exception type (if any)
197
+ exc_val: Exception value (if any)
198
+ exc_tb: Exception traceback (if any)
199
+ """
200
+ self._session.close()
@@ -0,0 +1,30 @@
1
+ """Levels client module."""
2
+
3
+ from .modeled import ModeledGroundwaterLevelClient
4
+ from .observed import ObservedGroundwaterLevelClient
5
+
6
+
7
+ class LevelsClient:
8
+ """Client for accessing groundwater level data.
9
+
10
+ Provides access to both observed and modeled groundwater levels
11
+ through a hierarchical API structure.
12
+
13
+ Example:
14
+ >>> client = SGUClient()
15
+ >>> stations = client.levels.observed.get_stations()
16
+ >>> areas = client.levels.modeled.get_areas()
17
+ >>> levels = client.levels.modeled.get_levels()
18
+ """
19
+
20
+ def __init__(self, base_client):
21
+ """Initialize the levels client.
22
+
23
+ Args:
24
+ base_client: The base HTTP client instance.
25
+ """
26
+ self.observed = ObservedGroundwaterLevelClient(base_client)
27
+ self.modeled = ModeledGroundwaterLevelClient(base_client)
28
+
29
+
30
+ __all__ = ["LevelsClient"]
@@ -0,0 +1,275 @@
1
+ """Modeled groundwater level client endpoints."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from sgu_client.client.base import BaseClient
7
+ from sgu_client.models.modeled import (
8
+ ModeledArea,
9
+ ModeledAreaCollection,
10
+ ModeledGroundwaterLevel,
11
+ ModeledGroundwaterLevelCollection,
12
+ )
13
+
14
+
15
+ class ModeledGroundwaterLevelClient:
16
+ """Client for modeled groundwater level-related SGU API endpoints."""
17
+
18
+ BASE_PATH = "collections"
19
+ MODELED_BASE_URL = "https://api.sgu.se/oppnadata/grundvattennivaer-sgu-hype-omraden/ogc/features/v1/"
20
+
21
+ def __init__(self, base_client: BaseClient):
22
+ """Initialize modeled groundwater level client.
23
+
24
+ Args:
25
+ base_client: Base HTTP client instance
26
+ """
27
+ self._client = base_client
28
+
29
+ def get_areas(
30
+ self,
31
+ bbox: list[float] | None = None,
32
+ limit: int | None = None,
33
+ filter_expr: str | None = None,
34
+ sortby: list[str] | None = None,
35
+ **kwargs: Any,
36
+ ) -> ModeledAreaCollection:
37
+ """Get modeled groundwater areas.
38
+
39
+ Args:
40
+ bbox: Bounding box as [min_lon, min_lat, max_lon, max_lat]
41
+ limit: Maximum number of features to return (1-50000, default 50000)
42
+ filter_expr: CQL filter expression
43
+ sortby: List of sort expressions (e.g., ['+omrade_id'])
44
+ **kwargs: Additional query parameters
45
+
46
+ Returns:
47
+ Typed collection of modeled groundwater areas
48
+ """
49
+ endpoint = f"{self.BASE_PATH}/omraden/items"
50
+ params = self._build_query_params(
51
+ bbox=bbox,
52
+ limit=limit,
53
+ filter=filter_expr,
54
+ sortby=sortby,
55
+ **kwargs,
56
+ )
57
+ response = self._make_request(endpoint, params)
58
+ return ModeledAreaCollection(**response)
59
+
60
+ def get_area(self, area_id: str) -> ModeledArea:
61
+ """Get a specific modeled groundwater area by ID.
62
+
63
+ Args:
64
+ area_id: Area identifier
65
+
66
+ Returns:
67
+ Typed modeled groundwater area
68
+
69
+ Raises:
70
+ ValueError: If area not found or multiple areas returned
71
+ """
72
+ endpoint = f"{self.BASE_PATH}/omraden/items/{area_id}"
73
+ response = self._make_request(endpoint, {})
74
+
75
+ # SGU API returns a FeatureCollection even for single items
76
+ collection = ModeledAreaCollection(**response)
77
+ if not collection.features:
78
+ raise ValueError(f"Area {area_id} not found")
79
+ if len(collection.features) > 1:
80
+ raise ValueError(f"Multiple areas returned for ID {area_id}")
81
+
82
+ return collection.features[0]
83
+
84
+ def get_levels(
85
+ self,
86
+ bbox: list[float] | None = None,
87
+ datetime: str | None = None,
88
+ limit: int | None = None,
89
+ filter_expr: str | None = None,
90
+ sortby: list[str] | None = None,
91
+ **kwargs: Any,
92
+ ) -> ModeledGroundwaterLevelCollection:
93
+ """Get modeled groundwater levels.
94
+
95
+ Args:
96
+ bbox: Bounding box as [min_lon, min_lat, max_lon, max_lat]
97
+ datetime: Date/time filter (RFC 3339 format or interval)
98
+ limit: Maximum number of features to return (1-50000, default 50000)
99
+ filter_expr: CQL filter expression
100
+ sortby: List of sort expressions (e.g., ['+datum', '-omrade_id'])
101
+ **kwargs: Additional query parameters
102
+
103
+ Returns:
104
+ Typed collection of modeled groundwater levels
105
+ """
106
+ endpoint = f"{self.BASE_PATH}/grundvattennivaer-tidigare/items"
107
+ params = self._build_query_params(
108
+ bbox=bbox,
109
+ datetime=datetime,
110
+ limit=limit,
111
+ filter=filter_expr,
112
+ sortby=sortby,
113
+ **kwargs,
114
+ )
115
+ response = self._make_request(endpoint, params)
116
+ return ModeledGroundwaterLevelCollection(**response)
117
+
118
+ def get_level(self, level_id: str) -> ModeledGroundwaterLevel:
119
+ """Get a specific modeled groundwater level by ID.
120
+
121
+ Args:
122
+ level_id: Level identifier
123
+
124
+ Returns:
125
+ Typed modeled groundwater level
126
+
127
+ Raises:
128
+ ValueError: If level not found or multiple levels returned
129
+ """
130
+ endpoint = f"{self.BASE_PATH}/grundvattennivaer-tidigare/items/{level_id}"
131
+ response = self._make_request(endpoint, {})
132
+
133
+ # SGU API returns a FeatureCollection even for single items
134
+ collection = ModeledGroundwaterLevelCollection(**response)
135
+ if not collection.features:
136
+ raise ValueError(f"Level {level_id} not found")
137
+ if len(collection.features) > 1:
138
+ raise ValueError(f"Multiple levels returned for ID {level_id}")
139
+
140
+ return collection.features[0]
141
+
142
+ def get_levels_by_area(
143
+ self, area_id: int, **kwargs: Any
144
+ ) -> ModeledGroundwaterLevelCollection:
145
+ """Get modeled groundwater levels for a specific area.
146
+
147
+ Args:
148
+ area_id: Area ID to filter by
149
+ **kwargs: Additional query parameters (limit, datetime, bbox, etc.)
150
+
151
+ Returns:
152
+ Typed collection of modeled groundwater levels for the specified area
153
+ """
154
+ filter_expr = f"omrade_id = {area_id}"
155
+ return self.get_levels(filter_expr=filter_expr, **kwargs)
156
+
157
+ def get_levels_by_areas(
158
+ self, area_ids: list[int], **kwargs: Any
159
+ ) -> ModeledGroundwaterLevelCollection:
160
+ """Get modeled groundwater levels for multiple areas.
161
+
162
+ Args:
163
+ area_ids: List of area IDs to filter by
164
+ **kwargs: Additional query parameters (limit, datetime, bbox, etc.)
165
+
166
+ Returns:
167
+ Typed collection of modeled groundwater levels for the specified areas
168
+ """
169
+ if not area_ids:
170
+ raise ValueError("At least one area ID must be provided")
171
+
172
+ # Create CQL filter expression for multiple area IDs
173
+ if len(area_ids) == 1:
174
+ filter_expr = f"omrade_id = {area_ids[0]}"
175
+ else:
176
+ area_ids_str = ", ".join(str(area_id) for area_id in area_ids)
177
+ filter_expr = f"omrade_id IN ({area_ids_str})"
178
+
179
+ return self.get_levels(filter_expr=filter_expr, **kwargs)
180
+
181
+ def get_levels_by_coords(
182
+ self,
183
+ lat: float,
184
+ lon: float,
185
+ buffer: float = 0.01,
186
+ **kwargs: Any,
187
+ ) -> ModeledGroundwaterLevelCollection:
188
+ """Get modeled groundwater levels for a specific coordinate.
189
+
190
+ This convenience function first finds relevant areas containing or near
191
+ the specified coordinates, then retrieves modeled levels for those areas.
192
+
193
+ Args:
194
+ lat: Latitude coordinate (WGS84)
195
+ lon: Longitude coordinate (WGS84)
196
+ buffer: Buffer distance in degrees around the point (default 0.01 ≈ 1km)
197
+ **kwargs: Additional query parameters (limit, datetime, etc.)
198
+
199
+ Returns:
200
+ Typed collection of modeled groundwater levels for areas near the coordinates
201
+
202
+ Raises:
203
+ ValueError: If no areas found near the specified coordinates
204
+ """
205
+ logger = logging.getLogger(__name__)
206
+
207
+ # Create bounding box around the point
208
+ bbox = [
209
+ lon - buffer, # min_lon
210
+ lat - buffer, # min_lat
211
+ lon + buffer, # max_lon
212
+ lat + buffer, # max_lat
213
+ ]
214
+
215
+ # Find areas within the bounding box
216
+ areas = self.get_areas(bbox=bbox, limit=1000)
217
+
218
+ if not areas.features:
219
+ raise ValueError(
220
+ f"No modeled groundwater areas found near coordinates "
221
+ f"({lat}, {lon}) within {buffer}° buffer"
222
+ )
223
+
224
+ # Extract area IDs
225
+ area_ids = [int(area.properties.omrade_id) for area in areas.features]
226
+
227
+ # Log warning if multiple areas found (near boundary)
228
+ if len(area_ids) > 1:
229
+ logger.warning(
230
+ f"Found {len(area_ids)} modeled areas near coordinates "
231
+ f"({lat}, {lon}). This suggests the point is close to an area boundary. "
232
+ f"Area IDs: {area_ids}. All areas will be included in the results."
233
+ )
234
+
235
+ # Get levels for all found areas
236
+ return self.get_levels_by_areas(area_ids, **kwargs)
237
+
238
+ def _build_query_params(self, **params: Any) -> dict[str, Any]:
239
+ """Build query parameters for API requests.
240
+
241
+ Args:
242
+ **params: Raw parameter values
243
+
244
+ Returns:
245
+ Cleaned dictionary of query parameters
246
+ """
247
+ query_params = {}
248
+
249
+ for key, value in params.items():
250
+ if value is None:
251
+ continue
252
+
253
+ if key == "bbox" and isinstance(value, list):
254
+ query_params[key] = ",".join(map(str, value))
255
+ elif key == "sortby" and isinstance(value, list):
256
+ query_params[key] = ",".join(value)
257
+ else:
258
+ query_params[key] = value
259
+
260
+ return query_params
261
+
262
+ def _make_request(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
263
+ """Make HTTP request to SGU API.
264
+
265
+ Args:
266
+ endpoint: API endpoint path
267
+ params: Query parameters
268
+
269
+ Returns:
270
+ JSON response data
271
+
272
+ Raises:
273
+ Various HTTP and API exceptions via base client
274
+ """
275
+ return self._client.get(endpoint, params=params, base_url=self.MODELED_BASE_URL)