fusion-platform-python-sdk 1.15.12__tar.gz → 1.16.6__tar.gz

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.
Files changed (57) hide show
  1. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/LICENSE.txt +1 -1
  2. {fusion_platform_python_sdk-1.15.12/fusion_platform_python_sdk.egg-info → fusion_platform_python_sdk-1.16.6}/PKG-INFO +11 -2
  3. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/__init__.py +2 -2
  4. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/command.py +296 -158
  5. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/fusion_platform_sdk.pdf +0 -0
  6. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/data.py +36 -1
  7. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/data_file.md +2 -0
  8. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/data_file.py +9 -131
  9. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/model.py +2 -0
  10. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_execution.py +7 -2
  11. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/session.py +35 -1
  12. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/translations.py +6 -2
  13. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6/fusion_platform_python_sdk.egg-info}/PKG-INFO +11 -2
  14. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/setup.py +1 -1
  15. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/tests/test_session.py +6 -0
  16. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/MANIFEST.in +0 -0
  17. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/README.md +0 -0
  18. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/__main__.py +0 -0
  19. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/base.py +0 -0
  20. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/common/__init__.py +0 -0
  21. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/common/raise_thread.py +0 -0
  22. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/common/utilities.py +0 -0
  23. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/detailed_example.py +0 -0
  24. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/documentation.py +0 -0
  25. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/glasgow.geojson +0 -0
  26. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/lake_district.geojson +0 -0
  27. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/localisations.py +0 -0
  28. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/localise.py +0 -0
  29. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/__init__.py +0 -0
  30. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/credit.md +0 -0
  31. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/credit.py +0 -0
  32. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/data.md +0 -0
  33. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/fields.py +0 -0
  34. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/organisation.md +0 -0
  35. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/organisation.py +0 -0
  36. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process.md +0 -0
  37. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process.py +0 -0
  38. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_execution.md +0 -0
  39. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_service_execution.md +0 -0
  40. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_service_execution.py +0 -0
  41. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_service_execution_log.md +0 -0
  42. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/process_service_execution_log.py +0 -0
  43. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/service.md +0 -0
  44. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/service.py +0 -0
  45. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/user.md +0 -0
  46. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/models/user.py +0 -0
  47. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform/quick_example.py +0 -0
  48. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform_python_sdk.egg-info/SOURCES.txt +0 -0
  49. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform_python_sdk.egg-info/dependency_links.txt +0 -0
  50. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform_python_sdk.egg-info/entry_points.txt +0 -0
  51. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform_python_sdk.egg-info/requires.txt +0 -0
  52. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/fusion_platform_python_sdk.egg-info/top_level.txt +0 -0
  53. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/setup.cfg +0 -0
  54. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/tests/test_base.py +0 -0
  55. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/tests/test_command.py +0 -0
  56. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/tests/test_init.py +0 -0
  57. {fusion_platform_python_sdk-1.15.12 → fusion_platform_python_sdk-1.16.6}/tests/test_localise.py +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2022 Digital Content Analysis Technology Ltd
3
+ Copyright (c) 2025 Digital Content Analysis Technology Ltd
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: fusion-platform-python-sdk
3
- Version: 1.15.12
3
+ Version: 1.16.6
4
4
  Summary: Python SDK used to interact with the Fusion Platform(r)
5
5
  Home-page: https://github.com/d-cat-support/fusion-platform-python-sdk
6
6
  Author: Digital Content Analysis Technology Ltd
@@ -24,6 +24,15 @@ Requires-Dist: python-i18n[YAML]
24
24
  Requires-Dist: requests
25
25
  Requires-Dist: tenacity
26
26
  Requires-Dist: tqdm
27
+ Dynamic: author
28
+ Dynamic: author-email
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: requires-dist
35
+ Dynamic: summary
27
36
 
28
37
  # Fusion Platform<sup>&reg;</sup> Python SDK
29
38
 
@@ -11,8 +11,8 @@ API, and this is defined within the corresponding OpenAPI 3.0 specification, whi
11
11
  """
12
12
 
13
13
  # Do not modify the following two lines as they are maintained by the version.sh script.
14
- __version__ = '1.15.12'
15
- __version_date__ = '2024-12-20T11:33:56Z'
14
+ __version__ = '1.16.6'
15
+ __version_date__ = '2025-02-11T12:17:15Z'
16
16
 
17
17
  # Exclude certain sub-modules from documentation.
18
18
  # @formatter:off
@@ -126,6 +126,11 @@ class Command(Base):
126
126
  _YAML_OPTIONS = 'options'
127
127
  _YAML_DISPATCHERS = 'dispatchers'
128
128
 
129
+ # Progress bar constants.
130
+ __PROGRESS_BAR_FORMAT = '{l_bar}{bar:50}{r_bar}'
131
+ __PROGRESS_BAR_COLOUR_ACTIVE = '#3379B6'
132
+ __PROGRESS_BAR_COLOUR_COMPLETE = '#5BB85C'
133
+
129
134
  def __init__(self):
130
135
  """
131
136
  Initialises the object.
@@ -271,26 +276,33 @@ class Command(Base):
271
276
  extracted_inputs = [(k + 1, input) for k, input in enumerate(process.inputs)]
272
277
 
273
278
  self.__print(logging.INFO, i18n.t('command.define_inputs', files=len(extracted_inputs)))
274
- progress_bar = tqdm(total=len(extracted_inputs), ncols=100)
275
-
276
- for k, input in extracted_inputs:
277
- # Get the data item for this input.
278
- data, _ = organisation.find_data(id=input.id)
279
- data.check_analysis_complete(wait=True)
280
-
281
- # Get the list of files which we filter to only use those files that are not sources. This will include any files which do not have an alternative.
282
- data_files = [data_file for data_file in data.files if data_file.file_type.lower().strip() == input.file_type.lower().strip()]
283
- file_ids_to_ignore = [str(data_file.source) for data_file in data_files if hasattr(data_file, 'source') and (data_file.source is not None)]
284
- data_files = [data_file for data_file in data_files if str(data_file.file_id) not in file_ids_to_ignore]
279
+ progress_bar = tqdm(total=len(extracted_inputs), bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE,
280
+ unit='input') # Unit matches keyword argument.
281
+ progress_bar.set_postfix(input=1)
285
282
 
286
- # Build the list of downloads. Here we take just the first file found on the assumption that there should be only one.
287
- if len(data_files) > 0:
288
- downloads.append((data_files[0], data_files[0].file_name))
289
- inputs.append({Model._FIELD_NAME: data.name, Model._FIELD_FILE: data_files[0].file_name})
290
-
291
- progress_bar.update(1)
283
+ try:
284
+ for k, input in extracted_inputs:
285
+ # Get the data item for this input.
286
+ data, _ = organisation.find_data(id=input.id)
287
+ data.check_analysis_complete(wait=True)
288
+
289
+ # Get the list of files which we filter to only use those files that are not sources. This will include any files which do not have an alternative.
290
+ data_files = [data_file for data_file in data.files if data_file.file_type.lower().strip() == input.file_type.lower().strip()]
291
+ file_ids_to_ignore = [str(data_file.source) for data_file in data_files if hasattr(data_file, 'source') and (data_file.source is not None)]
292
+ data_files = [data_file for data_file in data_files if str(data_file.file_id) not in file_ids_to_ignore]
293
+
294
+ # Build the list of downloads. Here we take just the first file found on the assumption that there should be only one.
295
+ if len(data_files) > 0:
296
+ downloads.append((data_files[0], data_files[0].file_name, data_files[0].size))
297
+ inputs.append({Model._FIELD_NAME: data.name, Model._FIELD_FILE: data_files[0].file_name})
298
+
299
+ progress_bar.set_postfix(input=k)
300
+ progress_bar.update(1)
292
301
 
293
- progress_bar.close()
302
+ finally:
303
+ progress_bar.set_postfix()
304
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
305
+ progress_bar.close()
294
306
 
295
307
  else:
296
308
  # Just get the names of the inputs.
@@ -306,29 +318,36 @@ class Command(Base):
306
318
  non_aggregator_chains = process.chains if hasattr(process, Model._FIELD_CHAINS) else []
307
319
 
308
320
  self.__print(logging.INFO, i18n.t('command.define_storage', slices=non_aggregator_count))
309
- progress_bar = tqdm(total=non_aggregator_count, ncols=100)
321
+ progress_bar = tqdm(total=non_aggregator_count, bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE,
322
+ unit='slice') # Unit matches keyword argument.
323
+ progress_bar.set_postfix(slice=1)
310
324
 
311
- for group_index in range(1, non_aggregator_count + 1):
312
- ssd_ids = [(i, non_aggregator_chains[i].get(Model._FIELD_SSD_ID)) for i in range(len(non_aggregator_chains))]
313
- chain_digits = len(str(len(ssd_ids)))
325
+ try:
326
+ for group_index in range(1, non_aggregator_count + 1):
327
+ ssd_ids = [(i, non_aggregator_chains[i].get(Model._FIELD_SSD_ID)) for i in range(len(non_aggregator_chains))]
328
+ chain_digits = len(str(len(ssd_ids)))
314
329
 
315
- for chain_index, ssd_id in ssd_ids:
316
- storage_data_item = self.__get_storage_data_item(process, group_index, non_aggregator_count, ssd_id, chain_index)
330
+ for chain_index, ssd_id in ssd_ids:
331
+ storage_data_item = self.__get_storage_data_item(process, group_index, non_aggregator_count, ssd_id, chain_index)
317
332
 
318
- if storage_data_item is not None:
319
- # Add the storage file to the downloads. We assume there are zero or one file for the storage data item.
320
- storage_data_item.check_analysis_complete(wait=True)
321
- storage_data_file = next(storage_data_item.files, None)
333
+ if storage_data_item is not None:
334
+ # Add the storage file to the downloads. We assume there are zero or one file for the storage data item.
335
+ storage_data_item.check_analysis_complete(wait=True)
336
+ storage_data_file = next(storage_data_item.files, None)
322
337
 
323
- if storage_data_file is not None:
324
- path = f"{str(group_index).zfill(non_aggregator_digits)}_{str(chain_index).zfill(chain_digits)}_{storage_data_file.file_name.split('_')[-1]}"
325
- key = f"{group_index}_{non_aggregator_count}_{ssd_id}_{chain_index}"
326
- downloads.append((storage_data_file, path))
327
- storage[key] = path
338
+ if storage_data_file is not None:
339
+ path = f"{str(group_index).zfill(non_aggregator_digits)}_{str(chain_index).zfill(chain_digits)}_{storage_data_file.file_name.split('_')[-1]}"
340
+ key = f"{group_index}_{non_aggregator_count}_{ssd_id}_{chain_index}"
341
+ downloads.append((storage_data_file, path, storage_data_file.size))
342
+ storage[key] = path
328
343
 
329
- progress_bar.update(1)
344
+ progress_bar.set_postfix(slice=group_index)
345
+ progress_bar.update(1)
330
346
 
331
- progress_bar.close()
347
+ finally:
348
+ progress_bar.set_postfix()
349
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
350
+ progress_bar.close()
332
351
 
333
352
  # Download the files.
334
353
  self.__download_files(downloads)
@@ -539,11 +558,16 @@ class Command(Base):
539
558
  elif data_type == Command._DOWNLOAD_OUTPUT:
540
559
  output_sizes.append(file.size)
541
560
 
542
- downloads.append((file, path))
543
- stac_definitions.append(file.get_stac_item())
561
+ downloads.append((file, path, file.size))
562
+ stac_item, stac_item_file = file.get_stac_item()
544
563
 
545
- stac_collection = data_item.get_stac_collection(stac_definitions, owner=component.name, created_at=component.created_at, detail=component_options)
546
- stac_definitions.append(stac_collection)
564
+ if (stac_item is not None) and (stac_item_file is not None):
565
+ stac_definitions.append((stac_item, stac_item_file))
566
+
567
+ if len(stac_definitions) > 0:
568
+ stac_collection = data_item.get_stac_collection(stac_definitions, owner=component.name, created_at=component.created_at,
569
+ detail=component_options)
570
+ stac_definitions.append(stac_collection)
547
571
 
548
572
  # Write the STAC definitions.
549
573
  for stac_definition, stac_file_name in stac_definitions:
@@ -561,29 +585,68 @@ class Command(Base):
561
585
  Downloads the files from the downloads list.
562
586
 
563
587
  Args:
564
- downloads: A list of tuples with the file model and the download path.
588
+ downloads: A list of tuples with the file model, the download path and file size.
565
589
  """
566
590
  downloads = [] if downloads is None else downloads
567
591
 
568
592
  if len(downloads) > 0:
569
- # Report progress.
570
593
  self.__print(logging.INFO, i18n.t('command.download_files', files=len(downloads)))
571
- progress_bar = tqdm(total=len(downloads), ncols=100)
572
594
 
573
- # Do all the downloads in batches. We use small batches so that we do not swamp memory.
574
- batch_size = 2 * os.cpu_count() # Downloads don't swamp CPU.
595
+ # Initialise the progress bar to show bytes downloaded.
596
+ total_size = sum([size for _, _, size in downloads if size is not None])
597
+ progress_bar = tqdm(total=total_size, bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE, unit='B', unit_scale=True,
598
+ unit_divisor=1024) # Show progress in bytes.
575
599
 
576
- for batch in [downloads[i:i + batch_size] for i in range(0, len(downloads), batch_size)]:
577
- # Start downloading the batch.
578
- for file, path in batch:
579
- file.download(path=path, wait=False)
600
+ try:
601
+ # Do all the downloads in batches. We use small batches so that we do not swamp memory.
602
+ batch_size = 2 * os.cpu_count() # Downloads don't swamp CPU.
580
603
 
581
- # Wait for the download to complete.
582
- for file, path in batch:
583
- progress_bar.update(1)
584
- file.download_complete(wait=True)
604
+ for batch in [downloads[i:i + batch_size] for i in range(0, len(downloads), batch_size)]:
605
+ download_files = []
585
606
 
586
- progress_bar.close()
607
+ # Start downloading the batch.
608
+ for file, path, size in batch:
609
+ file.download(path=path, wait=False)
610
+ download_files.append((file, False, 0, size))
611
+
612
+ # Check the status of the batch of downloads so that we know when we have finished, updating the progress bar as we go.
613
+ finished = False
614
+ last_total_current_size = 0
615
+
616
+ while not finished:
617
+ # Wait a bit before checking progress again.
618
+ sleep(0.25)
619
+
620
+ # Now work out haw far we've got.
621
+ total_current_size = 0
622
+ count_complete = 0
623
+
624
+ for i in range(len(download_files)):
625
+ file, complete, current_size, download_size = download_files[i]
626
+
627
+ if not complete:
628
+ # Get the current download size.
629
+ _, _, current_size = file.download_progress()
630
+
631
+ # Now check for completion.
632
+ complete = file.download_complete()
633
+ download_files[i] = (file, complete, current_size, download_size)
634
+
635
+ count_complete += 1 if complete else 0
636
+ total_current_size += current_size
637
+
638
+ # Increment the progress bar.
639
+ update_current_size = total_current_size - last_total_current_size
640
+ last_total_current_size = total_current_size
641
+ progress_bar.update(update_current_size)
642
+
643
+ # Check if we have finished.
644
+ finished = count_complete == len(download_files)
645
+
646
+ finally:
647
+ # Make sure the progress bar is closed.
648
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
649
+ progress_bar.close()
587
650
 
588
651
  def download_process_execution(self, organisation, process_name, download_inputs, download_outputs, download_storage, download_intermediate,
589
652
  download_components, save_metrics):
@@ -645,19 +708,26 @@ class Command(Base):
645
708
  # Do all the downloads in batches. We use small batches so that we do not swamp memory.
646
709
  batch_size = 2 * os.cpu_count() # Downloads don't swamp CPU.
647
710
  self.__print(logging.INFO, i18n.t('command.download_executions', executions=len(executions)))
648
- progress_bar = tqdm(total=len(executions), ncols=100)
711
+ progress_bar = tqdm(total=len(executions), bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE,
712
+ unit='execution') # Unit matches keyword argument.
713
+ progress_bar.set_postfix(execution=1)
649
714
 
650
- # Now download each execution in batches.
651
- partial_download_execution = partial(self.__download_execution, process, download_inputs, download_outputs, download_storage, download_intermediate,
652
- download_components, output_dir)
653
- results = []
654
-
655
- with Pool(nodes=batch_size) as pool:
656
- for result in pool.uimap(partial_download_execution, executions):
657
- progress_bar.update(1)
658
- results.append(result)
715
+ try:
716
+ # Now download each execution in batches.
717
+ partial_download_execution = partial(self.__download_execution, process, download_inputs, download_outputs, download_storage, download_intermediate,
718
+ download_components, output_dir)
719
+ results = []
720
+
721
+ with Pool(nodes=batch_size) as pool:
722
+ for i, result in enumerate(pool.uimap(partial_download_execution, executions)):
723
+ progress_bar.set_postfix(execution=i + 1)
724
+ progress_bar.update(1)
725
+ results.append(result)
659
726
 
660
- progress_bar.close()
727
+ finally:
728
+ progress_bar.set_postfix()
729
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
730
+ progress_bar.close()
661
731
 
662
732
  # Combine the results.
663
733
  downloads = []
@@ -1173,9 +1243,14 @@ class Command(Base):
1173
1243
  # Form the status of the execution.
1174
1244
  status = ''
1175
1245
  group = ''
1246
+ abort_reason = i18n.t('command.log_execution_abort_reason', abort_reason=execution.abort_reason) if hasattr(execution, Model._FIELD_ABORT_REASON) else ''
1247
+ exit_type = i18n.t('command.log_execution_exit_type', exit_type=execution.exit_type) if hasattr(execution, Model._FIELD_EXIT_TYPE) else ''
1176
1248
 
1177
1249
  if execution.success:
1178
- status = i18n.t('command.log_execution_success')
1250
+ if len(abort_reason) <= 0:
1251
+ status = i18n.t('command.log_execution_success')
1252
+ else:
1253
+ status = i18n.t('command.log_execution_warning', exit_type=exit_type, abort_reason=abort_reason)
1179
1254
 
1180
1255
  elif execution.stopped:
1181
1256
  status = i18n.t('command.log_execution_stopped')
@@ -1184,9 +1259,6 @@ class Command(Base):
1184
1259
  status = i18n.t('command.log_execution_progress', progress=execution.progress)
1185
1260
 
1186
1261
  else:
1187
- abort_reason = i18n.t('command.log_execution_abort_reason', abort_reason=execution.abort_reason) if hasattr(execution,
1188
- Model._FIELD_ABORT_REASON) else ''
1189
- exit_type = i18n.t('command.log_execution_exit_type', exit_type=execution.exit_type) if hasattr(execution, Model._FIELD_EXIT_TYPE) else ''
1190
1262
  status = i18n.t('command.log_execution_failed', exit_type=exit_type, abort_reason=abort_reason)
1191
1263
 
1192
1264
  started_at = execution.started_at.strftime('%Y-%m-%d %H:%M:%S')
@@ -1475,78 +1547,97 @@ class Command(Base):
1475
1547
  Returns:
1476
1548
  The found data items in the same order as the input files.
1477
1549
  """
1478
- data_items = []
1479
- created_items = []
1480
-
1481
- self.__print(logging.INFO, i18n.t('command.upload_inputs', files=len(input_files)))
1482
- progress_bar = tqdm(total=len(input_files), ncols=100)
1550
+ data_items = [None] * len(input_files)
1551
+ find_items = []
1552
+ create_items = []
1553
+
1554
+ for i, input_file in enumerate(input_files if input_files is not None else []):
1555
+ # The input file can either be a string representing a name or a file, or a dictionary with explicit values for both.
1556
+ input_name = None
1557
+ filename = None
1558
+ extension = None
1559
+
1560
+ if isinstance(input_file, dict):
1561
+ input_name = input_file.get(Model._FIELD_NAME)
1562
+ filename = input_file.get(Model._FIELD_FILE)
1563
+ extension = os.path.splitext(filename.lower())[1] if filename is not None else None
1564
+ else:
1565
+ # An input name is not assumed to have an extension.
1566
+ extension = os.path.splitext(input_file.lower())[1]
1483
1567
 
1484
- try:
1485
- for i, input_file in enumerate(input_files if input_files is not None else []):
1486
- # The input file can either be a string representing a name or a file, or a dictionary with explicit values for both.
1487
- input_name = None
1488
- filename = None
1489
- extension = None
1490
-
1491
- if isinstance(input_file, dict):
1492
- input_name = input_file.get(Model._FIELD_NAME)
1493
- filename = input_file.get(Model._FIELD_FILE)
1494
- extension = os.path.splitext(filename.lower())[1]
1568
+ if len(extension) > 0:
1569
+ filename = input_file
1495
1570
  else:
1496
- # An input name is not assumed to have an extension.
1497
- extension = os.path.splitext(input_file.lower())[1]
1498
-
1499
- if len(extension) > 0:
1500
- filename = input_file
1501
- else:
1502
- input_name = input_file
1503
- extension = None
1504
-
1505
- if extension is not None:
1506
- # Create a data item for each input.
1507
- if (extension == '.tif') or (extension == '.tiff'):
1508
- file_type = fusion_platform.FILE_TYPE_GEOTIFF
1509
- elif extension == '.jp2':
1510
- file_type = fusion_platform.FILE_TYPE_JPEG2000
1511
- elif extension == '.dem':
1512
- file_type = fusion_platform.FILE_TYPE_DEM
1513
- elif (extension == '.json') or (extension == '.geojson'):
1514
- file_type = fusion_platform.FILE_TYPE_GEOJSON
1515
- elif extension == '.kml':
1516
- file_type = fusion_platform.FILE_TYPE_KML
1517
- elif extension == '.kmz':
1518
- file_type = fusion_platform.FILE_TYPE_KMZ
1519
- elif extension == '.csv':
1520
- file_type = fusion_platform.FILE_TYPE_CSV
1521
- elif extension == '.zip':
1522
- file_type = fusion_platform.FILE_TYPE_ESRI_SHAPEFILE
1523
- elif (extension == '.jpeg') or (extension == '.jpg'):
1524
- file_type = fusion_platform.FILE_TYPE_JPEG
1525
- elif extension == '.png':
1526
- file_type = fusion_platform.FILE_TYPE_PNG
1527
- else:
1528
- file_type = fusion_platform.FILE_TYPE_OTHER
1529
-
1530
- input_name = f"{process_name} {i + 1}" if input_name is None else input_name
1531
- created = organisation.create_data(name=input_name, file_type=file_type, files=[filename], wait=False)
1532
- data_items.append(created)
1533
- created_items.append(created)
1571
+ input_name = input_file
1572
+ extension = None
1573
+
1574
+ if extension is not None:
1575
+ # Create a data item for each input.
1576
+ if (extension == '.tif') or (extension == '.tiff'):
1577
+ file_type = fusion_platform.FILE_TYPE_GEOTIFF
1578
+ elif extension == '.jp2':
1579
+ file_type = fusion_platform.FILE_TYPE_JPEG2000
1580
+ elif extension == '.dem':
1581
+ file_type = fusion_platform.FILE_TYPE_DEM
1582
+ elif (extension == '.json') or (extension == '.geojson'):
1583
+ file_type = fusion_platform.FILE_TYPE_GEOJSON
1584
+ elif extension == '.kml':
1585
+ file_type = fusion_platform.FILE_TYPE_KML
1586
+ elif extension == '.kmz':
1587
+ file_type = fusion_platform.FILE_TYPE_KMZ
1588
+ elif extension == '.csv':
1589
+ file_type = fusion_platform.FILE_TYPE_CSV
1590
+ elif extension == '.zip':
1591
+ file_type = fusion_platform.FILE_TYPE_ESRI_SHAPEFILE
1592
+ elif (extension == '.jpeg') or (extension == '.jpg'):
1593
+ file_type = fusion_platform.FILE_TYPE_JPEG
1594
+ elif extension == '.png':
1595
+ file_type = fusion_platform.FILE_TYPE_PNG
1534
1596
  else:
1597
+ file_type = fusion_platform.FILE_TYPE_OTHER
1598
+
1599
+ input_name = f"{process_name} {i + 1}" if input_name is None else input_name
1600
+ create_items.append((i, input_name, file_type, filename))
1601
+ else:
1602
+ find_items.append((i, input_name))
1603
+
1604
+ # Find all required items with a progress bar.
1605
+ if len(find_items) > 0:
1606
+ self.__print(logging.INFO, i18n.t('command.find_inputs', inputs=len(find_items)))
1607
+ progress_bar = tqdm(total=len(find_items), bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE,
1608
+ unit='input') # Unit matches keyword argument.
1609
+ progress_bar.set_postfix(input=1)
1610
+ k = 1
1611
+
1612
+ try:
1613
+ for i, input_name in find_items:
1535
1614
  data_item, _ = organisation.find_data(name=input_name)
1536
1615
 
1537
1616
  if data_item is None:
1538
1617
  raise CommandError(i18n.t('command.no_such_input', input_name=input_name))
1539
1618
 
1540
- data_items.append(data_item)
1619
+ data_items[i] = data_item
1620
+
1621
+ k += 1
1622
+ progress_bar.set_postfix(input=k)
1541
1623
  progress_bar.update(1)
1624
+ finally:
1625
+ progress_bar.set_postfix()
1626
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
1627
+ progress_bar.close()
1542
1628
 
1543
- # Wait for the analysis of any created items to complete.
1544
- for created in created_items:
1545
- created.create_complete(wait=True)
1546
- progress_bar.update(1)
1629
+ # Create all required items. The upload has a progress bar.
1630
+ if len(create_items) > 0:
1631
+ self.__print(logging.INFO, i18n.t('command.upload_inputs', inputs=len(create_items)))
1632
+ created_items = []
1547
1633
 
1548
- finally:
1549
- progress_bar.close()
1634
+ for i, input_name, file_type, filename in create_items:
1635
+ created = organisation.create_data(name=input_name, file_type=file_type, files=[filename], wait=False)
1636
+ created_items.append((created, False, 0, os.path.getsize(filename)))
1637
+ data_items[i] = created
1638
+
1639
+ # Wait for the analysis of any created items to complete.
1640
+ self.__wait_for_uploads(created_items)
1550
1641
 
1551
1642
  return data_items
1552
1643
 
@@ -1559,42 +1650,35 @@ class Command(Base):
1559
1650
  storage_files: The list of storage files to be uploaded.
1560
1651
  process: The newly created process.
1561
1652
  """
1562
- created_items = []
1563
-
1564
1653
  self.__print(logging.INFO, i18n.t('command.upload_storage', files=len(storage_files)))
1565
- progress_bar = tqdm(total=len(storage_files), ncols=100)
1654
+ storage_files = {} if storage_files is None else storage_files
1655
+ created_items = []
1566
1656
 
1567
- try:
1568
- for storage_key, storage_file in (storage_files if storage_files is not None else {}).items():
1569
- # The key represents the values used to generate the new storage id, without the process id, which will be different.
1570
- elements = storage_key.split('_')
1571
- group_index = int(elements[0])
1572
- group_count = int(elements[1])
1573
- ssd_id = elements[2]
1574
- chain_index = int(elements[3])
1575
- storage_name = i18n.t('command.storage_name', process=process.name, group_index=group_index, chain_index=chain_index)
1657
+ for storage_key, storage_file in storage_files.items():
1658
+ # The key represents the values used to generate the new storage id, without the process id, which will be different.
1659
+ elements = storage_key.split('_')
1660
+ group_index = int(elements[0])
1661
+ group_count = int(elements[1])
1662
+ ssd_id = elements[2]
1663
+ chain_index = int(elements[3])
1664
+ storage_name = i18n.t('command.storage_name', process=process.name, group_index=group_index, chain_index=chain_index)
1576
1665
 
1577
- group_index = None if group_count == 1 else group_index # If not in a group, then this needs to be None.
1578
- group_count = None if group_count == 1 else group_count # If not in a group, then this needs to be None.
1579
- storage_id = self.__get_storage_id(process.id, group_index, group_count, ssd_id, chain_index)
1666
+ group_index = None if group_count == 1 else group_index # If not in a group, then this needs to be None.
1667
+ group_count = None if group_count == 1 else group_count # If not in a group, then this needs to be None.
1668
+ storage_id = self.__get_storage_id(process.id, group_index, group_count, ssd_id, chain_index)
1580
1669
 
1581
- # The destination filename must be correct for everything to work, so we must make a copy with the right name.
1582
- with tempfile.TemporaryDirectory() as tmp_dir:
1583
- upload_file = os.path.join(tmp_dir, Command._STORAGE_NAME)
1584
- shutil.copy(storage_file, upload_file)
1670
+ # The destination filename must be correct for everything to work, so we must make a copy with the right name.
1671
+ with tempfile.TemporaryDirectory() as tmp_dir:
1672
+ upload_file = os.path.join(tmp_dir, Command._STORAGE_NAME)
1673
+ shutil.copy(storage_file, upload_file)
1585
1674
 
1586
- created = organisation.create_data(name=storage_name, file_type=fusion_platform.FILE_TYPE_GZIP, files=[upload_file], wait=False, id=storage_id)
1587
- created_items.append(created)
1675
+ created = organisation.create_data(name=storage_name, file_type=fusion_platform.FILE_TYPE_GZIP, files=[upload_file], wait=False, id=storage_id)
1676
+ created_items.append((created, False, 0, os.path.getsize(upload_file)))
1588
1677
 
1589
- os.remove(upload_file)
1678
+ os.remove(upload_file)
1590
1679
 
1591
- # Wait for the analysis of any created items to complete.
1592
- for created in created_items:
1593
- created.create_complete(wait=True)
1594
- progress_bar.update(1)
1595
-
1596
- finally:
1597
- progress_bar.close()
1680
+ # Wait for the analysis of any created items to complete.
1681
+ self.__wait_for_uploads(created_items)
1598
1682
 
1599
1683
  def __validate_constrained(self, values, text):
1600
1684
  """
@@ -1609,6 +1693,60 @@ class Command(Base):
1609
1693
  """
1610
1694
  return text.lower() in [value.lower() for value in values]
1611
1695
 
1696
+ def __wait_for_uploads(self, upload_data):
1697
+ """
1698
+ Waits for all the specified uploads to complete, showing a progress bar of bytes uploaded.
1699
+
1700
+ Args:
1701
+ upload_data: A list of tuples of the data items being uploaded with the data model, if the upload has completed, the upload size and total size.
1702
+ """
1703
+
1704
+ # Initialise the progress bar to show bytes uploaded.
1705
+ total_size = sum([size for _, _, _, size in upload_data if size is not None])
1706
+ progress_bar = tqdm(total=total_size, bar_format=Command.__PROGRESS_BAR_FORMAT, colour=Command.__PROGRESS_BAR_COLOUR_ACTIVE, unit='B', unit_scale=True,
1707
+ unit_divisor=1024) # Show progress in bytes.
1708
+
1709
+ try:
1710
+ # Check the status of all uploads so that we know when we have finished, updating the progress bar as we go.
1711
+ finished = False
1712
+ last_total_current_size = 0
1713
+
1714
+ while not finished:
1715
+ # Wait a bit before checking progress again.
1716
+ sleep(0.25)
1717
+
1718
+ # Now work out haw far we've got.
1719
+ total_current_size = 0
1720
+ count_complete = 0
1721
+
1722
+ for i in range(len(upload_data)):
1723
+ data, complete, current_size, upload_size = upload_data[i]
1724
+
1725
+ if not complete:
1726
+ # Check every file being uploaded for the data model.
1727
+ upload_progress = data.upload_progress()
1728
+ current_size = sum([size for _, size in upload_progress])
1729
+
1730
+ # Now check for completion.
1731
+ complete = data.create_complete()
1732
+ upload_data[i] = (data, complete, current_size, upload_size)
1733
+
1734
+ count_complete += 1 if complete else 0
1735
+ total_current_size += current_size
1736
+
1737
+ # Increment the progress bar.
1738
+ update_current_size = total_current_size - last_total_current_size
1739
+ last_total_current_size = total_current_size
1740
+ progress_bar.update(update_current_size)
1741
+
1742
+ # Check if we have finished.
1743
+ finished = count_complete == len(upload_data)
1744
+
1745
+ finally:
1746
+ # Make sure the progress bar is closed.
1747
+ progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
1748
+ progress_bar.close()
1749
+
1612
1750
 
1613
1751
  def main():
1614
1752
  """
@@ -100,6 +100,7 @@ class Data(Model):
100
100
  super(Data, self).__init__(session)
101
101
 
102
102
  # Initialise the fields.
103
+ self.__upload_progress = {}
103
104
  self.__upload_threads = {}
104
105
 
105
106
  def __add_file(self, file_type, file):
@@ -138,10 +139,11 @@ class Data(Model):
138
139
  raise ModelError(i18n.t('models.data.failed_add_file_not_unique'))
139
140
 
140
141
  # Create and start a thread for the upload.
142
+ self.__upload_progress[file] = (file, 0) # Indexed by file path, not file id.
141
143
  thread = None
142
144
 
143
145
  try:
144
- thread = RaiseThread(target=self._session.upload_file, args=(url, file))
146
+ thread = RaiseThread(target=self._session.upload_file, args=(url, file, self.__upload_callback))
145
147
  thread.start()
146
148
 
147
149
  finally:
@@ -393,6 +395,12 @@ class Data(Model):
393
395
  interval_end = None
394
396
 
395
397
  for item, item_file_name in items:
398
+ # So that we can extract things correctly, turn any mapping proxy into a dict (and relevant sub-mapping proxies).
399
+ item = dict(item)
400
+
401
+ if item.get('properties') is not None:
402
+ item['properties'] = dict(item.get('properties'))
403
+
396
404
  # Calculate the spatial extent.
397
405
  item_bbox = item.get('bbox')
398
406
 
@@ -422,3 +430,30 @@ class Data(Model):
422
430
  'extent': {'spatial': {'bbox': [bbox]}, 'temporal': {'interval': [[interval_start, interval_end]]}},
423
431
  'links': links
424
432
  }, collection_file_name
433
+
434
+ def __upload_callback(self, url, source, size):
435
+ """
436
+ Callback method used to receive progress from upload. Updates the upload progress.
437
+
438
+ Args:
439
+ url: The URL to upload as a file.
440
+ source: The source file path.
441
+ size: The total size in bytes so far uploaded.
442
+ """
443
+ self.__upload_progress[source] = (source, size)
444
+
445
+ def upload_progress(self):
446
+ """
447
+ Returns current upload progress.
448
+
449
+ Returns:
450
+ A list of tuples of the source and total number of bytes uploaded so far.
451
+
452
+ Raises:
453
+ ModelError: if no upload is in progress.
454
+ """
455
+ # Make sure at least one upload is in progress.
456
+ if (self.__upload_threads is None) or (len(self.__upload_threads) == 0):
457
+ raise ModelError(i18n.t('models.data.no_upload'))
458
+
459
+ return list(self.__upload_progress.values())
@@ -39,3 +39,5 @@
39
39
  * **geojson**: The content of the associated file if it is a GeoJSON file (of limited size).
40
40
  * **title**: The title for the selector.
41
41
  * **description**: The description of the selector.
42
+ * **stac_item**: The STAC item associated with this file.
43
+ * **stac_item_file**: The name of the STAC item file.
@@ -93,9 +93,14 @@ class DataFileSchema(Schema):
93
93
  downloads = fields.Integer(allow_none=True, metadata={'read_only': True}) # Changed to prevent this being updated.
94
94
  geojson = fields.String(allow_none=True, metadata={'read_only': True}) # Changed to prevent this being updated.
95
95
 
96
+ # Removed detail.
97
+
96
98
  title = fields.String(allow_none=True, metadata={'read_only': True}) # Changed to prevent this being updated.
97
99
  description = fields.String(allow_none=True, metadata={'read_only': True}) # Changed to prevent this being updated.
98
100
 
101
+ stac_item = fields.Dict(allow_none=True) # Added pseudo-parameter.
102
+ stac_item_file = fields.String(allow_none=True) # Added pseudo-parameter.
103
+
99
104
  class Meta:
100
105
  """
101
106
  When loading an object, make sure we exclude any unknown fields, rather than raising an exception, and put fields in their definition order.
@@ -293,135 +298,8 @@ class DataFile(Model):
293
298
  A tuple of the STAC item definition as a dictionary and the item file name used in the definition.
294
299
  """
295
300
 
296
- # Obtain all the relevant information.
297
- bounds = self.bounds if hasattr(self, Model._FIELD_BOUNDS) else None
298
- crs = self.crs if hasattr(self, Model._FIELD_CRS) else None
299
- file_name = self.file_name if hasattr(self, Model._FIELD_FILE_NAME) else None
300
- size = self.size if hasattr(self, Model._FIELD_SIZE) else None
301
-
302
- # Attempt to find the file type to get its extension and geospatial type. Default to "Other" if not found.
303
- found_file_type = DataFile._FILE_TYPES.get(self.file_type)
304
-
305
- if found_file_type is None:
306
- found_file_type = DataFile._FILE_TYPES.get(fusion_platform.FILE_TYPE_OTHER)
307
-
308
- geospatial = found_file_type[1]
309
-
310
- # Get the file extension.
311
- extension = os.path.splitext(file_name)[1] if file_name is not None else None
312
- extension = found_file_type[0] if extension is None else extension
313
-
314
- if (extension is None) or (extension == '*'):
315
- extension = ''
316
- else:
317
- extension = f".{extension}" if not extension.startswith('.') else extension
301
+ # Extract the STAC item from the model.
302
+ stac_item = self.stac_item if hasattr(self, Model._FIELD_STAC_ITEM) else None
303
+ stac_item_file = self.stac_item_file if hasattr(self, Model._FIELD_STAC_ITEM_FILE) else None
318
304
 
319
- # Work out a suitable name for the file if one is not available.
320
- if file_name is None:
321
- file_name = f"{self.id}{extension}"
322
-
323
- item_file_name = item_file_name_format.format(file_name=file_name)
324
-
325
- # Start to build the STAC extensions list.
326
- stac_extensions = ['https://stac-extensions.github.io/processing/v1.2.0/schema.json'] # For properties#processing.[software,version].
327
-
328
- # Get the bounding box and geometry, if there is one.
329
- bbox = []
330
- geometry = {}
331
-
332
- if (bounds is not None) and (len(bounds) >= 4):
333
- bbox = bounds
334
- geometry = {'type': 'Polygon', 'coordinates': [[[bbox[0], bbox[1]], [bbox[2], bbox[1]], [bbox[2], bbox[3]], [bbox[0], bbox[3]], [bbox[0], bbox[1]]]]}
335
-
336
- # Get the datetime from the filename, if possible. We try various iterations of sections the file name until we find something.
337
- for file_part in file_name.split('_'):
338
- extracted_datetime = datetime_parse(file_part) or datetime_parse(f"{file_part}T00:00:00Z") # Assume UTC.
339
-
340
- if extracted_datetime is not None:
341
- break
342
-
343
- if extracted_datetime is None:
344
- extracted_datetime = self.created_at
345
-
346
- # Start to build the properties.
347
- properties = {'processing.software': i18n.t('fusion_platform.sdk'), 'processing.version': fusion_platform.__version__, 'datetime': extracted_datetime}
348
-
349
- # Add in the CRS, if there is one.
350
- if crs is not None:
351
- properties['proj:epsg'] = crs
352
- stac_extensions.append('https://stac-extensions.github.io/projection/v1.1.0/schema.json') # For properties#proj:epsg.
353
-
354
- # Build the links.
355
- links = [{'rel': 'self', 'href': item_file_name, 'type': 'application/json'}]
356
-
357
- # Add in the optional owning collection.
358
- if collection_file_name is not None:
359
- links.append({'rel': 'root', 'href': collection_file_name, 'type': 'application/json'})
360
- links.append({'rel': 'parent', 'href': collection_file_name, 'type': 'application/json'})
361
-
362
- # Start to build the asset from the associated file.
363
- asset = {'href': file_name, 'roles': []}
364
-
365
- # Extract out the relevant values depending upon the file type.
366
- raster_bands = None
367
- eo_bands = None
368
-
369
- if (geospatial == DataFile._GEOSPATIAL_RASTER) and hasattr(self, Model._FIELD_SELECTORS) and (self.selectors is not None):
370
- for selector in self.selectors:
371
- raster_band = {}
372
- eo_band = {}
373
-
374
- if hasattr(self, Model._FIELD_RESOLUTION):
375
- raster_band['spatial_resolution'] = self.resolution
376
-
377
- if selector.get(Model._FIELD_UNIT) is not None:
378
- raster_band['unit'] = selector.get(Model._FIELD_UNIT)
379
-
380
- if (selector.get(Model._FIELD_MINIMUM) is not None) and (selector.get(Model._FIELD_MAXIMUM) is not None) and (
381
- selector.get(Model._FIELD_MEAN) is not None) and (selector.get(Model._FIELD_SD) is not None):
382
- raster_band['statistics'] = {
383
- 'minimum': selector.get(Model._FIELD_MINIMUM),
384
- 'maximum': selector.get(Model._FIELD_MAXIMUM),
385
- 'mean': selector.get(Model._FIELD_MEAN),
386
- 'stddev': selector.get(Model._FIELD_SD)
387
- }
388
-
389
- if (selector.get(Model._FIELD_HISTOGRAM_MINIMUM) is not None) and (selector.get(Model._FIELD_HISTOGRAM_MAXIMUM) is not None) and (
390
- selector.get(Model._FIELD_HISTOGRAM) is not None):
391
- raster_band['histogram'] = {
392
- 'count': len(selector.get(Model._FIELD_HISTOGRAM)),
393
- 'min': selector.get(Model._FIELD_HISTOGRAM_MINIMUM),
394
- 'max': selector.get(Model._FIELD_HISTOGRAM_MAXIMUM),
395
- 'buckets': selector.get(Model._FIELD_HISTOGRAM),
396
- }
397
-
398
- if selector.get(Model._FIELD_CATEGORY) is not None:
399
- eo_band['name'] = selector.get(Model._FIELD_CATEGORY)
400
-
401
- raster_bands = [raster_band] if raster_bands is None else [raster_band] + raster_bands
402
- eo_bands = [eo_band] if eo_bands is None else [eo_band] + eo_bands
403
-
404
- stac_extensions.append('https://stac-extensions.github.io/eo/v1.1.0/schema.json') # For assets#eo:bands.
405
- stac_extensions.append('https://stac-extensions.github.io/raster/v1.1.0/schema.json') # For assets#raster:bands.
406
-
407
- if raster_bands is not None:
408
- asset['raster:bands'] = raster_bands
409
- asset['eo:bands'] = eo_bands
410
-
411
- # Add in the file size, if there is one.
412
- if size is not None:
413
- asset['file:size'] = size
414
- stac_extensions.append('https://stac-extensions.github.io/file/v2.1.0/schema.json') # For assets#file:size.
415
-
416
- # Build the STAC item.
417
- return {
418
- 'type': 'Feature',
419
- 'stac_version': '1.0.0',
420
- 'stac_extensions': stac_extensions,
421
- 'id': self.id,
422
- 'bbox': bbox,
423
- 'geometry': geometry,
424
- 'properties': properties,
425
- 'links': links,
426
- 'assets': {'asset': asset}
427
- }, item_file_name
305
+ return stac_item, stac_item_file
@@ -96,6 +96,8 @@ class Model(Base):
96
96
  _FIELD_SELECTOR = 'selector'
97
97
  _FIELD_SELECTORS = 'selectors'
98
98
  _FIELD_SSD_ID = 'ssd_id'
99
+ _FIELD_STAC_ITEM = 'stac_item'
100
+ _FIELD_STAC_ITEM_FILE = 'stac_item_file'
99
101
  _FIELD_STARTED_AT = 'started_at'
100
102
  _FIELD_PUBLISHABLE = 'publishable'
101
103
  _FIELD_SIZE = 'size'
@@ -186,13 +186,18 @@ class ProcessExecution(Model):
186
186
  complete = self.progress >= 100
187
187
 
188
188
  # Raise an exception if the execution failed.
189
+ abort_reason = self.abort_reason if hasattr(self, Model._FIELD_ABORT_REASON) else None
190
+
189
191
  if complete and not self.success:
190
- self._logger.error(i18n.t('models.process_execution.execution_failed'))
191
- raise ModelError(i18n.t('models.process_execution.execution_failed'))
192
+ self._logger.error(i18n.t('models.process_execution.execution_failed', abort_reason=abort_reason))
193
+ raise ModelError(i18n.t('models.process_execution.execution_failed', abort_reason=abort_reason))
192
194
 
193
195
  if complete:
194
196
  self._logger.debug('execution %s is complete', self.id)
195
197
 
198
+ if abort_reason is not None:
199
+ self._logger.warning(i18n.t('models.process_execution.execution_warning', abort_reason=abort_reason))
200
+
196
201
  if (not wait) or complete:
197
202
  break
198
203
 
@@ -13,6 +13,7 @@ import logging
13
13
  import os
14
14
  import requests
15
15
  from tenacity import before_sleep_log, retry, retry_if_exception_type, stop_after_attempt, wait_random_exponential
16
+ from tqdm.utils import CallbackIOWrapper
16
17
 
17
18
  import fusion_platform
18
19
  from fusion_platform.base import Base
@@ -47,6 +48,36 @@ class ValueError(SessionError):
47
48
  pass
48
49
 
49
50
 
51
+ class UploadCallback:
52
+ """
53
+ Provides a callback mechanism for uploads using the CallbackIOWrapper.
54
+ """
55
+
56
+ def __init__(self, url, source, callback):
57
+ """
58
+ Initialises the object.
59
+
60
+ Args:
61
+ url: The URL being uploaded to.
62
+ source: The source file path.
63
+ callback: The callback method used to receive upload progress.
64
+ """
65
+ self.__url = url
66
+ self.__source = source
67
+ self.__callback = callback
68
+ self.__upload_size = 0
69
+
70
+ def callback(self, size):
71
+ """
72
+ Receives the upload callback every time data is read from the associated file.
73
+
74
+ Args:
75
+ size: The number of bytes which have been read from the associated file.
76
+ """
77
+ self.__upload_size += size
78
+ self.__callback(self.__url, self.__source, self.__upload_size)
79
+
80
+
50
81
  class Session(Base):
51
82
  """
52
83
  Provides a session for use in interfacing with the Fusion Platform<sup>&reg;</sup> API.
@@ -257,19 +288,22 @@ class Session(Base):
257
288
  # Return the payload.
258
289
  return payload
259
290
 
260
- def upload_file(self, url, source):
291
+ def upload_file(self, url, source, callback=None):
261
292
  """
262
293
  Uploads a file from the source path.
263
294
 
264
295
  Args:
265
296
  url: The URL to download as a file.
266
297
  source: The source file path.
298
+ callback: The optional callback method used to receive upload progress.
267
299
  """
268
300
  try:
269
301
  # Upload the file as a data stream.
270
302
  self._logger.info('uploading %s -> %s', url, source)
271
303
 
272
304
  with open(source, 'rb') as file:
305
+ # Wrap the file reader with a callback to give progress.
306
+ file = CallbackIOWrapper(UploadCallback(url, source, callback).callback, file, 'read') if callback is not None else file
273
307
  response = requests.put(url, data=file)
274
308
 
275
309
  # Raise any errors.
@@ -24,6 +24,7 @@ i18n.add_translation('command.log_execution_group', ' (group \'%{group_id}\' %{g
24
24
  i18n.add_translation('command.log_execution_duration', ' - %{minutes} minute(s)', 'en')
25
25
  i18n.add_translation('command.log_execution_period', ' (%{started_at}%{ended_at})', 'en')
26
26
  i18n.add_translation('command.log_execution_ended_at', ' -> %{ended_at}', 'en')
27
+ i18n.add_translation('command.log_execution_warning', 'warning%{exit_type}%{abort_reason}', 'en')
27
28
  i18n.add_translation('command.log_execution_failed', 'failed%{exit_type}%{abort_reason}', 'en')
28
29
  i18n.add_translation('command.log_execution_exit_type', ' (%{exit_type})', 'en')
29
30
  i18n.add_translation('command.log_execution_abort_reason', ': %{abort_reason}', 'en')
@@ -50,7 +51,7 @@ i18n.add_translation('command.using_storage', 'Uploading storage \'%{filename}\'
50
51
  i18n.add_translation('command.using_service', 'Using service \'%{service}\'', 'en')
51
52
  i18n.add_translation('command.using_organisation', 'Using organisation \'%{organisation}\'', 'en')
52
53
  i18n.add_translation('command.upload_storage', 'Uploading %{files} storage file(s)...', 'en')
53
- i18n.add_translation('command.upload_inputs', 'Uploading/finding %{files} input file(s)...', 'en')
54
+ i18n.add_translation('command.upload_inputs', 'Uploading %{inputs} input file(s)...', 'en')
54
55
  i18n.add_translation('command.storage_name', '%{process} Storage %{group_index} %{chain_index}', 'en')
55
56
  i18n.add_translation('command.start_process', 'Starting \'%{process}\' from service \'%{service}\'', 'en')
56
57
  i18n.add_translation('command.remove_process', 'Removing input \'%{process}\'', 'en')
@@ -59,6 +60,7 @@ i18n.add_translation('command.prompt_yes', 'Yes', 'en')
59
60
  i18n.add_translation('command.prompt_no', 'No', 'en')
60
61
  i18n.add_translation('command.password', 'Password', 'en')
61
62
  i18n.add_translation('command.organisation', 'Organisation', 'en')
63
+ i18n.add_translation('command.find_inputs', 'Finding %{inputs} input file(s)...', 'en')
62
64
  i18n.add_translation('command.executing', 'Executing...', 'en')
63
65
  i18n.add_translation('command.email', 'Email', 'en')
64
66
  i18n.add_translation('command.download_process_execution', 'Downloading \'%{process}\' (inputs %{inputs}, outputs %{outputs}, storage %{storage}, intermediate %{intermediate}, metrics %{metrics}, components %{components})', 'en')
@@ -237,13 +239,15 @@ i18n.add_translation('models.data_file.no_download', 'No download is in progress
237
239
  i18n.add_translation('models.data_file.download_already_in_progress', 'Cannot download file as the download is already in progress', 'en')
238
240
  i18n.add_translation('models.data_file.organisation_id.description', 'The owning organisation.', 'en')
239
241
  i18n.add_translation('models.data_file.organisation_id.title', 'Organisation', 'en')
242
+ i18n.add_translation('models.data.no_upload', 'No upload is in progress', 'en')
240
243
  i18n.add_translation('models.data.no_create', 'No create is in progress', 'en')
241
244
  i18n.add_translation('models.data.failed_add_missing_file', 'Failed to add file as the file does not exist: %{file}', 'en')
242
245
  i18n.add_translation('models.data.failed_add_file_not_unique', 'Failed to add file as the id is not unique', 'en')
243
246
  i18n.add_translation('models.data.failed_add_file_url', 'Failed to get URL from add file response', 'en')
244
247
  i18n.add_translation('models.data.failed_add_file_id', 'Failed to get id from add file response', 'en')
245
248
  i18n.add_translation('models.data.failed_copy_id', 'Failed to get data id from copy response', 'en')
246
- i18n.add_translation('models.process_execution.execution_failed', 'Execution has failed', 'en')
249
+ i18n.add_translation('models.process_execution.execution_warning', 'Execution has completed successfully but with a warning: %{abort_reason}', 'en')
250
+ i18n.add_translation('models.process_execution.execution_failed', 'Execution has failed: %{abort_reason}', 'en')
247
251
  i18n.add_translation('models.fields.uuid.invalid_uuid', 'Not a valid utf-8 string', 'en')
248
252
  i18n.add_translation('models.fields.url.invalid_url', 'Not a valid URL', 'en')
249
253
  i18n.add_translation('models.fields.tuple.invalid', 'Not a valid tuple', 'en')
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: fusion-platform-python-sdk
3
- Version: 1.15.12
3
+ Version: 1.16.6
4
4
  Summary: Python SDK used to interact with the Fusion Platform(r)
5
5
  Home-page: https://github.com/d-cat-support/fusion-platform-python-sdk
6
6
  Author: Digital Content Analysis Technology Ltd
@@ -24,6 +24,15 @@ Requires-Dist: python-i18n[YAML]
24
24
  Requires-Dist: requests
25
25
  Requires-Dist: tenacity
26
26
  Requires-Dist: tqdm
27
+ Dynamic: author
28
+ Dynamic: author-email
29
+ Dynamic: classifier
30
+ Dynamic: description
31
+ Dynamic: description-content-type
32
+ Dynamic: home-page
33
+ Dynamic: license
34
+ Dynamic: requires-dist
35
+ Dynamic: summary
27
36
 
28
37
  # Fusion Platform<sup>&reg;</sup> Python SDK
29
38
 
@@ -19,7 +19,7 @@ PACKAGE_DIR = pathlib.Path(__file__).parent
19
19
  # @formatter:off
20
20
  setup(
21
21
  name='fusion-platform-python-sdk',
22
- version='1.15.12',
22
+ version='1.16.6',
23
23
  description='Python SDK used to interact with the Fusion Platform(r)',
24
24
  long_description=(PACKAGE_DIR / 'README.md').read_text(),
25
25
  long_description_content_type='text/markdown',
@@ -302,3 +302,9 @@ class TestSession(CustomTestCase):
302
302
  adapter = mock.put(url, status_code=200)
303
303
  session.upload_file(url, source)
304
304
  self.assertIsNotNone(adapter.last_request.text)
305
+
306
+ def callback(url, source, upload_size):
307
+ pass
308
+
309
+ session.upload_file(url, source, callback=callback) # Because the request is mocked, the callback will not be called because there are no reads.
310
+ self.assertIsNotNone(adapter.last_request.text)