terrakio-core 0.4.97__py3-none-any.whl → 0.4.98.1b1__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.

@@ -1,712 +1,835 @@
1
- from typing import Dict, Any, Optional
2
- import json
3
- import gzip
4
1
  import os
5
- import weakref
6
- import weakref
7
- from pathlib import Path
8
- from urllib.parse import urlparse
9
- from ..helper.decorators import require_token, require_api_key, require_auth
10
- import aiohttp
11
- from typing import Dict, Any, Optional, List, Union
12
- import asyncio
13
- import xarray as xr
14
- from io import BytesIO
15
- import geopandas as gpd
16
- from shapely.geometry import shape
2
+ import time
3
+ import typer
4
+ from typing import Dict, Any, Optional, List
5
+ from dateutil import parser
6
+
7
+ from rich.console import Console
8
+ from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
9
+
10
+ from ..exceptions import (
11
+ CreateCollectionError,
12
+ GetCollectionError,
13
+ ListCollectionsError,
14
+ CollectionNotFoundError,
15
+ CollectionAlreadyExistsError,
16
+ InvalidCollectionTypeError,
17
+ DeleteCollectionError,
18
+ ListTasksError,
19
+ UploadRequestsError,
20
+ UploadArtifactsError,
21
+ GetTaskError,
22
+ TaskNotFoundError,
23
+ DownloadFilesError,
24
+ CancelTaskError,
25
+ CancelCollectionTasksError,
26
+ CancelAllTasksError,
27
+ )
28
+ from ..helper.decorators import require_api_key
29
+
30
+ import aiohttp # Make sure this is imported at the top
31
+
17
32
 
18
33
  class MassStats:
19
34
  def __init__(self, client):
20
35
  self._client = client
36
+ self.console = Console()
37
+
38
+ async def track_progress(self, task_id):
39
+ task_info = await self.get_task(task_id=task_id)
40
+ number_of_jobs = task_info["task"]["total"]
41
+ start_time = parser.parse(task_info["task"]["createdAt"])
42
+
43
+ self.console.print(f"[bold cyan]Tracking task: {task_id}[/bold cyan]")
44
+
45
+ completed_jobs_info = []
46
+
47
+ def get_job_description(job_info, include_status=False):
48
+ if not job_info:
49
+ return "No job info"
50
+
51
+ service = job_info.get("service", "Unknown service")
52
+ desc = service
53
+
54
+ if include_status:
55
+ status = job_info.get("status", "unknown")
56
+ desc += f" - {status}"
57
+
58
+ return desc
59
+
60
+ progress = Progress(
61
+ TextColumn("[progress.description]{task.description}"),
62
+ BarColumn(),
63
+ TaskProgressColumn(),
64
+ TimeElapsedColumn(),
65
+ )
66
+
67
+ with progress:
68
+ last_completed_count = 0
69
+ current_job_task = None
70
+ current_job_description = None
71
+
72
+ while len(completed_jobs_info) < number_of_jobs:
73
+ task_info = await self.get_task(task_id=task_id)
74
+ completed_number = task_info["task"]["completed"]
75
+ current_job_info = task_info["currentJob"]
76
+
77
+ if completed_number > last_completed_count:
78
+ if current_job_task is not None:
79
+ completed_description = current_job_description.replace(" - pending", "").replace(" - running", "").replace(" - waiting", "")
80
+ completed_description += " - completed"
81
+
82
+ progress.update(
83
+ current_job_task,
84
+ description=f"[{last_completed_count + 1}/{number_of_jobs}] {completed_description}",
85
+ completed=100
86
+ )
87
+ completed_jobs_info.append({
88
+ "task": current_job_task,
89
+ "description": completed_description,
90
+ "job_number": last_completed_count + 1
91
+ })
92
+ current_job_task = None
93
+ current_job_description = None
94
+
95
+ last_completed_count = completed_number
96
+
97
+ if current_job_info:
98
+ status = current_job_info["status"]
99
+ current_job_description = get_job_description(current_job_info, include_status=True)
100
+
101
+ total_value = current_job_info.get("total", 0)
102
+ completed_value = current_job_info.get("completed", 0)
103
+
104
+ if total_value == -9999:
105
+ percent = 0
106
+ elif total_value > 0:
107
+ percent = int(completed_value / total_value * 100)
108
+ else:
109
+ percent = 0
110
+
111
+ if current_job_task is None:
112
+ current_job_task = progress.add_task(
113
+ f"[{completed_number + 1}/{number_of_jobs}] {current_job_description}",
114
+ total=100,
115
+ start_time=start_time
116
+ )
117
+ else:
118
+ progress.update(
119
+ current_job_task,
120
+ description=f"[{completed_number + 1}/{number_of_jobs}] {current_job_description}",
121
+ completed=percent
122
+ )
123
+
124
+ if status == "Error":
125
+ self.console.print("[bold red]Error![/bold red]")
126
+ raise typer.Exit(code=1)
127
+ if status == "Cancelled":
128
+ self.console.print("[bold orange]Cancelled![/bold orange]")
129
+ raise typer.Exit(code=1)
130
+ elif status == "Completed":
131
+ completed_description = current_job_description.replace(" - pending", "").replace(" - running", "").replace(" - waiting", "")
132
+ completed_description += " - completed"
133
+ progress.update(
134
+ current_job_task,
135
+ description=f"[{completed_number + 1}/{number_of_jobs}] {completed_description}",
136
+ completed=100
137
+ )
138
+
139
+ if completed_number == number_of_jobs and current_job_info is None:
140
+ if current_job_task is not None:
141
+ completed_description = current_job_description.replace(" - pending", "").replace(" - running", "").replace(" - waiting", "")
142
+ completed_description += " - completed"
143
+ progress.update(
144
+ current_job_task,
145
+ description=f"[{number_of_jobs}/{number_of_jobs}] {completed_description}",
146
+ completed=100
147
+ )
148
+ completed_jobs_info.append({
149
+ "task": current_job_task,
150
+ "description": completed_description,
151
+ "job_number": number_of_jobs
152
+ })
153
+ break
154
+
155
+ time.sleep(10)
156
+
157
+ self.console.print(f"[bold green]All {number_of_jobs} jobs finished![/bold green]")
21
158
 
22
159
  @require_api_key
23
- async def _upload_request(
24
- self,
25
- name: str,
26
- size: int,
27
- sample: str,
28
- output: str,
29
- config: Dict[str, Any],
30
- region: str = None,
31
- overwrite: bool = False,
32
- skip_existing: bool = False,
33
- location: Optional[str] = None,
34
- force_loc: Optional[bool] = None,
35
- server: Optional[str] = "dev-au.terrak.io",
160
+ async def create_collection(
161
+ self,
162
+ collection: str,
163
+ bucket: Optional[str] = None,
164
+ location: Optional[str] = None,
165
+ collection_type: str = "basic"
36
166
  ) -> Dict[str, Any]:
37
167
  """
38
- Upload a request to the mass stats server.
168
+ Create a collection for the current user.
39
169
 
40
170
  Args:
41
- name: The name of the job
42
- size: The size of the job
43
- sample: The sample expression for deciding which server to make the request to
44
- output: The output of the job
45
- config: The config of the job
46
- overwrite: Whether to overwrite the job
47
- skip_existing: Whether to skip existing jobs
48
- location: The location of the job
49
- force_loc: Whether to force the location
50
- server: The server to use
51
-
171
+ collection: The name of the collection (required)
172
+ bucket: The bucket to use (optional, admin only)
173
+ location: The location to use (optional, admin only)
174
+ collection_type: The type of collection to create (optional, defaults to "basic")
175
+
52
176
  Returns:
53
- API response as a dictionary
54
-
177
+ API response as a dictionary containing the collection id
178
+
55
179
  Raises:
56
- APIError: If the API request fails
180
+ CollectionAlreadyExistsError: If the collection already exists
181
+ InvalidCollectionTypeError: If the collection type is invalid
182
+ CreateCollectionError: If the API request fails due to unknown reasons
57
183
  """
58
- # we don't actually need the region function inside the request, the endpoint will fix that for us
59
184
  payload = {
60
- "name": name,
61
- "size": size,
62
- "sample": sample,
63
- "output": output,
64
- "config": config,
65
- "overwrite": overwrite,
66
- "skip_existing": skip_existing,
67
- "server": server,
68
- "region": region
69
- }
70
- payload_mapping = {
71
- "location": location,
72
- "force_loc": force_loc
185
+ "collection_type": collection_type
73
186
  }
74
- for key, value in payload_mapping.items():
75
- if value is not None:
76
- payload[key] = str(value).lower()
77
- return await self._client._terrakio_request("POST", "mass_stats/upload", json=payload)
187
+
188
+ if bucket is not None:
189
+ payload["bucket"] = bucket
190
+
191
+ if location is not None:
192
+ payload["location"] = location
193
+
194
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}", json=payload)
195
+
196
+ if status != 200:
197
+ if status == 400:
198
+ raise CollectionAlreadyExistsError(f"Collection {collection} already exists", status_code=status)
199
+ if status == 422:
200
+ raise InvalidCollectionTypeError(f"Invalid collection type: {collection_type}", status_code=status)
201
+ raise CreateCollectionError(f"Create collection failed with status {status}", status_code=status)
202
+
203
+ return response
78
204
 
79
205
  @require_api_key
80
- async def start_job(self, id: str) -> Dict[str, Any]:
206
+ async def delete_collection(
207
+ self,
208
+ collection: str,
209
+ full: Optional[bool] = False,
210
+ outputs: Optional[list] = [],
211
+ data: Optional[bool] = False
212
+ ) -> Dict[str, Any]:
81
213
  """
82
- Start a mass stats job by task ID.
214
+ Delete a collection by name.
83
215
 
84
216
  Args:
85
- task_id: The ID of the task to start
86
-
217
+ collection: The name of the collection to delete (required)
218
+ full: Delete the full collection (optional, defaults to False)
219
+ outputs: Specific output folders to delete (optional, defaults to empty list)
220
+ data: Whether to delete raw data (xdata folder) (optional, defaults to False)
221
+
87
222
  Returns:
88
- API response as a dictionary
89
-
223
+ API response as a dictionary confirming deletion
224
+
225
+ Raises:
226
+ CollectionNotFoundError: If the collection is not found
227
+ DeleteCollectionError: If the API request fails due to unknown reasons
90
228
  """
91
- return await self._client._terrakio_request("POST", f"mass_stats/start/{id}")
92
-
229
+ payload = {
230
+ "full": full,
231
+ "outputs": outputs,
232
+ "data": data
233
+ }
234
+
235
+ response, status = await self._client._terrakio_request("DELETE", f"collections/{collection}", json=payload)
236
+
237
+ if status != 200:
238
+ if status == 404:
239
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
240
+ raise DeleteCollectionError(f"Delete collection failed with status {status}", status_code=status)
241
+
242
+ return response
243
+
93
244
  @require_api_key
94
- def get_task_id(self, name: str, stage: str, uid: Optional[str] = None) -> Dict[str, Any]:
245
+ async def get_collection(self, collection: str) -> Dict[str, Any]:
95
246
  """
96
- Get the task ID for a mass stats job by name and stage (and optionally user ID).
247
+ Get a collection by name.
97
248
 
98
249
  Args:
99
- name: The name of the job
100
- stage: The stage of the job
101
- uid: The user ID of the job
102
-
250
+ collection: The name of the collection to retrieve(required)
251
+
103
252
  Returns:
104
- API response as a dictionary
253
+ API response as a dictionary containing collection information
254
+
255
+ Raises:
256
+ CollectionNotFoundError: If the collection is not found
257
+ GetCollectionError: If the API request fails due to unknown reasons
258
+ """
259
+ response, status = await self._client._terrakio_request("GET", f"collections/{collection}")
105
260
 
261
+ if status != 200:
262
+ if status == 404:
263
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
264
+ raise GetCollectionError(f"Get collection failed with status {status}", status_code=status)
265
+
266
+ return response
267
+
268
+ @require_api_key
269
+ async def list_collections(
270
+ self,
271
+ collection_type: Optional[str] = None,
272
+ limit: Optional[int] = 10,
273
+ page: Optional[int] = 0
274
+ ) -> List[Dict[str, Any]]:
275
+ """
276
+ List collections for the current user.
277
+
278
+ Args:
279
+ collection_type: Filter by collection type (optional)
280
+ limit: Number of collections to return (optional, defaults to 10)
281
+ page: Page number (optional, defaults to 0)
282
+
283
+ Returns:
284
+ API response as a list of dictionaries containing collection information
285
+
106
286
  Raises:
107
- APIError: If the API request fails
287
+ ListCollectionsError: If the API request fails due to unknown reasons
108
288
  """
109
- url = f"mass_stats/job_id?name={name}&stage={stage}"
110
- if uid is not None:
111
- url += f"&uid={uid}"
112
- return self._client._terrakio_request("GET", url)
113
-
289
+ params = {}
290
+
291
+ if collection_type is not None:
292
+ params["collection_type"] = collection_type
293
+
294
+ if limit is not None:
295
+ params["limit"] = limit
296
+
297
+ if page is not None:
298
+ params["page"] = page
299
+
300
+ response, status = await self._client._terrakio_request("GET", "collections", params=params)
301
+ if status != 200:
302
+ raise ListCollectionsError(f"List collections failed with status {status}", status_code=status)
303
+
304
+ return response
305
+
114
306
  @require_api_key
115
- async def track_job(self, ids: Optional[list] = None) -> Dict[str, Any]:
307
+ async def list_tasks(
308
+ self,
309
+ limit: Optional[int] = 10,
310
+ page: Optional[int] = 0
311
+ ) -> List[Dict[str, Any]]:
116
312
  """
117
- Track the status of one or more mass stats jobs.
313
+ List tasks for the current user.
118
314
 
119
315
  Args:
120
- ids: The IDs of the jobs to track
316
+ limit: Number of tasks to return (optional, defaults to 10)
317
+ page: Page number (optional, defaults to 0)
318
+
319
+ Returns:
320
+ API response as a list of dictionaries containing task information
321
+
322
+ Raises:
323
+ ListTasksError: If the API request fails due to unknown reasons
324
+ """
325
+ params = {
326
+ "limit": limit,
327
+ "page": page
328
+ }
329
+ response, status = await self._client._terrakio_request("GET", "tasks", params=params)
330
+
331
+ if status != 200:
332
+ raise ListTasksError(f"List tasks failed with status {status}", status_code=status)
121
333
 
334
+ return response
335
+
336
+ @require_api_key
337
+ async def upload_requests(
338
+ self,
339
+ collection: str
340
+ ) -> Dict[str, Any]:
341
+ """
342
+ Retrieve signed url to upload requests for a collection.
343
+
344
+ Args:
345
+ collection: Name of collection
346
+
122
347
  Returns:
123
- API response as a dictionary
348
+ API response as a dictionary containing the upload URL
124
349
 
125
350
  Raises:
126
- APIError: If the API request fails
351
+ CollectionNotFoundError: If the collection is not found
352
+ UploadRequestsError: If the API request fails due to unknown reasons
127
353
  """
128
- data = {"ids": ids} if ids is not None else {}
129
- return await self._client._terrakio_request("POST", "mass_stats/track", json=data)
130
-
354
+ response, status = await self._client._terrakio_request("GET", f"collections/{collection}/upload/requests")
355
+
356
+ if status != 200:
357
+ if status == 404:
358
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
359
+ raise UploadRequestsError(f"Upload requests failed with status {status}", status_code=status)
360
+
361
+ return response
362
+
131
363
  @require_api_key
132
- def get_history(self, limit: Optional[int] = 100) -> Dict[str, Any]:
364
+ async def upload_artifacts(
365
+ self,
366
+ collection: str,
367
+ file_type: str,
368
+ compressed: Optional[bool] = True
369
+ ) -> Dict[str, Any]:
133
370
  """
134
- Get the history of mass stats jobs.
371
+ Retrieve signed url to upload artifact file to a collection.
135
372
 
136
373
  Args:
137
- limit: The number of jobs to return
138
-
374
+ collection: Name of collection
375
+ file_type: The extension of the file
376
+ compressed: Whether to compress the file using gzip or not (defaults to True)
377
+
139
378
  Returns:
140
- API response as a dictionary
379
+ API response as a dictionary containing the upload URL
141
380
 
142
381
  Raises:
143
- APIError: If the API request fails
382
+ CollectionNotFoundError: If the collection is not found
383
+ UploadArtifactsError: If the API request fails due to unknown reasons
144
384
  """
145
- params = {"limit": limit}
146
- return self._client._terrakio_request("GET", "mass_stats/history", params=params)
147
-
385
+ params = {
386
+ "file_type": file_type,
387
+ "compressed": str(compressed).lower(),
388
+ }
148
389
 
149
- @require_api_key
150
- async def start_post_processing(
390
+ response, status = await self._client._terrakio_request("GET", f"collections/{collection}/upload", params=params)
391
+
392
+ if status != 200:
393
+ if status == 404:
394
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
395
+ raise UploadArtifactsError(f"Upload artifacts failed with status {status}", status_code=status)
396
+
397
+ return response
398
+
399
+ @require_api_key
400
+ async def get_task(
151
401
  self,
152
- process_name: str,
153
- data_name: str,
154
- output: str,
155
- consumer: str,
156
- overwrite: bool = False
402
+ task_id: str
157
403
  ) -> Dict[str, Any]:
158
-
159
- with open(consumer, 'rb') as f:
160
- script_bytes = f.read()
404
+ """
405
+ Get task information by task ID.
406
+
407
+ Args:
408
+ task_id: ID of task to track
161
409
 
162
- data = aiohttp.FormData()
163
- data.add_field('process_name', process_name)
164
- data.add_field('data_name', data_name)
165
- data.add_field('output', output)
166
- data.add_field('overwrite', str(overwrite).lower())
167
- data.add_field('consumer', script_bytes, filename=os.path.basename(consumer), content_type='text/x-python')
410
+ Returns:
411
+ API response as a dictionary containing task information
412
+
413
+ Raises:
414
+ TaskNotFoundError: If the task is not found
415
+ GetTaskError: If the API request fails due to unknown reasons
416
+ """
417
+ response, status = await self._client._terrakio_request("GET", f"tasks/info/{task_id}")
418
+
419
+ if status != 200:
420
+ if status == 404:
421
+ raise TaskNotFoundError(f"Task {task_id} not found", status_code=status)
422
+ raise GetTaskError(f"Get task failed with status {status}", status_code=status)
168
423
 
169
- return await self._client._terrakio_request(
170
- "POST",
171
- "mass_stats/post_process",
172
- data=data,
173
- )
424
+ return response
174
425
 
175
- @require_api_key
176
- async def zonal_stats_transform(
426
+ @require_api_key
427
+ async def generate_data(
177
428
  self,
178
- data_name: str,
429
+ collection: str,
179
430
  output: str,
180
- consumer: bytes,
181
- overwrite: bool = False
431
+ skip_existing: Optional[bool] = True,
432
+ force_loc: Optional[bool] = None,
433
+ server: Optional[str] = None
182
434
  ) -> Dict[str, Any]:
183
-
184
- data = aiohttp.FormData()
185
- data.add_field('data_name', data_name)
186
- data.add_field('output', output)
187
- data.add_field('overwrite', str(overwrite).lower())
188
- data.add_field('consumer', consumer, filename="consumer.py", content_type='text/x-python')
435
+ """
436
+ Generate data for a collection.
437
+
438
+ Args:
439
+ collection: Name of collection
440
+ output: Output type (str)
441
+ force_loc: Write data directly to the cloud under this folder
442
+ skip_existing: Skip existing data
443
+ server: Server to use
444
+
445
+ Returns:
446
+ API response as a dictionary containing task information
447
+
448
+ Raises:
449
+ CollectionNotFoundError: If the collection is not found
450
+ GetTaskError: If the API request fails due to unknown reasons
451
+ """
452
+ payload = {"output": output, "skip_existing": skip_existing}
453
+
454
+ if force_loc is not None:
455
+ payload["force_loc"] = force_loc
456
+ if server is not None:
457
+ payload["server"] = server
458
+
459
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/generate_data", json=payload)
460
+
461
+ if status != 200:
462
+ if status == 404:
463
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
464
+ raise GetTaskError(f"Generate data failed with status {status}", status_code=status)
465
+
466
+ return response
189
467
 
190
- return await self._client._terrakio_request(
191
- "POST",
192
- "mass_stats/transform",
193
- data=data,
194
- )
195
-
196
468
  @require_api_key
197
- def download_results(
469
+ async def training_samples(
198
470
  self,
199
- file_name: str,
200
- id: Optional[str] = None,
201
- force_loc: Optional[bool] = None,
202
- bucket: Optional[str] = None,
203
- location: Optional[str] = None,
204
- output: Optional[str] = None
471
+ collection: str,
472
+ expressions: list[str],
473
+ filters: list[str],
474
+ aoi: dict,
475
+ samples: int,
476
+ crs: str,
477
+ tile_size: int,
478
+ res: float,
479
+ output: str,
480
+ year_range: Optional[list[int]] = None,
481
+ server: Optional[str] = None
205
482
  ) -> Dict[str, Any]:
206
483
  """
207
- Download results from a mass stats job or arbitrary results if force_loc is True.
484
+ Generate training samples for a collection.
208
485
 
209
486
  Args:
210
- file_name: File name of resulting zip file (required)
211
- id: Post processing id. Can't be used with 'force_loc'
212
- force_loc: Download arbitrary results not connected to a mass-stats job id. Can't be used with 'id'
213
- bucket: Bucket name (required if force_loc is True)
214
- location: Path to folder in bucket (required if force_loc is True)
215
- output: Output type (required if force_loc is True)
487
+ collection: Name of collection
488
+ expressions: List of expressions for each sample
489
+ filters: Expressions to filter sample areas
490
+ aoi: AOI to sample from (geojson dict)
491
+ samples: Number of samples to generate
492
+ crs: CRS of AOI
493
+ tile_size: Pixel width and height of samples
494
+ res: Resolution of samples
495
+ output: Sample output type
496
+ year_range: Optional year range filter
497
+ server: Server to use
216
498
 
217
499
  Returns:
218
- API response as a dictionary
500
+ API response as a dictionary containing task information
219
501
 
220
502
  Raises:
221
- APIError: If the API request fails
222
- ValueError: If validation fails for parameter combinations
503
+ CollectionNotFoundError: If the collection is not found
504
+ GetTaskError: If the API request fails due to unknown reasons
223
505
  """
224
- if id is not None and force_loc is True:
225
- raise ValueError("Cannot use both 'id' and 'force_loc' parameters simultaneously")
226
-
227
- if id is None and force_loc is not True:
228
- raise ValueError("Either 'id' or 'force_loc=True' must be provided")
506
+ payload = {
507
+ "expressions": expressions,
508
+ "filters": filters,
509
+ "aoi": aoi,
510
+ "samples": samples,
511
+ "crs": crs,
512
+ "tile_size": tile_size,
513
+ "res": res,
514
+ "output": output
515
+ }
229
516
 
230
- if force_loc is True:
231
- if bucket is None or location is None or output is None:
232
- raise ValueError("When force_loc is True, 'bucket', 'location', and 'output' must be provided")
517
+ if year_range is not None:
518
+ payload["year_range"] = year_range
519
+ if server is not None:
520
+ payload["server"] = server
233
521
 
234
- params = {"file_name": file_name}
522
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/training_samples", json=payload)
523
+
524
+ if status != 200:
525
+ if status == 404:
526
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
527
+ raise GetTaskError(f"Training sample failed with status {status}", status_code=status)
235
528
 
236
- if id is not None:
237
- params["id"] = id
238
- if force_loc is True:
239
- params["force_loc"] = force_loc
240
- params["bucket"] = bucket
241
- params["location"] = location
242
- params["output"] = output
243
-
244
- return self._client._terrakio_request("GET", "mass_stats/download", params=params)
529
+ return response
245
530
 
246
- @require_api_key
247
- async def _upload_file(self, file_path: str, url: str, use_gzip: bool = False):
248
- """
249
- Helper method to upload a JSON file to a signed URL.
531
+ # @require_api_key
532
+ # async def post_processing(
533
+ # self,
534
+ # collection: str,
535
+ # folder: str,
536
+ # consumer: str
537
+ # ) -> Dict[str, Any]:
538
+ # """
539
+ # Run post processing for a collection.
540
+
541
+ # Args:
542
+ # collection: Name of collection
543
+ # folder: Folder to store output
544
+ # consumer: Post processing script
545
+
546
+ # Returns:
547
+ # API response as a dictionary containing task information
548
+
549
+ # Raises:
550
+ # CollectionNotFoundError: If the collection is not found
551
+ # GetTaskError: If the API request fails due to unknown reasons
552
+ # """
553
+ # # payload = {
554
+ # # "folder": folder,
555
+ # # "consumer": consumer
556
+ # # }
557
+ # # we have the consumer as a string, we need to read in the file and then pass in the content
558
+ # with open(consumer, 'rb') as f:
559
+ # files = {
560
+ # 'consumer': ('consumer.py', f.read(), 'text/plain')
561
+ # }
562
+ # data = {
563
+ # 'folder': folder
564
+ # }
250
565
 
251
- Args:
252
- file_path: Path to the JSON file
253
- url: Signed URL to upload to
254
- use_gzip: Whether to compress the file with gzip
255
- """
256
- try:
257
- with open(file_path, 'r') as file:
258
- json_data = json.load(file)
259
- except FileNotFoundError:
260
- raise FileNotFoundError(f"JSON file not found: {file_path}")
261
- except json.JSONDecodeError as e:
262
- raise ValueError(f"Invalid JSON in file {file_path}: {e}")
566
+ # # response, status = await self._client._terrakio_request("POST", f"collections/{collection}/post_process", json=payload)
567
+ # response, status = await self._client._terrakio_request(
568
+ # "POST",
569
+ # f"collections/{collection}/post_process",
570
+ # files=files,
571
+ # data=data
572
+ # )
573
+ # if status != 200:
574
+ # if status == 404:
575
+ # raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
576
+ # raise GetTaskError(f"Post processing failed with status {status}", status_code=status)
263
577
 
264
- return await self._upload_json_data(json_data, url, use_gzip)
578
+ # return response
579
+
265
580
 
266
581
  @require_api_key
267
- async def _upload_json_data(self, json_data: Union[Dict, List], url: str, use_gzip: bool = False):
582
+ async def post_processing(
583
+ self,
584
+ collection: str,
585
+ folder: str,
586
+ consumer: str
587
+ ) -> Dict[str, Any]:
268
588
  """
269
- Helper method to upload JSON data directly to a signed URL.
270
-
589
+ Run post processing for a collection.
590
+
271
591
  Args:
272
- json_data: JSON data (dict or list) to upload
273
- url: Signed URL to upload to
274
- use_gzip: Whether to compress the data with gzip
592
+ collection: Name of collection
593
+ folder: Folder to store output
594
+ consumer: Path to post processing script
595
+
596
+ Returns:
597
+ API response as a dictionary containing task information
598
+
599
+ Raises:
600
+ CollectionNotFoundError: If the collection is not found
601
+ GetTaskError: If the API request fails due to unknown reasons
275
602
  """
276
- if hasattr(json, 'dumps') and 'ignore_nan' in json.dumps.__code__.co_varnames:
277
- dumps_kwargs = {'ignore_nan': True}
278
- else:
279
- dumps_kwargs = {}
280
-
281
- if use_gzip:
282
- body = gzip.compress(json.dumps(json_data, **dumps_kwargs).encode('utf-8'))
283
- headers = {
284
- 'Content-Type': 'application/json',
285
- 'Content-Encoding': 'gzip'
286
- }
287
- else:
288
- body = json.dumps(json_data, **dumps_kwargs).encode('utf-8')
289
- headers = {
290
- 'Content-Type': 'application/json'
291
- }
603
+ # Read file and build multipart form data
604
+ with open(consumer, 'rb') as f:
605
+ form = aiohttp.FormData()
606
+ form.add_field('folder', folder) # Add text field
607
+ form.add_field(
608
+ 'consumer', # Field name
609
+ f.read(), # File content
610
+ filename='consumer.py', # Filename
611
+ content_type='text/x-python' # MIME type
612
+ )
613
+
614
+ # Send using data= with FormData object (NOT files=)
615
+ response, status = await self._client._terrakio_request(
616
+ "POST",
617
+ f"collections/{collection}/post_process",
618
+ data=form # ✅ Pass FormData as data
619
+ )
620
+
621
+ if status != 200:
622
+ if status == 404:
623
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
624
+ raise GetTaskError(f"Post processing failed with status {status}", status_code=status)
292
625
 
293
- response = await self._client._regular_request("PUT", url, data=body, headers=headers)
294
626
  return response
295
-
627
+
296
628
  @require_api_key
297
- async def download_file(self,
298
- job_name: str,
299
- bucket: str,
300
- file_type: str,
301
- output_path: str,
302
- folder: str = None,
303
- page_size: int = None,
304
- ) -> list:
305
- """
306
- Download a file from mass_stats using job name and file name.
307
-
308
- Args:
309
- job_name: Name of the job
310
- download_all: Whether to download all raw files from the job
311
- file_type: either 'raw' or 'processed'
312
- current_page: Current page number for pagination
313
- page_size: Number of file per page for download
314
- output_path: Path where the file should be saved
315
-
316
- Returns:
317
- str: Path to the downloaded file
629
+ async def zonal_stats(
630
+ self,
631
+ collection: str,
632
+ id_property: str,
633
+ column_name: str,
634
+ expr: str,
635
+ resolution: Optional[int] = 1,
636
+ in_crs: Optional[str] = "epsg:4326",
637
+ out_crs: Optional[str] = "epsg:4326"
638
+ ) -> Dict[str, Any]:
318
639
  """
640
+ Run zonal stats over uploaded geojson collection.
319
641
 
642
+ Args:
643
+ collection: Name of collection
644
+ id_property: Property key in geojson to use as id
645
+ column_name: Name of new column to add
646
+ expr: Terrak.io expression to evaluate
647
+ resolution: Resolution of request (optional, defaults to 1)
648
+ in_crs: CRS of geojson (optional, defaults to "epsg:4326")
649
+ out_crs: Desired output CRS (optional, defaults to "epsg:4326")
320
650
 
321
- if file_type not in ("raw", "processed"):
322
- raise ValueError("file_type must be 'raw' or 'processed'.")
323
-
324
- if file_type == "raw" and page_size is None:
325
- raise ValueError("page_size is required to define pagination size when downloading raw files.")
651
+ Returns:
652
+ API response as a dictionary containing task information
326
653
 
327
- request_body = {
328
- "job_name": job_name,
329
- "bucket": bucket,
330
- "file_type": file_type,
331
- "folder": folder
654
+ Raises:
655
+ CollectionNotFoundError: If the collection is not found
656
+ GetTaskError: If the API request fails due to unknown reasons
657
+ """
658
+ payload = {
659
+ "id_property": id_property,
660
+ "column_name": column_name,
661
+ "expr": expr,
662
+ "resolution": resolution,
663
+ "in_crs": in_crs,
664
+ "out_crs": out_crs
332
665
  }
666
+
667
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/zonal_stats", json=payload)
333
668
 
334
- output_dir = Path(output_path)
335
- output_dir.mkdir(parents=True, exist_ok=True)
336
- output_files = []
337
-
338
- async def download_urls_batch(download_urls, session):
339
- for i, url in enumerate(download_urls):
340
- parsed = urlparse(url)
341
- path_parts = Path(parsed.path).parts
342
- try:
343
- data_idx = path_parts.index("data") if file_type == "raw" else path_parts.index("outputs")
344
- subpath = Path(*path_parts[data_idx + 1:])
345
- except ValueError:
346
- subpath = Path(path_parts[-1])
347
- file_save_path = output_dir / subpath
348
- file_save_path.parent.mkdir(parents=True, exist_ok=True)
349
- self._client.logger.info(f"Downloading file to {file_save_path} ({i+1}/{len(download_urls)})")
350
-
351
- async with session.get(url) as resp:
352
- resp.raise_for_status()
353
- import aiofiles
354
- async with aiofiles.open(file_save_path, 'wb') as file:
355
- async for chunk in resp.content.iter_chunked(1048576): # 1 MB
356
- if chunk:
357
- await file.write(chunk)
358
-
359
- if not os.path.exists(file_save_path):
360
- raise Exception(f"File was not written to {file_save_path}")
361
-
362
- file_size = os.path.getsize(file_save_path)
363
- self._client.logger.info(f"File downloaded successfully to {file_save_path} (size: {file_size / (1024 * 1024):.4f} mb)")
364
- output_files.append(str(file_save_path))
365
-
366
- try:
367
- page = 1
368
- total_files = None
369
- downloaded_files = 0
370
- async with aiohttp.ClientSession() as session:
371
- while True:
372
- params = {
373
- "page": page,
374
- "page_size": page_size
375
- }
376
- response = await self._client._terrakio_request("POST", "mass_stats/download_files", json=request_body, params=params)
377
- data = response
378
-
379
- download_urls = data.get('download_urls')
380
- if not download_urls:
381
- break
382
- await download_urls_batch(download_urls, session)
383
- if total_files is None:
384
- total_files = data.get('subdir_total_files')
385
- downloaded_files += len(download_urls)
386
- if total_files is not None and downloaded_files >= total_files:
387
- break
388
- if len(download_urls) < page_size:
389
- break # Last page
390
- page += 1
391
- return output_files
392
- except Exception as e:
393
- raise Exception(f"Error in download process: {e}")
394
-
395
- def validate_request(self, request_json: Union[str, List[Dict]]):
396
- # Handle both file path and direct JSON data
397
- if isinstance(request_json, str):
398
- # It's a file path
399
- with open(request_json, 'r') as file:
400
- request_data = json.load(file)
401
- elif isinstance(request_json, list):
402
- # It's already JSON data
403
- request_data = request_json
404
- else:
405
- raise ValueError("request_json must be either a file path (str) or JSON data (list)")
406
-
407
- # Rest of validation logic stays exactly the same
408
- if not isinstance(request_data, list):
409
- raise ValueError("Request JSON should contain a list of dictionaries")
410
-
411
- for i, request in enumerate(request_data):
412
- if not isinstance(request, dict):
413
- raise ValueError(f"Request {i} should be a dictionary")
414
- required_keys = ["request", "group", "file"]
415
- for key in required_keys:
416
- if key not in request:
417
- raise ValueError(f"Request {i} should contain {key}")
418
- try:
419
- str(request["group"])
420
- except ValueError:
421
- ValueError("Group must be string or convertible to string")
422
- if not isinstance(request["request"], dict):
423
- raise ValueError("Request must be a dictionary")
424
- if not isinstance(request["file"], (str, int, list)):
425
- raise ValueError("'file' must be a string or a list of strings")
426
- if i == 3:
427
- break
428
-
429
- async def execute_job(
430
- self,
431
- name: str,
432
- output: str,
433
- config: Dict[str, Any],
434
- request_json: Union[str, List[Dict]], # ← Accept both file path OR data
435
- region: str = None,
436
- overwrite: bool = False,
437
- skip_existing: bool = False,
438
- location: str = None,
439
- force_loc: bool = None,
440
- server: str = None
669
+ if status != 200:
670
+ if status == 404:
671
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
672
+ raise GetTaskError(f"Zonal stats failed with status {status}", status_code=status)
673
+
674
+ return response
675
+
676
+ @require_api_key
677
+ async def zonal_stats_transform(
678
+ self,
679
+ collection: str,
680
+ consumer: str
441
681
  ) -> Dict[str, Any]:
442
682
  """
443
- Execute a mass stats job.
444
-
683
+ Transform raw data in collection. Creates a new collection.
684
+
445
685
  Args:
446
- name: The name of the job
447
- output: The output of the job
448
- config: The config of the job
449
- request_json: Path to the request JSON file
450
- overwrite: Whether to overwrite the job
451
- skip_existing: Whether to skip existing jobs
452
- location: The location of the job
453
- force_loc: Whether to force the location
454
- server: The server to use
455
-
686
+ collection: Name of collection
687
+ consumer: Post processing script (file path or script content)
688
+
456
689
  Returns:
457
- API response as a dictionary
458
-
690
+ API response as a dictionary containing task information
691
+
459
692
  Raises:
460
- APIError: If the API request fails
693
+ CollectionNotFoundError: If the collection is not found
694
+ GetTaskError: If the API request fails due to unknown reasons
461
695
  """
696
+ if os.path.isfile(consumer):
697
+ with open(consumer, 'r') as f:
698
+ script_content = f.read()
699
+ else:
700
+ script_content = consumer
701
+
702
+ files = {
703
+ 'consumer': ('script.py', script_content, 'text/plain')
704
+ }
462
705
 
463
- def extract_manifest_from_request(request_data: List[Dict[str, Any]]) -> List[str]:
464
- """Extract unique group names from request data to create manifest list."""
465
- groups = []
466
- seen_groups = set()
467
-
468
- for item in request_data:
469
- if not isinstance(item, dict):
470
- raise ValueError("Each item in request JSON should be a dictionary")
471
-
472
- if 'group' not in item:
473
- raise ValueError("Each item should have a 'group' field")
474
-
475
- group = item['group']
476
- if group not in seen_groups:
477
- groups.append(group)
478
- seen_groups.add(group)
479
-
480
- return groups
481
-
482
- # # Load and validate request JSON
483
- # try:
484
- # with open(request_json, 'r') as file:
485
- # request_data = json.load(file)
486
- # if isinstance(request_data, list):
487
- # size = len(request_data)
488
- # else:
489
- # raise ValueError(f"Request JSON file {request_json} should contain a list of dictionaries")
490
- # except FileNotFoundError as e:
491
- # return e
492
- # except json.JSONDecodeError as e:
493
- # return e
494
- try:
495
- if isinstance(request_json, str):
496
- # It's a file path
497
- with open(request_json, 'r') as file:
498
- request_data = json.load(file)
499
- elif isinstance(request_json, list):
500
- # It's already JSON data
501
- request_data = request_json
502
- else:
503
- raise ValueError("request_json must be either a file path (str) or JSON data (list)")
504
-
505
- if isinstance(request_data, list):
506
- size = len(request_data)
507
- else:
508
- raise ValueError("Request JSON should contain a list of dictionaries")
509
- except FileNotFoundError as e:
510
- return e
511
- except json.JSONDecodeError as e:
512
- return e
513
-
514
- # Generate manifest from request data (kept in memory)
515
- try:
516
- manifest_groups = extract_manifest_from_request(request_data)
517
- except Exception as e:
518
- raise ValueError(f"Error extracting manifest from request JSON: {e}")
519
-
520
- # Extract the first expression
521
- first_request = request_data[0] # Changed from data[0] to request_data[0]
522
- first_expression = first_request["request"]["expr"]
523
-
524
- # Get upload URLs
525
- upload_result = await self._upload_request(
526
- name=name,
527
- size=size,
528
- region=region,
529
- sample = first_expression,
530
- output=output,
531
- config=config,
532
- location=location,
533
- force_loc=force_loc,
534
- overwrite=overwrite,
535
- server=server,
536
- skip_existing=skip_existing
706
+ response, status = await self._client._terrakio_request(
707
+ "POST",
708
+ f"collections/{collection}/transform",
709
+ files=files
537
710
  )
711
+
712
+ if status != 200:
713
+ if status == 404:
714
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
715
+ raise GetTaskError(f"Transform failed with status {status}", status_code=status)
538
716
 
539
- requests_url = upload_result.get('requests_url')
540
- manifest_url = upload_result.get('manifest_url')
541
-
542
- if not requests_url:
543
- raise ValueError("No requests_url returned from server for request JSON upload")
544
-
545
- # Upload request JSON file
546
- try:
547
- self.validate_request(request_json)
548
-
549
- if isinstance(request_json, str):
550
- # File path - use existing _upload_file method
551
- requests_response = await self._upload_file(request_json, requests_url, use_gzip=True)
552
- else:
553
- # JSON data - use _upload_json_data method
554
- requests_response = await self._upload_json_data(request_json, requests_url, use_gzip=True)
555
-
556
- if requests_response.status not in [200, 201, 204]:
557
- # ... rest stays the same
558
- self._client.logger.error(f"Requests upload error: {requests_response.text()}")
559
- raise Exception(f"Failed to upload request JSON: {requests_response.text()}")
560
- except Exception as e:
561
- raise Exception(f"Error uploading request JSON file {request_json}: {e}")
562
-
563
- if not manifest_url:
564
- raise ValueError("No manifest_url returned from server for manifest JSON upload")
565
-
566
- # Upload manifest JSON data directly (no temporary file needed)
567
- try:
568
- manifest_response = await self._upload_json_data(manifest_groups, manifest_url, use_gzip=False)
569
- if manifest_response.status not in [200, 201, 204]:
570
- self._client.logger.error(f"Manifest upload error: {manifest_response.text()}")
571
- raise Exception(f"Failed to upload manifest JSON: {manifest_response.text()}")
572
- except Exception as e:
573
- raise Exception(f"Error uploading manifest JSON: {e}")
574
-
575
- # Start the job
576
- start_job_task_id = await self.start_job(upload_result.get("id"))
577
- return start_job_task_id
717
+ return response
578
718
 
579
719
  @require_api_key
580
- def cancel_job(self, id: str) -> Dict[str, Any]:
720
+ async def download_files(
721
+ self,
722
+ collection: str,
723
+ file_type: str,
724
+ page: Optional[int] = 0,
725
+ page_size: Optional[int] = 100,
726
+ folder: Optional[str] = None
727
+ ) -> Dict[str, Any]:
581
728
  """
582
- Cancel a mass stats job by ID.
583
-
729
+ Get list of signed urls to download files in collection.
730
+
584
731
  Args:
585
- id: The ID of the mass stats job to cancel
586
-
732
+ collection: Name of collection
733
+ file_type: Whether to return raw or processed (after post processing) files
734
+ page: Page number (optional, defaults to 0)
735
+ page_size: Number of files to return per page (optional, defaults to 100)
736
+ folder: If processed file type, which folder to download files from (optional)
737
+
587
738
  Returns:
588
- API response as a dictionary
589
-
739
+ API response as a dictionary containing list of download URLs
740
+
590
741
  Raises:
591
- APIError: If the API request fails
742
+ CollectionNotFoundError: If the collection is not found
743
+ DownloadFilesError: If the API request fails due to unknown reasons
592
744
  """
593
- return self._client._terrakio_request("POST", f"mass_stats/cancel/{id}")
594
-
595
- @require_api_key
596
- def cancel_all_jobs(self) -> Dict[str, Any]:
597
- """
598
- Cancel all mass stats jobs.
745
+ params = {"file_type": file_type}
599
746
 
600
- Returns:
601
- API response as a dictionary
602
-
603
- Raises:
604
- APIError: If the API request fails
605
- """
606
- return self._client._terrakio_request("POST", "mass_stats/cancel")
607
-
747
+ if page is not None:
748
+ params["page"] = page
749
+ if page_size is not None:
750
+ params["page_size"] = page_size
751
+ if folder is not None:
752
+ params["folder"] = folder
753
+
754
+ response, status = await self._client._terrakio_request("GET", f"collections/{collection}/download", params=params)
755
+
756
+ if status != 200:
757
+ if status == 404:
758
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
759
+ raise DownloadFilesError(f"Download files failed with status {status}", status_code=status)
760
+
761
+ return response
762
+
608
763
  @require_api_key
609
- async def random_sample(
764
+ async def cancel_task(
610
765
  self,
611
- name: str,
612
- config: dict,
613
- aoi: dict,
614
- samples: int,
615
- crs: str,
616
- tile_size: int,
617
- res: float,
618
- output: str,
619
- year_range: list[int] = None,
620
- overwrite: bool = False,
621
- server: str = None,
622
- bucket: str = None
623
- ) -> Dict[str, Any]:
766
+ task_id: str
767
+ ):
624
768
  """
625
- Submit a random sample job.
769
+ Cancel a task by task ID.
626
770
 
627
771
  Args:
628
- name: The name of the job
629
- config: The config of the job
630
- aoi: The AOI of the job
631
- samples: The number of samples to take
632
- crs: The CRS of the job
633
- tile_size: The tile size of the job
634
- res: The resolution of the job
635
- output: The output of the job
636
- year_range: The year range of the job
637
- overwrite: Whether to overwrite the job
638
- server: The server to use
639
- bucket: The bucket to use
772
+ task_id: ID of task to cancel
640
773
 
641
774
  Returns:
642
- API response as a dictionary
643
-
644
- Raises:
645
- APIError: If the API request fails
775
+ API response as a dictionary containing task information
776
+
777
+ Raises:
778
+ TaskNotFoundError: If the task is not found
779
+ CancelTaskError: If the API request fails due to unknown reasons
646
780
  """
647
- payload ={
648
- "name": name,
649
- "config": config,
650
- "aoi": aoi,
651
- "samples": samples,
652
- "crs": crs,
653
- "tile_size": tile_size,
654
- "res": res,
655
- "output": output,
656
- "overwrite": str(overwrite).lower(),
657
- }
658
- payload_mapping = {
659
- "year_range": year_range,
660
- "server": server,
661
- "bucket": bucket,
662
- }
663
- for key, value in payload_mapping.items():
664
- if value is not None:
665
- payload[key] = value
666
- return await self._client._terrakio_request("POST", "random_sample", json=payload)
781
+ response, status = await self._client._terrakio_request("POST", f"tasks/cancel/{task_id}")
782
+ if status != 200:
783
+ if status == 404:
784
+ raise TaskNotFoundError(f"Task {task_id} not found", status_code=status)
785
+ raise CancelTaskError(f"Cancel task failed with status {status}", status_code=status)
667
786
 
787
+ return response
668
788
 
669
789
  @require_api_key
670
- def create_pyramids(self, name: str, levels: int, config: dict) -> Dict[str, Any]:
790
+ async def cancel_collection_tasks(
791
+ self,
792
+ collection: str
793
+ ):
671
794
  """
672
- Create pyramids for a dataset.
795
+ Cancel all tasks for a collection.
673
796
 
674
797
  Args:
675
- name: The name of the job
676
- levels: The levels of the pyramids
677
- config: The config of the job
798
+ collection: Name of collection
678
799
 
679
800
  Returns:
680
- API response as a dictionary
801
+ API response as a dictionary containing task information for the collection
802
+
803
+ Raises:
804
+ CollectionNotFoundError: If the collection is not found
805
+ CancelCollectionTasksError: If the API request fails due to unknown reasons
681
806
  """
682
- payload = {
683
- "name": name,
684
- "levels": levels,
685
- "config": config
686
- }
687
- return self._client._terrakio_request("POST", "pyramids/create", json=payload)
807
+
808
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/cancel")
809
+ if status != 200:
810
+ if status == 404:
811
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
812
+ raise CancelCollectionTasksError(f"Cancel collection tasks failed with status {status}", status_code=status)
688
813
 
814
+ return response
815
+
689
816
  @require_api_key
690
- async def combine_tiles(self, data_name: str, overwrite: bool = True, output: str = "netcdf", max_file_size_mb = 5120) -> Dict[str, Any]:
817
+ async def cancel_all_tasks(
818
+ self
819
+ ):
691
820
  """
692
- Combine tiles for a dataset.
693
-
694
- Args:
695
- data_name: The name of the dataset
696
- overwrite: Whether to overwrite the dataset
697
- output: The output of the dataset
821
+ Cancel all tasks for the current user.
698
822
 
699
823
  Returns:
700
- API response as a dictionary
824
+ API response as a dictionary containing task information for all tasks
701
825
 
702
826
  Raises:
703
- APIError: If the API request fails
827
+ CancelAllTasksError: If the API request fails due to unknown reasons
704
828
  """
705
- payload = {
706
- 'data_name': data_name,
707
- 'folder': "file-gen",
708
- 'output': output,
709
- 'overwrite': str(overwrite).lower(),
710
- 'max_file_size_mb': max_file_size_mb
711
- }
712
- return await self._client._terrakio_request("POST", "mass_stats/combine_tiles", json=payload)
829
+
830
+ response, status = await self._client._terrakio_request("POST", "tasks/cancel")
831
+
832
+ if status != 200:
833
+ raise CancelAllTasksError(f"Cancel all tasks failed with status {status}", status_code=status)
834
+
835
+ return response