terrakio-core 0.4.98.1b5__py3-none-any.whl → 0.4.98.1b7__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,34 +1,34 @@
1
+ import json
1
2
  import os
2
3
  import time
3
- import typer
4
4
  from typing import Dict, Any, Optional, List
5
- from dateutil import parser
6
5
 
6
+ import aiohttp
7
+ import typer
8
+ from dateutil import parser
7
9
  from rich.console import Console
8
10
  from rich.progress import Progress, TextColumn, BarColumn, TaskProgressColumn, TimeElapsedColumn
9
11
 
10
12
  from ..exceptions import (
13
+ CancelAllTasksError,
14
+ CancelCollectionTasksError,
15
+ CancelTaskError,
16
+ CollectionAlreadyExistsError,
17
+ CollectionNotFoundError,
11
18
  CreateCollectionError,
19
+ DeleteCollectionError,
20
+ DownloadFilesError,
12
21
  GetCollectionError,
13
- ListCollectionsError,
14
- CollectionNotFoundError,
15
- CollectionAlreadyExistsError,
22
+ GetTaskError,
16
23
  InvalidCollectionTypeError,
17
- DeleteCollectionError,
24
+ ListCollectionsError,
18
25
  ListTasksError,
19
- UploadRequestsError,
20
- UploadArtifactsError,
21
- GetTaskError,
22
26
  TaskNotFoundError,
23
- DownloadFilesError,
24
- CancelTaskError,
25
- CancelCollectionTasksError,
26
- CancelAllTasksError,
27
+ UploadArtifactsError,
28
+ UploadRequestsError,
27
29
  )
28
30
  from ..helper.decorators import require_api_key
29
31
 
30
- import aiohttp # Make sure this is imported at the top
31
-
32
32
 
33
33
  class MassStats:
34
34
  def __init__(self, client):
@@ -156,6 +156,7 @@ class MassStats:
156
156
 
157
157
  self.console.print(f"[bold green]All {number_of_jobs} jobs finished![/bold green]")
158
158
 
159
+ # below are functions related to collection
159
160
  @require_api_key
160
161
  async def create_collection(
161
162
  self,
@@ -202,45 +203,6 @@ class MassStats:
202
203
 
203
204
  return response
204
205
 
205
- @require_api_key
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]:
213
- """
214
- Delete a collection by name.
215
-
216
- Args:
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
-
222
- Returns:
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
228
- """
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
-
244
206
  @require_api_key
245
207
  async def get_collection(self, collection: str) -> Dict[str, Any]:
246
208
  """
@@ -303,6 +265,46 @@ class MassStats:
303
265
 
304
266
  return response
305
267
 
268
+ @require_api_key
269
+ async def delete_collection(
270
+ self,
271
+ collection: str,
272
+ full: Optional[bool] = False,
273
+ outputs: Optional[list] = [],
274
+ data: Optional[bool] = False
275
+ ) -> Dict[str, Any]:
276
+ """
277
+ Delete a collection by name.
278
+
279
+ Args:
280
+ collection: The name of the collection to delete (required)
281
+ full: Delete the full collection (optional, defaults to False)
282
+ outputs: Specific output folders to delete (optional, defaults to empty list)
283
+ data: Whether to delete raw data (xdata folder) (optional, defaults to False)
284
+
285
+ Returns:
286
+ API response as a dictionary confirming deletion
287
+
288
+ Raises:
289
+ CollectionNotFoundError: If the collection is not found
290
+ DeleteCollectionError: If the API request fails due to unknown reasons
291
+ """
292
+ payload = {
293
+ "full": full,
294
+ "outputs": outputs,
295
+ "data": data
296
+ }
297
+
298
+ response, status = await self._client._terrakio_request("DELETE", f"collections/{collection}", json=payload)
299
+
300
+ if status != 200:
301
+ if status == 404:
302
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
303
+ raise DeleteCollectionError(f"Delete collection failed with status {status}", status_code=status)
304
+
305
+ return response
306
+
307
+ # below are functions related to tasks
306
308
  @require_api_key
307
309
  async def list_tasks(
308
310
  self,
@@ -332,34 +334,109 @@ class MassStats:
332
334
  raise ListTasksError(f"List tasks failed with status {status}", status_code=status)
333
335
 
334
336
  return response
337
+
338
+ @require_api_key
339
+ async def get_task(
340
+ self,
341
+ task_id: str
342
+ ) -> Dict[str, Any]:
343
+ """
344
+ Get task information by task ID.
345
+
346
+ Args:
347
+ task_id: ID of task to track
348
+
349
+ Returns:
350
+ API response as a dictionary containing task information
351
+
352
+ Raises:
353
+ TaskNotFoundError: If the task is not found
354
+ GetTaskError: If the API request fails due to unknown reasons
355
+ """
356
+ response, status = await self._client._terrakio_request("GET", f"tasks/info/{task_id}")
357
+
358
+ if status != 200:
359
+ if status == 404:
360
+ raise TaskNotFoundError(f"Task {task_id} not found", status_code=status)
361
+ raise GetTaskError(f"Get task failed with status {status}", status_code=status)
362
+
363
+ return response
364
+
365
+ @require_api_key
366
+ async def cancel_task(
367
+ self,
368
+ task_id: str
369
+ ) -> Dict[str, Any]:
370
+ """
371
+ Cancel a task by task ID.
372
+
373
+ Args:
374
+ task_id: ID of task to cancel
375
+
376
+ Returns:
377
+ API response as a dictionary containing task information
378
+
379
+ Raises:
380
+ TaskNotFoundError: If the task is not found
381
+ CancelTaskError: If the API request fails due to unknown reasons
382
+ """
383
+ response, status = await self._client._terrakio_request("POST", f"tasks/cancel/{task_id}")
384
+
385
+ if status != 200:
386
+ if status == 404:
387
+ raise TaskNotFoundError(f"Task {task_id} not found", status_code=status)
388
+ raise CancelTaskError(f"Cancel task failed with status {status}", status_code=status)
389
+
390
+ return response
335
391
 
336
392
  @require_api_key
337
- async def upload_requests(
393
+ async def cancel_collection_tasks(
338
394
  self,
339
395
  collection: str
340
396
  ) -> Dict[str, Any]:
341
397
  """
342
- Retrieve signed url to upload requests for a collection.
398
+ Cancel all tasks for a collection.
343
399
 
344
400
  Args:
345
401
  collection: Name of collection
346
-
402
+
347
403
  Returns:
348
- API response as a dictionary containing the upload URL
404
+ API response as a dictionary containing task information for the collection
349
405
 
350
406
  Raises:
351
407
  CollectionNotFoundError: If the collection is not found
352
- UploadRequestsError: If the API request fails due to unknown reasons
408
+ CancelCollectionTasksError: If the API request fails due to unknown reasons
353
409
  """
354
- response, status = await self._client._terrakio_request("GET", f"collections/{collection}/upload/requests")
355
-
410
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/cancel")
411
+
356
412
  if status != 200:
357
413
  if status == 404:
358
414
  raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
359
- raise UploadRequestsError(f"Upload requests failed with status {status}", status_code=status)
415
+ raise CancelCollectionTasksError(f"Cancel collection tasks failed with status {status}", status_code=status)
416
+
417
+ return response
418
+
419
+ @require_api_key
420
+ async def cancel_all_tasks(
421
+ self
422
+ ) -> Dict[str, Any]:
423
+ """
424
+ Cancel all tasks for the current user.
425
+
426
+ Returns:
427
+ API response as a dictionary containing task information for all tasks
428
+
429
+ Raises:
430
+ CancelAllTasksError: If the API request fails due to unknown reasons
431
+ """
432
+ response, status = await self._client._terrakio_request("POST", "tasks/cancel")
433
+
434
+ if status != 200:
435
+ raise CancelAllTasksError(f"Cancel all tasks failed with status {status}", status_code=status)
360
436
 
361
437
  return response
362
438
 
439
+ # below are functions related to the web ui and needs to be deleted in the future
363
440
  @require_api_key
364
441
  async def upload_artifacts(
365
442
  self,
@@ -395,53 +472,68 @@ class MassStats:
395
472
  raise UploadArtifactsError(f"Upload artifacts failed with status {status}", status_code=status)
396
473
 
397
474
  return response
398
-
475
+
399
476
  @require_api_key
400
- async def get_task(
477
+ async def zonal_stats(
401
478
  self,
402
- task_id: str
479
+ collection: str,
480
+ id_property: str,
481
+ column_name: str,
482
+ expr: str,
483
+ resolution: Optional[int] = 1,
484
+ in_crs: Optional[str] = "epsg:4326",
485
+ out_crs: Optional[str] = "epsg:4326"
403
486
  ) -> Dict[str, Any]:
404
487
  """
405
- Get task information by task ID.
488
+ Run zonal stats over uploaded geojson collection.
406
489
 
407
490
  Args:
408
- task_id: ID of task to track
409
-
491
+ collection: Name of collection
492
+ id_property: Property key in geojson to use as id
493
+ column_name: Name of new column to add
494
+ expr: Terrak.io expression to evaluate
495
+ resolution: Resolution of request (optional, defaults to 1)
496
+ in_crs: CRS of geojson (optional, defaults to "epsg:4326")
497
+ out_crs: Desired output CRS (optional, defaults to "epsg:4326")
498
+
410
499
  Returns:
411
500
  API response as a dictionary containing task information
412
501
 
413
502
  Raises:
414
- TaskNotFoundError: If the task is not found
503
+ CollectionNotFoundError: If the collection is not found
415
504
  GetTaskError: If the API request fails due to unknown reasons
416
505
  """
417
- response, status = await self._client._terrakio_request("GET", f"tasks/info/{task_id}")
506
+ payload = {
507
+ "id_property": id_property,
508
+ "column_name": column_name,
509
+ "expr": expr,
510
+ "resolution": resolution,
511
+ "in_crs": in_crs,
512
+ "out_crs": out_crs
513
+ }
514
+
515
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/zonal_stats", json=payload)
418
516
 
419
517
  if status != 200:
420
518
  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)
519
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
520
+ raise GetTaskError(f"Zonal stats failed with status {status}", status_code=status)
423
521
 
424
522
  return response
425
523
 
426
524
  @require_api_key
427
- async def generate_data(
525
+ async def zonal_stats_transform(
428
526
  self,
429
527
  collection: str,
430
- output: str,
431
- skip_existing: Optional[bool] = True,
432
- force_loc: Optional[bool] = None,
433
- server: Optional[str] = None
528
+ consumer: str
434
529
  ) -> Dict[str, Any]:
435
530
  """
436
- Generate data for a collection.
531
+ Transform raw data in collection. Creates a new collection.
437
532
 
438
533
  Args:
439
534
  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
-
535
+ consumer: Post processing script (file path or script content)
536
+
445
537
  Returns:
446
538
  API response as a dictionary containing task information
447
539
 
@@ -449,53 +541,127 @@ class MassStats:
449
541
  CollectionNotFoundError: If the collection is not found
450
542
  GetTaskError: If the API request fails due to unknown reasons
451
543
  """
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
544
+ if os.path.isfile(consumer):
545
+ with open(consumer, 'r') as f:
546
+ script_content = f.read()
547
+ else:
548
+ script_content = consumer
549
+
550
+ files = {
551
+ 'consumer': ('script.py', script_content, 'text/plain')
552
+ }
458
553
 
459
- response, status = await self._client._terrakio_request("POST", f"collections/{collection}/generate_data", json=payload)
554
+ response, status = await self._client._terrakio_request(
555
+ "POST",
556
+ f"collections/{collection}/transform",
557
+ files=files
558
+ )
460
559
 
461
560
  if status != 200:
462
561
  if status == 404:
463
562
  raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
464
- raise GetTaskError(f"Generate data failed with status {status}", status_code=status)
563
+ raise GetTaskError(f"Transform failed with status {status}", status_code=status)
465
564
 
466
565
  return response
467
566
 
567
+ async def _upload_requests(
568
+ self,
569
+ collection: str
570
+ ) -> Dict[str, Any]:
571
+ """
572
+ Retrieve signed url to upload requests for a collection.
573
+
574
+ Args:
575
+ collection: Name of collection
576
+
577
+ Returns:
578
+ API response as a dictionary containing the upload URL
579
+
580
+ Raises:
581
+ CollectionNotFoundError: If the collection is not found
582
+ UploadRequestsError: If the API request fails due to unknown reasons
583
+ """
584
+ response, status = await self._client._terrakio_request("GET", f"collections/{collection}/upload/requests")
585
+
586
+ if status != 200:
587
+ if status == 404:
588
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
589
+ raise UploadRequestsError(f"Upload requests failed with status {status}", status_code=status)
590
+
591
+ return response
592
+
468
593
  @require_api_key
469
- async def training_samples(
594
+ async def _upload_file(self, file_path: str, url: str, use_gzip: bool = True):
595
+ """
596
+ Helper method to upload a JSON file to a signed URL.
597
+
598
+ Args:
599
+ file_path: Path to the JSON file
600
+ url: Signed URL to upload to
601
+ use_gzip: Whether to compress the file with gzip
602
+ """
603
+ try:
604
+ with open(file_path, 'r') as file:
605
+ json_data = json.load(file)
606
+ except FileNotFoundError:
607
+ raise FileNotFoundError(f"JSON file not found: {file_path}")
608
+ except json.JSONDecodeError as e:
609
+ raise ValueError(f"Invalid JSON in file {file_path}: {e}")
610
+
611
+ return await self._upload_json_data(json_data, url, use_gzip)
612
+
613
+ @require_api_key
614
+ async def _upload_json_data(self, json_data, url: str, use_gzip: bool = True):
615
+ """
616
+ Helper method to upload JSON data directly to a signed URL.
617
+
618
+ Args:
619
+ json_data: JSON data (dict or list) to upload
620
+ url: Signed URL to upload to
621
+ use_gzip: Whether to compress the data with gzip
622
+ """
623
+ if hasattr(json, 'dumps') and 'ignore_nan' in json.dumps.__code__.co_varnames:
624
+ dumps_kwargs = {'ignore_nan': True}
625
+ else:
626
+ dumps_kwargs = {}
627
+
628
+ if use_gzip:
629
+ import gzip
630
+ body = gzip.compress(json.dumps(json_data, **dumps_kwargs).encode('utf-8'))
631
+ headers = {
632
+ 'Content-Type': 'application/json',
633
+ 'Content-Encoding': 'gzip'
634
+ }
635
+ else:
636
+ body = json.dumps(json_data, **dumps_kwargs).encode('utf-8')
637
+ headers = {
638
+ 'Content-Type': 'application/json'
639
+ }
640
+
641
+ response = await self._client._regular_request("PUT", url, data=body, headers=headers)
642
+ return response
643
+
644
+ @require_api_key
645
+ async def generate_data(
470
646
  self,
471
647
  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,
648
+ file_path: str,
479
649
  output: str,
480
- year_range: Optional[list[int]] = None,
650
+ skip_existing: Optional[bool] = True,
651
+ force_loc: Optional[bool] = None,
481
652
  server: Optional[str] = None
482
653
  ) -> Dict[str, Any]:
483
654
  """
484
- Generate training samples for a collection.
655
+ Generate data for a collection.
485
656
 
486
657
  Args:
487
658
  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
659
+ file_path: Path to the file to upload
660
+ output: Output type (str)
661
+ force_loc: Write data directly to the cloud under this folder
662
+ skip_existing: Skip existing data
497
663
  server: Server to use
498
-
664
+
499
665
  Returns:
500
666
  API response as a dictionary containing task information
501
667
 
@@ -503,81 +669,34 @@ class MassStats:
503
669
  CollectionNotFoundError: If the collection is not found
504
670
  GetTaskError: If the API request fails due to unknown reasons
505
671
  """
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
- }
672
+ await self.create_collection(
673
+ collection = collection
674
+ )
675
+
676
+ upload_urls = await self._upload_requests(
677
+ collection = collection
678
+ )
679
+
680
+ url = upload_urls['url']
681
+
682
+ await self._upload_file(file_path, url)
683
+
684
+ payload = {"output": output, "skip_existing": skip_existing}
516
685
 
517
- if year_range is not None:
518
- payload["year_range"] = year_range
686
+ if force_loc is not None:
687
+ payload["force_loc"] = force_loc
519
688
  if server is not None:
520
689
  payload["server"] = server
521
690
 
522
- response, status = await self._client._terrakio_request("POST", f"collections/{collection}/training_samples", json=payload)
691
+ response, status = await self._client._terrakio_request("POST", f"collections/{collection}/generate_data", json=payload)
523
692
 
524
693
  if status != 200:
525
694
  if status == 404:
526
695
  raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
527
- raise GetTaskError(f"Training sample failed with status {status}", status_code=status)
696
+ raise GetTaskError(f"Generate data failed with status {status}", status_code=status)
528
697
 
529
698
  return response
530
699
 
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
- # }
565
-
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)
577
-
578
- # return response
579
-
580
-
581
700
  @require_api_key
582
701
  async def post_processing(
583
702
  self,
@@ -600,22 +719,24 @@ class MassStats:
600
719
  CollectionNotFoundError: If the collection is not found
601
720
  GetTaskError: If the API request fails due to unknown reasons
602
721
  """
603
- # Read file and build multipart form data
722
+ await self.create_collection(
723
+ collection = collection
724
+ )
725
+
604
726
  with open(consumer, 'rb') as f:
605
727
  form = aiohttp.FormData()
606
- form.add_field('folder', folder) # Add text field
728
+ form.add_field('folder', folder)
607
729
  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
730
+ 'consumer',
731
+ f.read(),
732
+ filename='consumer.py',
733
+ content_type='text/x-python'
612
734
  )
613
735
 
614
- # Send using data= with FormData object (NOT files=)
615
736
  response, status = await self._client._terrakio_request(
616
737
  "POST",
617
738
  f"collections/{collection}/post_process",
618
- data=form # ✅ Pass FormData as data
739
+ data=form
619
740
  )
620
741
 
621
742
  if status != 200:
@@ -626,95 +747,137 @@ class MassStats:
626
747
  return response
627
748
 
628
749
  @require_api_key
629
- async def zonal_stats(
750
+ async def training_samples(
630
751
  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]:
752
+ name: str,
753
+ aoi: str,
754
+ expression_x: str,
755
+ filter_x: str = "skip",
756
+ filter_x_rate: float = 1,
757
+ expression_y: str = "skip",
758
+ filter_y: str = "skip",
759
+ filter_y_rate: float = 1,
760
+ samples: int = 1000,
761
+ tile_size: float = 256,
762
+ crs: str = "epsg:3577",
763
+ res: float = 10,
764
+ res_y: float = None,
765
+ skip_test: bool = False,
766
+ start_year: int = None,
767
+ end_year: int = None,
768
+ bucket: str = None,
769
+ server: str = None,
770
+ extra_filters: list[str] = None,
771
+ extra_filters_rate: list[float] = None,
772
+ extra_filters_res: list[float] = None
773
+ ) -> dict:
639
774
  """
640
- Run zonal stats over uploaded geojson collection.
775
+ Generate an AI dataset using specified parameters.
641
776
 
642
777
  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")
778
+ name: Name of the collection to create
779
+ aoi: Path to GeoJSON file containing area of interest
780
+ expression_x: Expression for X data (features)
781
+ filter_x: Filter expression for X data (default: "skip")
782
+ filter_x_rate: Filter rate for X data (default: 1)
783
+ expression_y: Expression for Y data (labels) (default: "skip")
784
+ filter_y: Filter expression for Y data (default: "skip")
785
+ filter_y_rate: Filter rate for Y data (default: 1)
786
+ samples: Number of samples to generate (default: 1000)
787
+ tile_size: Size of tiles in pixels (default: 256)
788
+ crs: Coordinate reference system (default: "epsg:3577")
789
+ res: Resolution for X data (default: 10)
790
+ res_y: Resolution for Y data, defaults to res if None
791
+ skip_test: Skip expression validation test (default: False)
792
+ start_year: Start year for temporal filtering
793
+ end_year: End year for temporal filtering
794
+ bucket: Storage bucket name
795
+ server: Server to use for processing
796
+ extra_filters: Additional filter expressions
797
+ extra_filters_rate: Rates for additional filters
798
+ extra_filters_res: Resolutions for additional filters
650
799
 
651
800
  Returns:
652
- API response as a dictionary containing task information
801
+ Response containing task_id and collection name
653
802
 
654
803
  Raises:
655
804
  CollectionNotFoundError: If the collection is not found
656
- GetTaskError: If the API request fails due to unknown reasons
805
+ GetTaskError: If the API request fails
806
+ TypeError: If extra filters have mismatched rate and resolution lists
657
807
  """
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
665
- }
808
+ expressions = [{"expr": expression_x, "res": res, "prefix": "x"}]
666
809
 
667
- response, status = await self._client._terrakio_request("POST", f"collections/{collection}/zonal_stats", json=payload)
668
-
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)
810
+ res_y = res_y or res
673
811
 
674
- return response
675
-
676
- @require_api_key
677
- async def zonal_stats_transform(
678
- self,
679
- collection: str,
680
- consumer: str
681
- ) -> Dict[str, Any]:
682
- """
683
- Transform raw data in collection. Creates a new collection.
684
-
685
- Args:
686
- collection: Name of collection
687
- consumer: Post processing script (file path or script content)
688
-
689
- Returns:
690
- API response as a dictionary containing task information
691
-
692
- Raises:
693
- CollectionNotFoundError: If the collection is not found
694
- GetTaskError: If the API request fails due to unknown reasons
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
812
+ if expression_y != "skip":
813
+ expressions.append({"expr": expression_y, "res": res_y, "prefix": "y"})
814
+
815
+ filters = []
816
+ if filter_x != "skip":
817
+ filters.append({"expr": filter_x, "res": res, "rate": filter_x_rate})
818
+
819
+ if filter_y != "skip":
820
+ filters.append({"expr": filter_y, "res": res_y, "rate": filter_y_rate})
821
+
822
+ if extra_filters:
823
+ try:
824
+ extra_filters_combined = zip(extra_filters, extra_filters_res, extra_filters_rate, strict=True)
825
+ except TypeError:
826
+ raise TypeError("Extra filters must have matching rate and resolution.")
827
+
828
+ for expr, filter_res, rate in extra_filters_combined:
829
+ filters.append({"expr": expr, "res": filter_res, "rate": rate})
830
+
831
+ if start_year is not None:
832
+ for expr_dict in expressions:
833
+ expr_dict["expr"] = expr_dict["expr"].replace("{year}", str(start_year))
834
+
835
+ for filter_dict in filters:
836
+ filter_dict["expr"] = filter_dict["expr"].replace("{year}", str(start_year))
837
+
838
+ if not skip_test:
839
+ for expr_dict in expressions:
840
+ test_request = self._client.model._generate_test_request(expr_dict["expr"], crs, -1)
841
+ await self._client._terrakio_request("POST", "geoquery", json=test_request)
842
+
843
+ for filter_dict in filters:
844
+ test_request = self._client.model._generate_test_request(filter_dict["expr"], crs, -1)
845
+ await self._client._terrakio_request("POST", "geoquery", json=test_request)
846
+
847
+ with open(aoi, 'r') as f:
848
+ aoi_data = json.load(f)
849
+
850
+ await self.create_collection(
851
+ collection = name,
852
+ bucket = bucket,
853
+ collection_type = "basic"
854
+ )
701
855
 
702
- files = {
703
- 'consumer': ('script.py', script_content, 'text/plain')
856
+ payload = {
857
+ "expressions": expressions,
858
+ "filters": filters,
859
+ "aoi": aoi_data,
860
+ "samples": samples,
861
+ "crs": crs,
862
+ "tile_size": tile_size,
863
+ "res": res,
864
+ "output": "nc",
865
+ "year_range": [start_year, end_year],
866
+ "server": server
704
867
  }
705
868
 
706
- response, status = await self._client._terrakio_request(
707
- "POST",
708
- f"collections/{collection}/transform",
709
- files=files
710
- )
869
+ task_id_dict, status = await self._client._terrakio_request("POST", f"collections/{name}/training_samples", json=payload)
711
870
 
712
871
  if status != 200:
713
872
  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)
873
+ raise CollectionNotFoundError(f"Collection {name} not found", status_code=status)
874
+ raise GetTaskError(f"Training sample failed with status {status}", status_code=status)
716
875
 
717
- return response
876
+ task_id = task_id_dict["task_id"]
877
+
878
+ await self._client.mass_stats.track_progress(task_id)
879
+
880
+ return {"task_id": task_id, "collection": name}
718
881
 
719
882
  @require_api_key
720
883
  async def download_files(
@@ -723,25 +886,32 @@ class MassStats:
723
886
  file_type: str,
724
887
  page: Optional[int] = 0,
725
888
  page_size: Optional[int] = 100,
726
- folder: Optional[str] = None
889
+ folder: Optional[str] = None,
890
+ url: Optional[bool] = True
727
891
  ) -> Dict[str, Any]:
728
892
  """
729
- Get list of signed urls to download files in collection.
893
+ Get list of signed urls to download files in collection, or download the files directly.
730
894
 
731
895
  Args:
732
896
  collection: Name of collection
733
- file_type: Whether to return raw or processed (after post processing) files
897
+ file_type: Type of files to download - must be either 'raw' or 'processed'
734
898
  page: Page number (optional, defaults to 0)
735
899
  page_size: Number of files to return per page (optional, defaults to 100)
736
900
  folder: If processed file type, which folder to download files from (optional)
901
+ url: If True, return signed URLs; if False, download files directly (optional, defaults to True)
737
902
 
738
903
  Returns:
739
- API response as a dictionary containing list of download URLs
904
+ API response as a dictionary containing list of download URLs (if url=True),
905
+ or a dictionary with downloaded file information (if url=False)
740
906
 
741
907
  Raises:
742
908
  CollectionNotFoundError: If the collection is not found
743
909
  DownloadFilesError: If the API request fails due to unknown reasons
910
+ ValueError: If file_type is not 'raw' or 'processed'
744
911
  """
912
+ if file_type not in ['raw', 'processed']:
913
+ raise ValueError(f"file_type must be either 'raw' or 'processed', got '{file_type}'")
914
+
745
915
  params = {"file_type": file_type}
746
916
 
747
917
  if page is not None:
@@ -758,78 +928,189 @@ class MassStats:
758
928
  raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
759
929
  raise DownloadFilesError(f"Download files failed with status {status}", status_code=status)
760
930
 
761
- return response
931
+ if url:
932
+ return response
933
+
934
+ downloaded_files = []
935
+ files_to_download = response.get('files', []) if isinstance(response, dict) else []
936
+
937
+ async with aiohttp.ClientSession() as session:
938
+ for file_info in files_to_download:
939
+ try:
940
+ file_url = file_info.get('url')
941
+ filename = file_info.get('file', '')
942
+ group = file_info.get('group', '')
943
+
944
+ if not file_url:
945
+ downloaded_files.append({
946
+ 'filename': filename,
947
+ 'group': group,
948
+ 'error': 'No URL provided'
949
+ })
950
+ continue
951
+
952
+ async with session.get(file_url) as file_response:
953
+ if file_response.status == 200:
954
+ content = await file_response.read()
955
+
956
+ output_dir = folder if folder else "downloads"
957
+ if group:
958
+ output_dir = os.path.join(output_dir, group)
959
+ os.makedirs(output_dir, exist_ok=True)
960
+ filepath = os.path.join(output_dir, filename)
961
+
962
+ with open(filepath, 'wb') as f:
963
+ f.write(content)
964
+
965
+ downloaded_files.append({
966
+ 'filename': filename,
967
+ 'group': group,
968
+ 'filepath': filepath,
969
+ 'size': len(content)
970
+ })
971
+ else:
972
+ downloaded_files.append({
973
+ 'filename': filename,
974
+ 'group': group,
975
+ 'error': f"Failed to download: HTTP {file_response.status}"
976
+ })
977
+ except Exception as e:
978
+ downloaded_files.append({
979
+ 'filename': file_info.get('file', 'unknown'),
980
+ 'group': file_info.get('group', ''),
981
+ 'error': str(e)
982
+ })
983
+
984
+ return {
985
+ 'collection': collection,
986
+ 'downloaded_files': downloaded_files,
987
+ 'total': len(downloaded_files)
988
+ }
762
989
 
763
990
  @require_api_key
764
- async def cancel_task(
991
+ async def gen_and_process(
765
992
  self,
766
- task_id: str
767
- ):
993
+ collection: str,
994
+ requests_file: str,
995
+ output: str,
996
+ folder: str,
997
+ consumer: str,
998
+ extra: Optional[Dict[str, Any]] = None,
999
+ force_loc: Optional[bool] = False,
1000
+ skip_existing: Optional[bool] = True,
1001
+ server: Optional[str] = None
1002
+ ) -> Dict[str, Any]:
768
1003
  """
769
- Cancel a task by task ID.
1004
+ Generate data and run post-processing in a single task.
770
1005
 
771
1006
  Args:
772
- task_id: ID of task to cancel
1007
+ collection: Name of collection
1008
+ requests_file: Path to JSON file containing request configurations
1009
+ output: Output type (str)
1010
+ folder: Folder to store output
1011
+ consumer: Path to post processing script
1012
+ extra: Additional configuration parameters (optional)
1013
+ force_loc: Write data directly to the cloud under this folder (optional, defaults to False)
1014
+ skip_existing: Skip existing data (optional, defaults to True)
1015
+ server: Server to use (optional)
773
1016
 
774
1017
  Returns:
775
1018
  API response as a dictionary containing task information
776
1019
 
777
1020
  Raises:
778
- TaskNotFoundError: If the task is not found
779
- CancelTaskError: If the API request fails due to unknown reasons
1021
+ CollectionNotFoundError: If the collection is not found
1022
+ GetTaskError: If the API request fails due to unknown reasons
780
1023
  """
781
- response, status = await self._client._terrakio_request("POST", f"tasks/cancel/{task_id}")
1024
+ await self.create_collection(collection=collection)
1025
+
1026
+ upload_urls = await self._upload_requests(collection=collection)
1027
+ url = upload_urls['url']
1028
+ await self._upload_file(requests_file, url)
1029
+
1030
+ with open(consumer, 'rb') as f:
1031
+ form = aiohttp.FormData()
1032
+ form.add_field('output', output)
1033
+ form.add_field('force_loc', str(force_loc).lower())
1034
+ form.add_field('skip_existing', str(skip_existing).lower())
1035
+
1036
+ if server is not None:
1037
+ form.add_field('server', server)
1038
+
1039
+ form.add_field('extra', json.dumps(extra or {}))
1040
+ form.add_field('folder', folder)
1041
+ form.add_field(
1042
+ 'consumer',
1043
+ f.read(),
1044
+ filename='consumer.py',
1045
+ content_type='text/x-python'
1046
+ )
1047
+
1048
+ response, status = await self._client._terrakio_request(
1049
+ "POST",
1050
+ f"collections/{collection}/gen_and_process",
1051
+ data=form
1052
+ )
1053
+
782
1054
  if status != 200:
783
1055
  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)
1056
+ raise CollectionNotFoundError(f"Collection {collection} not found", status_code=status)
1057
+ raise GetTaskError(f"Gen and process failed with status {status}", status_code=status)
786
1058
 
787
1059
  return response
788
1060
 
789
1061
  @require_api_key
790
- async def cancel_collection_tasks(
1062
+ async def create_pyramids(
791
1063
  self,
792
- collection: str
793
- ):
1064
+ name: str,
1065
+ levels: int,
1066
+ config: Dict[str, Any]
1067
+ ) -> Dict[str, Any]:
794
1068
  """
795
- Cancel all tasks for a collection.
1069
+ Create pyramid tiles for a dataset.
796
1070
 
797
1071
  Args:
798
- collection: Name of collection
799
-
800
- Returns:
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
806
- """
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)
813
-
814
- return response
815
-
816
- @require_api_key
817
- async def cancel_all_tasks(
818
- self
819
- ):
820
- """
821
- Cancel all tasks for the current user.
1072
+ name: Dataset name
1073
+ levels: Maximum zoom level for pyramid (e.g., 8)
1074
+ config: Full pyramid configuration dictionary containing:
1075
+ - name: Dataset name (will override the name parameter)
1076
+ - bucket: GCS bucket name (e.g., "terrakio")
1077
+ - products: List of product names (e.g., ["air_temp", "prec"])
1078
+ - path: Path pattern (e.g., "pyramids/%s_%s_%03d_%03d_%02d.snp")
1079
+ - data_type: Data type (e.g., "float32")
1080
+ - i_max: Maximum i index
1081
+ - j_max: Maximum j index
1082
+ - x_size: Tile size in x (e.g., 400)
1083
+ - y_size: Tile size in y (e.g., 400)
1084
+ - dates_iso8601: List of ISO8601 date strings
1085
+ - no_data: No data value (e.g., -9999.0)
822
1086
 
823
1087
  Returns:
824
- API response as a dictionary containing task information for all tasks
1088
+ API response with task_id
825
1089
 
826
1090
  Raises:
827
- CancelAllTasksError: If the API request fails due to unknown reasons
1091
+ GetTaskError: If the API request fails
828
1092
  """
829
-
830
- response, status = await self._client._terrakio_request("POST", "tasks/cancel")
831
-
1093
+ await self.create_collection(collection = name)
1094
+
1095
+ pyramid_request = {
1096
+ 'name': name,
1097
+ 'max_zoom': levels,
1098
+ **config
1099
+ }
1100
+
1101
+ response, status = await self._client._terrakio_request(
1102
+ "POST",
1103
+ "tasks/pyramids",
1104
+ json=pyramid_request
1105
+ )
1106
+
832
1107
  if status != 200:
833
- raise CancelAllTasksError(f"Cancel all tasks failed with status {status}", status_code=status)
1108
+ raise GetTaskError(
1109
+ f"Pyramid creation failed with status {status}: {response}",
1110
+ status_code=status
1111
+ )
1112
+
1113
+ task_id = response["task_id"]
1114
+ await self.track_progress(task_id)
834
1115
 
835
- return response
1116
+ return {"task_id": task_id}
@@ -59,131 +59,6 @@ class ModelManagement:
59
59
  }
60
60
  return req
61
61
 
62
- @require_api_key
63
- async def generate_ai_dataset(
64
- self,
65
- name: str,
66
- aoi: str,
67
- expression_x: str,
68
- filter_x: str = "skip",
69
- filter_x_rate: float = 1,
70
- expression_y: str = "skip",
71
- filter_y: str = "skip",
72
- filter_y_rate: float = 1,
73
- samples: int = 1000,
74
- tile_size: float = 256,
75
- crs: str = "epsg:3577",
76
- res: float = 10,
77
- res_y: float = None,
78
- skip_test: bool = False,
79
- start_year: int = None,
80
- end_year: int = None,
81
- bucket: str = None,
82
- server: str = None,
83
- extra_filters: list[str] = None,
84
- extra_filters_rate: list[float] = None,
85
- extra_filters_res: list[float] = None
86
- ) -> dict:
87
- """
88
- Generate an AI dataset using specified parameters.
89
-
90
- Args:
91
- name (str): Name of the collection to create
92
- aoi (str): Path to GeoJSON file containing area of interest
93
- expression_x (str): Expression for X data (features)
94
- filter_x (str): Filter expression for X data (default: "skip")
95
- filter_x_rate (float): Filter rate for X data (default: 1)
96
- expression_y (str): Expression for Y data (labels) (default: "skip")
97
- filter_y (str): Filter expression for Y data (default: "skip")
98
- filter_y_rate (float): Filter rate for Y data (default: 1)
99
- samples (int): Number of samples to generate (default: 1000)
100
- tile_size (float): Size of tiles in pixels (default: 256)
101
- crs (str): Coordinate reference system (default: "epsg:3577")
102
- res (float): Resolution for X data (default: 10)
103
- res_y (float): Resolution for Y data, defaults to res if None
104
- skip_test (bool): Skip expression validation test (default: False)
105
- start_year (int): Start year for temporal filtering
106
- end_year (int): End year for temporal filtering
107
- bucket (str): Storage bucket name
108
- server (str): Server to use for processing
109
- extra_filters (list[str]): Additional filter expressions
110
- extra_filters_rate (list[float]): Rates for additional filters
111
- extra_filters_res (list[float]): Resolutions for additional filters
112
-
113
- Returns:
114
- dict: Response containing task_id and collection name
115
-
116
- Raises:
117
- APIError: If the API request fails
118
- TypeError: If extra filters have mismatched rate and resolution lists
119
- """
120
- expressions = [{"expr": expression_x, "res": res, "prefix": "x"}]
121
-
122
- res_y = res_y or res
123
-
124
- if expression_y != "skip":
125
- expressions.append({"expr": expression_y, "res": res_y, "prefix": "y"})
126
-
127
- filters = []
128
- if filter_x != "skip":
129
- filters.append({"expr": filter_x, "res": res, "rate": filter_x_rate})
130
-
131
- if filter_y != "skip":
132
- filters.append({"expr": filter_y, "res": res_y, "rate": filter_y_rate})
133
-
134
- if extra_filters:
135
- try:
136
- extra_filters_combined = zip(extra_filters, extra_filters_res, extra_filters_rate, strict=True)
137
- except TypeError:
138
- raise TypeError("Extra filters must have matching rate and resolution.")
139
-
140
- for expr, filter_res, rate in extra_filters_combined:
141
- filters.append({"expr": expr, "res": filter_res, "rate": rate})
142
-
143
- if start_year is not None:
144
- for expr_dict in expressions:
145
- expr_dict["expr"] = expr_dict["expr"].replace("{year}", str(start_year))
146
-
147
- for filter_dict in filters:
148
- filter_dict["expr"] = filter_dict["expr"].replace("{year}", str(start_year))
149
-
150
- # this is making request to the server that is being used when doing the initialization
151
- if not skip_test:
152
- for expr_dict in expressions:
153
- test_request = self._generate_test_request(expr_dict["expr"], crs, -1)
154
- await self._client._terrakio_request("POST", "geoquery", json=test_request)
155
-
156
- for filter_dict in filters:
157
- test_request = self._generate_test_request(filter_dict["expr"], crs, -1)
158
- await self._client._terrakio_request("POST", "geoquery", json=test_request)
159
-
160
- with open(aoi, 'r') as f:
161
- aoi_data = json.load(f)
162
-
163
- await self._client.mass_stats.create_collection(
164
- collection=name,
165
- bucket=bucket,
166
- collection_type="basic"
167
- )
168
-
169
- task_id_dict = await self._client.mass_stats.training_samples(
170
- collection=name,
171
- expressions=expressions,
172
- filters=filters,
173
- aoi=aoi_data,
174
- samples=samples,
175
- year_range=[start_year, end_year],
176
- crs=crs,
177
- tile_size=tile_size,
178
- res=res,
179
- output="nc",
180
- server=server
181
- )
182
-
183
- task_id = task_id_dict["task_id"]
184
-
185
- await self._client.mass_stats.track_progress(task_id)
186
-
187
62
  @require_api_key
188
63
  async def _get_url_for_upload_model_and_script(self, expression: str, model_name: str, script_name: str) -> str:
189
64
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: terrakio-core
3
- Version: 0.4.98.1b5
3
+ Version: 0.4.98.1b7
4
4
  Summary: Core package for the terrakio-python-api
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: aiofiles>=24.1.0
@@ -11,13 +11,13 @@ terrakio_core/convenience_functions/zonal_stats.py,sha256=7PI--RI0hiF1pzZ7_7hqty
11
11
  terrakio_core/endpoints/auth.py,sha256=5WvAO39aLsbJAVtxISwiOZseKr3B9I5tHjamPRehDBQ,8327
12
12
  terrakio_core/endpoints/dataset_management.py,sha256=jpwftiKOI59NhXXypqm6wtILIkfyjy9NfYifRIvhZS0,16791
13
13
  terrakio_core/endpoints/group_management.py,sha256=V0KOGTXwmePBFeym55G_m3ONR-jVj2IU4OVLiL5UKz4,15869
14
- terrakio_core/endpoints/mass_stats.py,sha256=YOtV1_yiTJwtJF-hXY5SrOZ7_tpHeXFk5HLoXcL1Yuc,30064
15
- terrakio_core/endpoints/model_management.py,sha256=AqQkFPIdPuApYC58VLRG0GxD-s6gic7Ffa9InG8lb3I,56505
14
+ terrakio_core/endpoints/mass_stats.py,sha256=6BVD6aVXcG9t-yOjPyTpwsNM5ScoYCAYnoUWl76Cqdw,41460
15
+ terrakio_core/endpoints/model_management.py,sha256=tvJ4BOBsyXKKH430byGH25CkLIzXWgxaPaL0CvL8_0Y,51341
16
16
  terrakio_core/endpoints/space_management.py,sha256=YWb55nkJnFJGlALJ520DvurxDqVqwYtsvqQPWzxzhDs,2266
17
17
  terrakio_core/endpoints/user_management.py,sha256=L_g4ysrh2xyz_JbObUU_tCxgHxisrDUPntWgQOs15GE,7709
18
18
  terrakio_core/helper/bounded_taskgroup.py,sha256=wiTH10jhKZgrsgrFUNG6gig8bFkUEPHkGRT2XY7Rgmo,677
19
19
  terrakio_core/helper/decorators.py,sha256=L6om7wmWNgCei3Wy5U0aZ-70OzsCwclkjIf7SfQuhCg,2289
20
20
  terrakio_core/helper/tiles.py,sha256=lcLCO6KiP05lCI9vngo3zCZJ6Z9C0pUxHSQS4H58EHc,2699
21
- terrakio_core-0.4.98.1b5.dist-info/METADATA,sha256=458ytSvoZuMsCqs9S_nPgW3K1_3-R_plS_WYmfAqjB8,1184
22
- terrakio_core-0.4.98.1b5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- terrakio_core-0.4.98.1b5.dist-info/RECORD,,
21
+ terrakio_core-0.4.98.1b7.dist-info/METADATA,sha256=TRow24aLBbKOYmCNkUOO2V7qMlhqC5s2HS2lfgIyMqA,1184
22
+ terrakio_core-0.4.98.1b7.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
+ terrakio_core-0.4.98.1b7.dist-info/RECORD,,