terrakio-core 0.3.4__py3-none-any.whl → 0.3.6__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.

Potentially problematic release.


This version of terrakio-core might be problematic. Click here for more details.

terrakio_core/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ # terrakio_core/__init__.py
1
2
  """
2
3
  Terrakio Core
3
4
 
@@ -5,3 +6,11 @@ Core components for Terrakio API clients.
5
6
  """
6
7
 
7
8
  __version__ = "0.3.4"
9
+
10
+ from .async_client import AsyncClient
11
+ from .sync_client import SyncClient
12
+
13
+ __all__ = [
14
+ "AsyncClient",
15
+ "SyncClient"
16
+ ]
@@ -0,0 +1,304 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import json
4
+ import pandas as pd
5
+ import xarray as xr
6
+ from io import BytesIO
7
+ from typing import Optional, Dict, Any, Union
8
+ from geopandas import GeoDataFrame
9
+ from shapely.geometry.base import BaseGeometry as ShapelyGeometry
10
+ from shapely.geometry import mapping
11
+ from .client import BaseClient
12
+ from .exceptions import APIError
13
+ from .endpoints.dataset_management import DatasetManagement
14
+ from .endpoints.user_management import UserManagement
15
+ from .endpoints.mass_stats import MassStats
16
+ from .endpoints.group_management import GroupManagement
17
+ from .endpoints.space_management import SpaceManagement
18
+ from .endpoints.model_management import ModelManagement
19
+ from .endpoints.auth import AuthClient
20
+ from .convenience_functions.convenience_functions import zonal_stats as _zonal_stats, create_dataset_file as _create_dataset_file
21
+
22
+ class AsyncClient(BaseClient):
23
+ def __init__(self, url: Optional[str] = None, api_key: Optional[str] = None, verbose: bool = False, session: Optional[aiohttp.ClientSession] = None):
24
+ super().__init__(url, api_key, verbose)
25
+ self.datasets = DatasetManagement(self)
26
+ self.users = UserManagement(self)
27
+ self.mass_stats = MassStats(self)
28
+ self.groups = GroupManagement(self)
29
+ self.space = SpaceManagement(self)
30
+ self.model = ModelManagement(self)
31
+ self.auth = AuthClient(self)
32
+ self._session = session
33
+ self._owns_session = session is None
34
+
35
+ async def _terrakio_request(self, method: str, endpoint: str, **kwargs):
36
+ # if self.session is None:
37
+ if self.session is None:
38
+ headers = {
39
+ 'Content-Type': 'application/json',
40
+ 'x-api-key': self.key,
41
+ 'Authorization': self.token
42
+ }
43
+ clean_headers = {k: v for k, v in headers.items() if v is not None}
44
+ async with aiohttp.ClientSession(headers=clean_headers, timeout=aiohttp.ClientTimeout(total=self.timeout)) as session:
45
+ return await self._make_request_with_retry(session, method, endpoint, **kwargs)
46
+ else:
47
+ return await self._make_request_with_retry(self._session, method, endpoint, **kwargs)
48
+
49
+
50
+ async def _make_request_with_retry(self, session: aiohttp.ClientSession, method: str, endpoint: str, **kwargs) -> Dict[Any, Any]:
51
+ url = f"{self.url}/{endpoint.lstrip('/')}"
52
+ for attempt in range(self.retry + 1):
53
+ try:
54
+ async with session.request(method, url, **kwargs) as response:
55
+ response_text = await response.text()
56
+ if not response.ok:
57
+ should_retry = False
58
+
59
+ if response.status == 400:
60
+ should_retry = False
61
+ else:
62
+ if response.status in [408, 502, 503, 504]:
63
+ should_retry = True
64
+ elif response.status == 500:
65
+ try:
66
+ response_text = await response.text()
67
+ if "Internal server error" not in response_text:
68
+ should_retry = True
69
+ except:
70
+ should_retry = True
71
+
72
+ if should_retry and attempt < self.retry:
73
+ self.logger.info(f"Request failed (attempt {attempt+1}/{self.retry+1}): {response.status} {response.reason}. Retrying...")
74
+ continue
75
+ else:
76
+ error_msg = f"API request failed: {response.status} {response.reason}"
77
+ try:
78
+ error_data = await response.json()
79
+ if "detail" in error_data:
80
+ error_msg += f" - {error_data['detail']}"
81
+ except:
82
+ pass
83
+ raise APIError(error_msg, status_code=response.status)
84
+
85
+ content_type = response.headers.get('content-type', '').lower()
86
+ content = await response.read()
87
+ if 'json' in content_type:
88
+ return json.loads(content.decode('utf-8'))
89
+ elif 'csv' in content_type:
90
+ return pd.read_csv(BytesIO(content))
91
+ elif 'image/' in content_type:
92
+ return content
93
+ elif 'text' in content_type:
94
+ return content.decode('utf-8')
95
+ else:
96
+ try:
97
+ return xr.open_dataset(BytesIO(content))
98
+ except:
99
+ raise APIError(f"Unknown response format. Content-Type: {response.headers.get('content-type', 'unknown')}", status_code=response.status)
100
+ except aiohttp.ClientError as e:
101
+ if attempt < self.retry:
102
+ self.logger.info(f"Request failed (attempt {attempt+1}/{self.retry+1}): {e}. Retrying...")
103
+ continue
104
+ else:
105
+ raise APIError(f"Request failed after {self.retry+1} attempts: {e}", status_code=None)
106
+
107
+
108
+ async def _regular_request(self, method: str, endpoint: str, **kwargs):
109
+ url = endpoint.lstrip('/')
110
+ if self._session is None:
111
+
112
+ async with aiohttp.ClientSession() as session:
113
+ try:
114
+ async with session.request(method, url, **kwargs) as response:
115
+ response.raise_for_status()
116
+ return response
117
+ except aiohttp.ClientError as e:
118
+ raise APIError(f"Request failed: {e}")
119
+ else:
120
+ # this means that we used the with statement, and we already have a session
121
+ try:
122
+ async with self._session.request(method, url, **kwargs) as response:
123
+ response.raise_for_status()
124
+ return response
125
+ except aiohttp.ClientError as e:
126
+ raise APIError(f"Request failed: {e}")
127
+
128
+ async def geoquery(
129
+ self,
130
+ expr: str,
131
+ feature: Union[Dict[str, Any], ShapelyGeometry],
132
+ in_crs: str = "epsg:4326",
133
+ out_crs: str = "epsg:4326",
134
+ output: str = "csv",
135
+ resolution: int = -1,
136
+ geom_fix: bool = False,
137
+ **kwargs
138
+ ):
139
+ """
140
+ Compute WCS query for a single geometry.
141
+
142
+ Args:
143
+ expr (str): The WCS expression to evaluate
144
+ feature (Union[Dict[str, Any], ShapelyGeometry]): The geographic feature
145
+ in_crs (str): Input coordinate reference system
146
+ out_crs (str): Output coordinate reference system
147
+ output (str): Output format ('csv' or 'netcdf')
148
+ resolution (int): Resolution parameter
149
+ geom_fix (bool): Whether to fix the geometry (default False)
150
+ **kwargs: Additional parameters to pass to the WCS request
151
+
152
+ Returns:
153
+ Union[pd.DataFrame, xr.Dataset, bytes]: The response data in the requested format
154
+
155
+ Raises:
156
+ APIError: If the API request fails
157
+ """
158
+ if hasattr(feature, 'is_valid'):
159
+ feature = {
160
+ "type": "Feature",
161
+ "geometry": mapping(feature),
162
+ "properties": {}
163
+ }
164
+ payload = {
165
+ "feature": feature,
166
+ "in_crs": in_crs,
167
+ "out_crs": out_crs,
168
+ "output": output,
169
+ "resolution": resolution,
170
+ "expr": expr,
171
+ "buffer": geom_fix,
172
+ **kwargs
173
+ }
174
+ return await self._terrakio_request("POST", "geoquery", json=payload)
175
+
176
+ async def zonal_stats(
177
+ self,
178
+ gdf: GeoDataFrame,
179
+ expr: str,
180
+ conc: int = 20,
181
+ inplace: bool = False,
182
+ in_crs: str = "epsg:4326",
183
+ out_crs: str = "epsg:4326",
184
+ resolution: int = -1,
185
+ geom_fix: bool = False,
186
+ ):
187
+ """
188
+ Compute zonal statistics for all geometries in a GeoDataFrame.
189
+
190
+ Args:
191
+ gdf (GeoDataFrame): GeoDataFrame containing geometries
192
+ expr (str): Terrakio expression to evaluate, can include spatial aggregations
193
+ conc (int): Number of concurrent requests to make
194
+ inplace (bool): Whether to modify the input GeoDataFrame in place
195
+ in_crs (str): Input coordinate reference system
196
+ out_crs (str): Output coordinate reference system
197
+ resolution (int): Resolution parameter
198
+ geom_fix (bool): Whether to fix the geometry (default False)
199
+ Returns:
200
+ geopandas.GeoDataFrame: GeoDataFrame with added columns for results, or None if inplace=True
201
+
202
+ Raises:
203
+ ValueError: If concurrency is too high
204
+ APIError: If the API request fails
205
+ """
206
+ return await _zonal_stats(
207
+ client=self,
208
+ gdf=gdf,
209
+ expr=expr,
210
+ conc=conc,
211
+ inplace=inplace,
212
+ in_crs=in_crs,
213
+ out_crs=out_crs,
214
+ resolution=resolution,
215
+ geom_fix=geom_fix
216
+ )
217
+
218
+ async def create_dataset_file(
219
+ self,
220
+ aoi: str,
221
+ expression: str,
222
+ output: str,
223
+ in_crs: str = "epsg:4326",
224
+ res: float = 0.0001,
225
+ region: str = "aus",
226
+ to_crs: str = "epsg:4326",
227
+ overwrite: bool = True,
228
+ skip_existing: bool = False,
229
+ non_interactive: bool = True,
230
+ poll_interval: int = 30,
231
+ download_path: str = "/home/user/Downloads",
232
+ ) -> dict:
233
+ """
234
+ Create a dataset file using mass stats operations.
235
+
236
+ Args:
237
+ aoi (str): Area of interest
238
+ expression (str): Terrakio expression to evaluate
239
+ output (str): Output format
240
+ in_crs (str): Input coordinate reference system (default "epsg:4326")
241
+ res (float): Resolution (default 0.0001)
242
+ region (str): Region (default "aus")
243
+ to_crs (str): Target coordinate reference system (default "epsg:4326")
244
+ overwrite (bool): Whether to overwrite existing files (default True)
245
+ skip_existing (bool): Whether to skip existing files (default False)
246
+ non_interactive (bool): Whether to run non-interactively (default True)
247
+ poll_interval (int): Polling interval in seconds (default 30)
248
+ download_path (str): Download path (default "/home/user/Downloads")
249
+
250
+ Returns:
251
+ dict: Dictionary containing generation_task_id and combine_task_id
252
+
253
+ Raises:
254
+ ConfigurationError: If mass stats client is not properly configured
255
+ RuntimeError: If job fails
256
+ """
257
+ return await _create_dataset_file(
258
+ client=self,
259
+ aoi=aoi,
260
+ expression=expression,
261
+ output=output,
262
+ in_crs=in_crs,
263
+ res=res,
264
+ region=region,
265
+ to_crs=to_crs,
266
+ overwrite=overwrite,
267
+ skip_existing=skip_existing,
268
+ non_interactive=non_interactive,
269
+ poll_interval=poll_interval,
270
+ download_path=download_path,
271
+ )
272
+
273
+ async def __aenter__(self):
274
+ # if there is no session, we create a session
275
+ if self._session is None:
276
+ headers = {
277
+ 'Content-Type': 'application/json',
278
+ 'x-api-key': self.key,
279
+ 'Authorization': self.token
280
+ }
281
+ # Remove None values from headers
282
+ clean_headers = {k: v for k, v in headers.items() if v is not None}
283
+ # we are creating the header and clean any value that is none
284
+ # now we create the session
285
+ self._session = aiohttp.ClientSession(
286
+ headers=clean_headers,
287
+ timeout=aiohttp.ClientTimeout(total=self.timeout)
288
+ )
289
+ return self
290
+ # if there is no session, we create a session
291
+
292
+ # now lets create the aexit function, this function is used when a user uses with, and this function will be automatically called when the with statement is done
293
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
294
+ # so if the session is not being passed in, and we created it by ourselves, we are responsible for closing the session
295
+ # if the session is being passed in, we are not responsible for closing the session
296
+ if self._owns_session and self._session:
297
+ await self._session.close()
298
+ self._session = None
299
+ # we close the session and set the session value to none
300
+
301
+ async def close(self):
302
+ if self._owns_session and self._session:
303
+ await self._session.close()
304
+ self._session = None