terrakio-core 0.3.4__py3-none-any.whl → 0.3.7__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/config.py CHANGED
@@ -2,21 +2,20 @@ import os
2
2
  import json
3
3
  from pathlib import Path
4
4
  from typing import Dict, Any, Optional
5
-
5
+ import logging
6
6
  from .exceptions import ConfigurationError
7
7
 
8
8
  # Default configuration file locations
9
9
  DEFAULT_CONFIG_FILE = os.path.join(os.environ.get("HOME", ""), ".tkio_config.json")
10
10
  DEFAULT_API_URL = "https://api.terrak.io"
11
11
 
12
- def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, quiet: bool = False) -> Dict[str, Any]:
12
+ def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, logger: logging.Logger = None) -> Dict[str, Any]:
13
13
  """
14
14
  Read and parse the configuration file.
15
15
 
16
16
  Args:
17
17
  config_file: Path to the configuration file
18
- quiet: If True, suppress informational messages
19
-
18
+ logger: Logger object to log messages
20
19
  Returns:
21
20
  Dict[str, Any]: Configuration parameters with additional flags:
22
21
  'is_logged_in': True if user is logged in
@@ -36,8 +35,7 @@ def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, quiet: bool = False
36
35
  config_path.parent.mkdir(parents=True, exist_ok=True)
37
36
  with open(config_path, 'w') as f:
38
37
  json.dump({}, f)
39
- if not quiet:
40
- print("No API key found. Please provide an API key to use this client.")
38
+ logger.info("No API key found. Please provide an API key to use this client.")
41
39
  return {
42
40
  'url': DEFAULT_API_URL,
43
41
  'key': None,
@@ -54,8 +52,7 @@ def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, quiet: bool = False
54
52
  # Read the config file data
55
53
  # Check if config has an API key
56
54
  if not config_data or 'TERRAKIO_API_KEY' not in config_data or not config_data.get('TERRAKIO_API_KEY'):
57
- if not quiet:
58
- print("No API key found. Please provide an API key to use this client.")
55
+ logger.info("No API key found. Please provide an API key to use this client.")
59
56
  return {
60
57
  'url': DEFAULT_API_URL,
61
58
  'key': None,
@@ -63,10 +60,7 @@ def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, quiet: bool = False
63
60
  'user_email': None,
64
61
  'token': config_data.get('PERSONAL_TOKEN')
65
62
  }
66
-
67
- # If we have config values, use them
68
- if not quiet:
69
- print(f"Currently logged in as: {config_data.get('EMAIL')}")
63
+ logger.info(f"Currently logged in as: {config_data.get('EMAIL')}")
70
64
  # this meanb that we have already logged in to the tkio account
71
65
 
72
66
  # Convert the JSON config to our expected format
@@ -82,9 +76,8 @@ def read_config_file(config_file: str = DEFAULT_CONFIG_FILE, quiet: bool = False
82
76
 
83
77
 
84
78
  except Exception as e:
85
- if not quiet:
86
- print(f"Error reading config: {e}")
87
- print("No API key found. Please provide an API key to use this client.")
79
+ logger.info(f"Error reading config: {e}")
80
+ logger.info("No API key found. Please provide an API key to use this client.")
88
81
  return {
89
82
  'url': DEFAULT_API_URL,
90
83
  'key': None,
@@ -0,0 +1,296 @@
1
+ import os
2
+ import asyncio
3
+ import tempfile
4
+ import time
5
+ import pandas as pd
6
+ from geopandas import GeoDataFrame
7
+ from shapely.geometry import mapping
8
+ from ..exceptions import APIError, ConfigurationError
9
+ from ..helper.bounded_taskgroup import BoundedTaskGroup
10
+ from ..helper.tiles import tiles
11
+ import uuid
12
+
13
+ async def zonal_stats(
14
+ client,
15
+ gdf: GeoDataFrame,
16
+ expr: str,
17
+ conc: int = 20,
18
+ inplace: bool = False,
19
+ in_crs: str = "epsg:4326",
20
+ out_crs: str = "epsg:4326",
21
+ resolution: int = -1,
22
+ geom_fix: bool = False,
23
+ ):
24
+ """
25
+ Compute zonal statistics for all geometries in a GeoDataFrame.
26
+
27
+ Args:
28
+ client: The AsyncClient instance
29
+ gdf (GeoDataFrame): GeoDataFrame containing geometries
30
+ expr (str): Terrakio expression to evaluate, can include spatial aggregations
31
+ conc (int): Number of concurrent requests to make
32
+ inplace (bool): Whether to modify the input GeoDataFrame in place
33
+ in_crs (str): Input coordinate reference system
34
+ out_crs (str): Output coordinate reference system
35
+ resolution (int): Resolution parameter
36
+ geom_fix (bool): Whether to fix the geometry (default False)
37
+ Returns:
38
+ geopandas.GeoDataFrame: GeoDataFrame with added columns for results, or None if inplace=True
39
+
40
+ Raises:
41
+ ValueError: If concurrency is too high
42
+ APIError: If the API request fails
43
+ """
44
+ if conc > 100:
45
+ raise ValueError("Concurrency (conc) is too high. Please set conc to 100 or less.")
46
+
47
+ total_geometries = len(gdf)
48
+
49
+ client.logger.info(f"Processing {total_geometries} geometries with concurrency {conc}")
50
+
51
+ completed_count = 0
52
+ lock = asyncio.Lock()
53
+
54
+ async def process_geometry(geom, index):
55
+ """Process a single geometry"""
56
+ nonlocal completed_count
57
+
58
+ try:
59
+ feature = {
60
+ "type": "Feature",
61
+ "geometry": mapping(geom),
62
+ "properties": {"index": index}
63
+ }
64
+ result = await client.geoquery(expr=expr, feature=feature, output="csv",
65
+ in_crs=in_crs, out_crs=out_crs, resolution=resolution, geom_fix=geom_fix)
66
+
67
+ if isinstance(result, dict) and result.get("error"):
68
+ error_msg = f"Request {index} failed: {result.get('error_message', 'Unknown error')}"
69
+ if result.get('status_code'):
70
+ error_msg = f"Request {index} failed with status {result['status_code']}: {result.get('error_message', 'Unknown error')}"
71
+ raise APIError(error_msg)
72
+
73
+ if isinstance(result, pd.DataFrame):
74
+ result['_geometry_index'] = index
75
+
76
+ async with lock:
77
+ completed_count += 1
78
+ if completed_count % max(1, total_geometries // 10) == 0:
79
+ client.logger.info(f"Progress: {completed_count}/{total_geometries} geometries processed")
80
+
81
+ return result
82
+
83
+ except Exception as e:
84
+ async with lock:
85
+ completed_count += 1
86
+ raise
87
+
88
+ try:
89
+ async with BoundedTaskGroup(max_concurrency=conc) as tg:
90
+ tasks = [
91
+ tg.create_task(process_geometry(gdf.geometry.iloc[idx], idx))
92
+ for idx in range(len(gdf))
93
+ ]
94
+ all_results = [task.result() for task in tasks]
95
+
96
+ except* Exception as eg:
97
+ for e in eg.exceptions:
98
+ if hasattr(e, 'response'):
99
+ raise APIError(f"API request failed: {e.response.text}")
100
+ raise
101
+
102
+ client.logger.info("All requests completed! Processing results...")
103
+
104
+ if not all_results:
105
+ raise ValueError("No valid results were returned for any geometry")
106
+
107
+ combined_df = pd.concat(all_results, ignore_index=True)
108
+
109
+ has_time = 'time' in combined_df.columns
110
+
111
+ if has_time:
112
+ if '_geometry_index' not in combined_df.columns:
113
+ raise ValueError("Missing geometry index in results")
114
+
115
+ combined_df.set_index(['_geometry_index', 'time'], inplace=True)
116
+
117
+ result_cols = combined_df.columns
118
+
119
+ result_rows = []
120
+ geometries = []
121
+
122
+ for (geom_idx, time_val), row in combined_df.iterrows():
123
+ new_row = {}
124
+
125
+ for col in gdf.columns:
126
+ if col != 'geometry':
127
+ new_row[col] = gdf.loc[geom_idx, col]
128
+
129
+ for col in result_cols:
130
+ new_row[col] = row[col]
131
+
132
+ result_rows.append(new_row)
133
+ geometries.append(gdf.geometry.iloc[geom_idx])
134
+
135
+ multi_index = pd.MultiIndex.from_tuples(
136
+ combined_df.index.tolist(),
137
+ names=['geometry_index', 'time']
138
+ )
139
+
140
+ result_gdf = GeoDataFrame(
141
+ result_rows,
142
+ geometry=geometries,
143
+ index=multi_index
144
+ )
145
+
146
+ if inplace:
147
+ return result_gdf
148
+ else:
149
+ return result_gdf
150
+ else:
151
+ result_gdf = gdf.copy() if not inplace else gdf
152
+
153
+ result_cols = [col for col in combined_df.columns if col not in ['_geometry_index']]
154
+
155
+ geom_idx_to_row = {}
156
+ for idx, row in combined_df.iterrows():
157
+ geom_idx = int(row['_geometry_index'])
158
+ geom_idx_to_row[geom_idx] = row
159
+
160
+ for col in result_cols:
161
+ if col not in result_gdf.columns:
162
+ result_gdf[col] = None
163
+
164
+ for geom_idx, row in geom_idx_to_row.items():
165
+ result_gdf.loc[geom_idx, col] = row[col]
166
+ if inplace:
167
+ return None
168
+ else:
169
+ return result_gdf
170
+
171
+ async def create_dataset_file(
172
+ client,
173
+ aoi: str,
174
+ expression: str,
175
+ output: str,
176
+ in_crs: str = "epsg:4326",
177
+ res: float = 0.0001,
178
+ region: str = "aus",
179
+ to_crs: str = "epsg:4326",
180
+ overwrite: bool = True,
181
+ skip_existing: bool = False,
182
+ non_interactive: bool = True,
183
+ poll_interval: int = 30,
184
+ download_path: str = "/home/user/Downloads",
185
+ ) -> dict:
186
+
187
+ name = f"tiles-{uuid.uuid4().hex[:8]}"
188
+
189
+ body, reqs, groups = tiles(
190
+ name = name,
191
+ aoi = aoi,
192
+ expression = expression,
193
+ output = output,
194
+ tile_size = 128,
195
+ crs = in_crs,
196
+ res = res,
197
+ region = region,
198
+ to_crs = to_crs,
199
+ fully_cover = True,
200
+ overwrite = overwrite,
201
+ skip_existing = skip_existing,
202
+ non_interactive = non_interactive
203
+ )
204
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tempreq:
205
+ tempreq.write(reqs)
206
+ tempreqname = tempreq.name
207
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tempmanifest:
208
+ tempmanifest.write(groups)
209
+ tempmanifestname = tempmanifest.name
210
+
211
+ task_id = await client.mass_stats.execute_job(
212
+ name=body["name"],
213
+ region=body["region"],
214
+ output=body["output"],
215
+ config = {},
216
+ overwrite=body["overwrite"],
217
+ skip_existing=body["skip_existing"],
218
+ request_json=tempreqname,
219
+ manifest_json=tempmanifestname,
220
+ )
221
+
222
+ start_time = time.time()
223
+ status = None
224
+
225
+ while True:
226
+ try:
227
+ taskid = task_id['task_id']
228
+ trackinfo = await client.mass_stats.track_job([taskid])
229
+ client.logger.info("the trackinfo is: ", trackinfo)
230
+ status = trackinfo[taskid]['status']
231
+
232
+ if status == 'Completed':
233
+ client.logger.info('Tiles generated successfully!')
234
+ break
235
+ elif status in ['Failed', 'Cancelled', 'Error']:
236
+ raise RuntimeError(f"Job {taskid} failed with status: {status}")
237
+ else:
238
+ elapsed_time = time.time() - start_time
239
+ client.logger.info(f"Job status: {status} - Elapsed time: {elapsed_time:.1f}s", end='\r')
240
+
241
+ await asyncio.sleep(poll_interval)
242
+
243
+
244
+ except KeyboardInterrupt:
245
+ client.logger.info(f"\nInterrupted! Job {taskid} is still running in the background.")
246
+ raise
247
+ except Exception as e:
248
+ client.logger.info(f"\nError tracking job: {e}")
249
+ raise
250
+
251
+ os.unlink(tempreqname)
252
+ os.unlink(tempmanifestname)
253
+
254
+ combine_result = await client.mass_stats.combine_tiles(body["name"], body["overwrite"], body["output"])
255
+ combine_task_id = combine_result.get("task_id")
256
+
257
+ combine_start_time = time.time()
258
+ while True:
259
+ try:
260
+ trackinfo = await client.mass_stats.track_job([combine_task_id])
261
+ client.logger.info('client create dataset file track info:', trackinfo)
262
+ if body["output"] == "netcdf":
263
+ download_file_name = trackinfo[combine_task_id]['folder'] + '.nc'
264
+ elif body["output"] == "geotiff":
265
+ download_file_name = trackinfo[combine_task_id]['folder'] + '.tif'
266
+ bucket = trackinfo[combine_task_id]['bucket']
267
+ combine_status = trackinfo[combine_task_id]['status']
268
+ if combine_status == 'Completed':
269
+ client.logger.info('Tiles combined successfully!')
270
+ break
271
+ elif combine_status in ['Failed', 'Cancelled', 'Error']:
272
+ raise RuntimeError(f"Combine job {combine_task_id} failed with status: {combine_status}")
273
+ else:
274
+ elapsed_time = time.time() - combine_start_time
275
+ client.logger.info(f"Combine job status: {combine_status} - Elapsed time: {elapsed_time:.1f}s", end='\r')
276
+ time.sleep(poll_interval)
277
+ except KeyboardInterrupt:
278
+ client.logger.info(f"\nInterrupted! Combine job {combine_task_id} is still running in the background.")
279
+ raise
280
+ except Exception as e:
281
+ client.logger.info(f"\nError tracking combine job: {e}")
282
+ raise
283
+
284
+ if download_path:
285
+ await client.mass_stats.download_file(
286
+ job_name=body["name"],
287
+ bucket=bucket,
288
+ file_type='processed',
289
+ page_size=10,
290
+ output_path=download_path,
291
+ )
292
+ else:
293
+ path = f"{body['name']}/outputs/merged/{download_file_name}"
294
+ client.logger.info(f"Combined file is available at {path}")
295
+
296
+ return {"generation_task_id": task_id, "combine_task_id": combine_task_id}
@@ -0,0 +1,180 @@
1
+ import os
2
+ import json
3
+ from typing import Dict, Any
4
+ from ..exceptions import APIError
5
+ from ..helper.decorators import require_token, require_api_key, require_auth
6
+
7
+ class AuthClient:
8
+ def __init__(self, client):
9
+ self._client = client
10
+
11
+ async def signup(self, email: str, password: str) -> Dict[str, str]:
12
+ """
13
+ Signup a new user with email and password.
14
+
15
+ Args:
16
+ email: User's email address
17
+ password: User's password
18
+
19
+ Returns:
20
+ Dict containing the authentication token
21
+
22
+ Raises:
23
+ APIError: If the signup request fails
24
+ """
25
+ payload = {
26
+ "email": email,
27
+ "password": password
28
+ }
29
+
30
+
31
+ try:
32
+ result = await self._client._terrakio_request("POST", "/users/signup", json=payload)
33
+ except Exception as e:
34
+ self._client.logger.info(f"Signup failed: {str(e)}")
35
+ raise APIError(f"Signup request failed: {str(e)}")
36
+
37
+
38
+ async def login(self, email: str, password: str) -> Dict[str, str]:
39
+ """
40
+ Login a user with email and password.
41
+
42
+ Args:
43
+ email: User's email address
44
+ password: User's password
45
+
46
+ Returns:
47
+ Dict containing the authentication token
48
+
49
+ Raises:
50
+ APIError: If the login request fails
51
+ """
52
+ payload = {
53
+ "email": email,
54
+ "password": password
55
+ }
56
+
57
+ try:
58
+ result = await self._client._terrakio_request("POST", "/users/login", json=payload)
59
+ token_response = result.get("token")
60
+
61
+ if token_response:
62
+ self._client.token = token_response
63
+
64
+ api_key_response = await self.view_api_key()
65
+ self._client.key = api_key_response
66
+
67
+ if not self._client.url:
68
+ self._client.url = "https://api.terrak.io"
69
+
70
+ self._save_config(email, token_response)
71
+
72
+ self._client.logger.info(f"Successfully authenticated as: {email}")
73
+ self._client.logger.info(f"Using Terrakio API at: {self._client.url}")
74
+
75
+ return {"token": token_response} if token_response else {"error": "Login failed"}
76
+
77
+ except Exception as e:
78
+ self._client.logger.info(f"Login failed: {str(e)}")
79
+ raise APIError(f"Login request failed: {str(e)}")
80
+
81
+ @require_token
82
+ async def view_api_key(self) -> str:
83
+ """
84
+ View the current API key for the authenticated user.
85
+
86
+ Returns:
87
+ str: The API key
88
+
89
+ Raises:
90
+ APIError: If the API request fails
91
+ """
92
+ result = await self._client._terrakio_request("GET", "/users/key")
93
+ api_key = result.get("apiKey")
94
+ return api_key
95
+
96
+ @require_api_key
97
+ @require_token
98
+ async def refresh_api_key(self) -> str:
99
+ """
100
+ Refresh the API key for the authenticated user.
101
+
102
+ Returns:
103
+ str: The new API key
104
+
105
+ Raises:
106
+ APIError: If the API request fails
107
+ """
108
+ result = await self._client._terrakio_request("POST", "/users/refresh_key")
109
+ self._client.key = result.get("apiKey")
110
+
111
+ self._update_config_key()
112
+
113
+ return self._client.key
114
+
115
+ @require_api_key
116
+ def get_user_info(self) -> Dict[str, Any]:
117
+ """
118
+ Get information about the authenticated user.
119
+
120
+ Returns:
121
+ Dict[str, Any]: User information
122
+
123
+ Raises:
124
+ APIError: If the API request fails
125
+ """
126
+ return self._client._terrakio_request("GET", "/users/info")
127
+
128
+ def _save_config(self, email: str, token: str):
129
+ """
130
+ Helper method to save config file.
131
+
132
+ Args:
133
+ email: User's email address
134
+ token: Authentication token
135
+ """
136
+ config_path = os.path.join(os.environ.get("HOME", ""), ".tkio_config.json")
137
+
138
+ try:
139
+ config = {"EMAIL": email, "TERRAKIO_API_KEY": self._client.key}
140
+ if os.path.exists(config_path):
141
+ with open(config_path, 'r') as f:
142
+ config = json.load(f)
143
+
144
+ config["EMAIL"] = email
145
+ config["TERRAKIO_API_KEY"] = self._client.key
146
+ config["PERSONAL_TOKEN"] = token
147
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
148
+
149
+ with open(config_path, 'w') as f:
150
+ json.dump(config, f, indent=4)
151
+
152
+ self._client.logger.info(f"API key saved to {config_path}")
153
+
154
+ except Exception as e:
155
+ self._client.logger.info(f"Warning: Failed to update config file: {e}")
156
+
157
+ def _update_config_key(self):
158
+ """
159
+ Helper method to update just the API key in config.
160
+ """
161
+ config_path = os.path.join(os.environ.get("HOME", ""), ".tkio_config.json")
162
+
163
+ try:
164
+ config = {"EMAIL": "", "TERRAKIO_API_KEY": ""}
165
+
166
+ if os.path.exists(config_path):
167
+ with open(config_path, 'r') as f:
168
+ config = json.load(f)
169
+
170
+ config["TERRAKIO_API_KEY"] = self._client.key
171
+
172
+ os.makedirs(os.path.dirname(config_path), exist_ok=True)
173
+
174
+ with open(config_path, 'w') as f:
175
+ json.dump(config, f, indent=4)
176
+
177
+ self._client.logger.info(f"API key updated in {config_path}")
178
+
179
+ except Exception as e:
180
+ self._client.logger.info(f"Warning: Failed to update config file: {e}")