fusion-platform-python-sdk 2.2.3__py3-none-any.whl → 2.3.8__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.
@@ -12,24 +12,25 @@ from collections import defaultdict
12
12
  from copy import deepcopy
13
13
  import csv
14
14
  from datetime import datetime, timedelta, timezone
15
+ from dateutil.relativedelta import relativedelta
15
16
  from decimal import Decimal
16
17
  from functools import partial
18
+ import i18n
17
19
  import json
20
+ import keyring
18
21
  import logging
22
+ import math
19
23
  import os
20
- import re
21
- import shutil
22
- import tempfile
23
- from time import sleep
24
- import uuid
25
-
26
- from dateutil.relativedelta import relativedelta
27
- import i18n
28
24
  from pathos.multiprocessing import ThreadPool as Pool
29
25
  from prompt_toolkit import print_formatted_text as print, prompt
30
26
  from prompt_toolkit.completion import WordCompleter
31
27
  from prompt_toolkit.validation import Validator
28
+ import re
29
+ import shutil
30
+ import tempfile
32
31
  from tqdm import tqdm
32
+ from time import sleep
33
+ import uuid
33
34
  import yaml
34
35
 
35
36
  # Make sure localisation is set up before any SDK-specific imports.
@@ -39,8 +40,9 @@ Localise.setup()
39
40
 
40
41
  import fusion_platform
41
42
  from fusion_platform.base import Base
42
- from fusion_platform.common.utilities import json_default, string_blank
43
+ from fusion_platform.common.utilities import dict_nested_get, json_default, string_blank
43
44
  from fusion_platform.models.data import Data
45
+ from fusion_platform.models.data_file import DataFile
44
46
  from fusion_platform.models.model import Model
45
47
  from fusion_platform.session import RequestError
46
48
 
@@ -84,10 +86,38 @@ class Command(Base):
84
86
  }
85
87
  }
86
88
 
89
+ _KEYRING_DEPLOYMENT_FORMAT = f"{fusion_platform.FUSION_PLATFORM}.{{deployment}}"
90
+
87
91
  # Useful download constants.
88
92
  _DOWNLOAD_INPUT = 'input'
89
93
  _DOWNLOAD_OUTPUT = 'output'
90
94
  _DOWNLOAD_STORAGE = 'storage'
95
+ _DOWNLOAD_STATISTICS = 'statistics'
96
+ _DOWNLOAD_UPLOAD = 'upload'
97
+
98
+ # Process execution exit types.
99
+ _EXIT_SUCCESS = 'success'
100
+ _EXIT_FATAL_ERROR = 'fatal error'
101
+ _EXIT_FATAL_WARNING = 'fatal warning'
102
+ _EXIT_NON_FATAL_WARNING = 'non-fatal warning'
103
+
104
+ # File types to extensions.
105
+ _FILE_TYPES_TO_EXTENSIONS = {
106
+ fusion_platform.FILE_TYPE_GEOTIFF: ['.tif', '.tiff'],
107
+ fusion_platform.FILE_TYPE_JPEG2000: ['.jp2'],
108
+ fusion_platform.FILE_TYPE_DEM: ['.dem'],
109
+ fusion_platform.FILE_TYPE_GEOJSON: ['.json', '.geojson'],
110
+ fusion_platform.FILE_TYPE_KML: ['.kml'],
111
+ fusion_platform.FILE_TYPE_KMZ: ['.kmz'],
112
+ fusion_platform.FILE_TYPE_CSV: ['.csv'],
113
+ fusion_platform.FILE_TYPE_ESRI_SHAPEFILE: ['.zip'],
114
+ fusion_platform.FILE_TYPE_JPEG: ['.jpeg', '.jpg'],
115
+ fusion_platform.FILE_TYPE_PNG: ['.png'],
116
+ fusion_platform.FILE_TYPE_PDF: ['.pdf'],
117
+ fusion_platform.FILE_TYPE_GZIP: ['.gz'],
118
+ fusion_platform.FILE_TYPE_ZIP: ['.zip']
119
+ # Not "Other" as it has no associated extensions.
120
+ }
91
121
 
92
122
  # Metrics used during download.
93
123
  _METRICS_FILE = 'metrics.csv'
@@ -130,8 +160,17 @@ class Command(Base):
130
160
 
131
161
  # Progress bar constants.
132
162
  __PROGRESS_BAR_FORMAT = '{l_bar}{bar:50}{r_bar}'
163
+ __PROGRESS_BAR_FORMAT_PROCESS = '{{percentage:3.0f}}%|{{bar:50}}| {current:>3}/{total:>3} (S {successes:>3}|W {warnings:>3}|E {errors:>3}|X {stopped:>3}), {process_status:>7}: {description}'
133
164
  __PROGRESS_BAR_COLOUR_ACTIVE = '#3379B6'
134
165
  __PROGRESS_BAR_COLOUR_COMPLETE = '#5BB85C'
166
+ __PROGRESS_BAR_COLOUR_WARNING = '#F09E22'
167
+ __PROGRESS_BAR_COLOUR_ERROR = '#D13B32'
168
+
169
+ # Error messages.
170
+ __DATA_ITEM_STILL_IN_USE = 'still in use'
171
+
172
+ # File constants.
173
+ __FILE_SIZE_UNIT_LIST = list(zip(['B ', 'KB', 'MB', 'GB', 'TB', 'PB'], [0, 0, 1, 2, 2, 2]))
135
174
 
136
175
  def __init__(self):
137
176
  """
@@ -152,113 +191,29 @@ class Command(Base):
152
191
  # Set the default print level.
153
192
  self.__print_level = logging.INFO
154
193
 
155
- def __create_process(self, organisation, process_name, options, dispatchers, service, data_items):
194
+ def __bytes_to_human(self, size):
156
195
  """
157
- Creates a new process based upon the service, options, dispatchers and input data items.
196
+ Converts a file size into a human-readable string. Based on https://gist.github.com/ElectroDrome/365347f9d71154a517d71b3655a768f7.
158
197
 
159
198
  Args:
160
- organisation: The logged in organisation.
161
- process_name: The name of the process.
162
- options: The list of options.
163
- dispatchers: The list of dispatchers.
164
- service: The base service to create the process from.
165
- data_items: The list of data items to be used as inputs.
199
+ size: The size in bytes.
166
200
 
167
201
  Returns:
168
- The process model.
202
+ The human-readable version of the size.
169
203
  """
204
+ human = '0 B '
170
205
 
171
- # Create a template process from the service.
172
- process = organisation.new_process(name=process_name, service=service)
206
+ if size == 1:
207
+ human = '1 B '
173
208
 
174
- # Configure the process to use the inputs and set its options. Note that we filter out model level fields and set those as keyword arguments.
175
- for i, data_item in enumerate(data_items):
176
- process.update(input_number=i + 1, data=data_item)
209
+ elif size > 1:
210
+ exponent = min(int(math.log(size, 1024)), len(Command.__FILE_SIZE_UNIT_LIST) - 1)
211
+ quotient = float(size) / (1024 ** exponent)
212
+ unit, number_of_decimal_places = Command.__FILE_SIZE_UNIT_LIST[exponent]
213
+ format_string = '{:.%sf} {}' % number_of_decimal_places
214
+ human = format_string.format(quotient, unit)
177
215
 
178
- kwargs = {}
179
-
180
- for key, value in options.items():
181
- if key in Command._MODEL_FIELDS:
182
- kwargs[key] = value
183
- else:
184
- process.update(option_name=key, value=value, coerce_value=True)
185
-
186
- if len(kwargs) > 0:
187
- process.update(**kwargs)
188
-
189
- # Add in optional dispatchers.
190
- dispatchers_added = 0
191
-
192
- for i, dispatcher in enumerate(process.available_dispatchers):
193
- finished = False
194
-
195
- while not finished:
196
- # Either find the dispatchers from what has been loaded from the YAML file or prompt the user.
197
- add = False
198
- dispatch_intermediate = False
199
- dispatcher_options = {}
200
-
201
- if dispatchers is not None:
202
- # See if any of the remaining dispatchers match this one. Once used, the corresponding dispatcher is marked as used.
203
- filtered_dispatchers = [item for item in dispatchers if
204
- (item.get(Model._FIELD_NAME) == dispatcher.name) and (not item.get(Model._FIELD_USED, False))]
205
-
206
- if len(filtered_dispatchers) > 0:
207
- selected_dispatcher = filtered_dispatchers[0]
208
- selected_dispatcher[Model._FIELD_USED] = True
209
- add = True
210
- dispatch_intermediate = selected_dispatcher.get(Model._FIELD_DISPATCH_INTERMEDIATE) if selected_dispatcher.get(
211
- Model._FIELD_DISPATCH_INTERMEDIATE) is not None else False
212
- dispatcher_options = selected_dispatcher.get(Model._FIELD_OPTIONS) if selected_dispatcher.get(Model._FIELD_OPTIONS) is not None else {}
213
-
214
- else:
215
- response = self.__get_input(i18n.t('command.add_dispatcher', dispatcher=dispatcher.name),
216
- constrained_values=[i18n.t('command.prompt_no'), i18n.t('command.prompt_yes')])
217
-
218
- if (response is not None) and response.lower().startswith(i18n.t('command.prompt_yes').lower()[0]):
219
- add = True
220
-
221
- if hasattr(dispatcher, Model._FIELD_DISPATCH_INTERMEDIATE):
222
- response = self.__get_input(i18n.t('command.dispatch_intermediate'),
223
- constrained_values=[i18n.t('command.prompt_no'), i18n.t('command.prompt_yes')])
224
-
225
- if (response is not None) and response.lower().startswith(i18n.t('command.prompt_yes').lower()[0]):
226
- dispatch_intermediate = True
227
-
228
- for option in dispatcher.options:
229
- finished_options = False
230
- option_value = None
231
-
232
- while not finished_options:
233
- constrained_values = option.constrained_values if option.data_type == fusion_platform.DATA_TYPE_CONSTRAINED else None
234
-
235
- prompt_constrained_values = i18n.t('models.process.option.representation_constrained_values',
236
- constrained_values=constrained_values) if constrained_values is not None else ''
237
- prompt_required = i18n.t('models.process.option.representation_required') if option.required else ''
238
- prompt = i18n.t('command.prompt_option', title=option.title, name=option.name, data_type=option.data_type,
239
- constrained_values=prompt_constrained_values, required=prompt_required)
240
-
241
- option_value = self.__get_input(prompt, default=option.value, constrained_values=constrained_values)
242
- finished_options = (not option.required) or (
243
- option.required and (option_value is not None) and (not string_blank(str(option_value))))
244
-
245
- if option_value is not None:
246
- dispatcher_options[option.name] = option_value
247
-
248
- if add:
249
- process.add_dispatcher(number=(i + 1))
250
- dispatchers_added += 1
251
-
252
- for option_name, option_value in dispatcher_options.items():
253
- process.update(dispatcher_number=dispatchers_added, option_name=option_name, value=option_value, coerce_value=True,
254
- dispatch_intermediate=dispatch_intermediate)
255
- else:
256
- finished = True
257
-
258
- # Create the process, which will validate its options and inputs.
259
- process.create()
260
-
261
- return process
216
+ return human
262
217
 
263
218
  def __data_name_to_file_name(self, data_name):
264
219
  """
@@ -287,10 +242,10 @@ class Command(Base):
287
242
  """
288
243
  self.__print(logging.INFO, i18n.t('command.define_process', process=process_name, inputs=download_inputs, storage=download_storage))
289
244
 
290
- # We are defining an existing process.
245
+ # We are defining an existing process. This will raise an exception if the process is not found.
291
246
  process, _ = self.get_process_or_execution(organisation, process_name)
292
247
 
293
- # Get the underlying service name.
248
+ # Get the underlying service, if available. We only optionally use this for the name.
294
249
  service, _ = organisation.find_services(ssd_id=process.ssd_id)
295
250
 
296
251
  # Get the options and dispatchers, optionally add in whether intermediate services are being dispatched.
@@ -410,6 +365,109 @@ class Command(Base):
410
365
 
411
366
  return process_definition
412
367
 
368
+ def __delete_with_confirmation(self, item, force, prompt_message, delete_message):
369
+ """
370
+ Deletes the item with an optional confirmation prompt. The item is deleted if force is True or the user responds with Yes to the prompt.
371
+
372
+ Args:
373
+ item: The model item to delete. This must have a #delete method.
374
+ force: Force deletion without prompting for confirmation?
375
+ prompt_message: The message to be displayed with the prompt.
376
+ delete_message: The message to be displayed with the deletion.
377
+ """
378
+ delete = force
379
+
380
+ if not delete:
381
+ confirm = self.__get_input(prompt_message, constrained_values=[i18n.t('command.prompt_no'), i18n.t('command.prompt_yes')])
382
+
383
+ if (confirm is not None) and confirm.lower().startswith(i18n.t('command.prompt_yes').lower()[0]):
384
+ delete = True
385
+
386
+ if delete:
387
+ self.__print(logging.INFO, delete_message)
388
+ item.delete()
389
+
390
+ def delete_process(self, organisation, process_name, force):
391
+ """
392
+ Deletes all processes matching the process name or id. An execution id may also be provided in which case just the execution is deleted.
393
+
394
+ Args:
395
+ organisation: The logged in organisation.
396
+ process_name: The name of the process to delete.
397
+ force: Force deletion without prompting for confirmation?
398
+
399
+ Returns:
400
+ True if a process or execution was deleted.
401
+ """
402
+ attempted_delete = True
403
+
404
+ # Find the process or execution. This will raise an exception if the process is not found, which we catch.
405
+ process = None
406
+ execution = None
407
+
408
+ try:
409
+ process, execution = self.get_process_or_execution(organisation, process_name)
410
+ except CommandError:
411
+ attempted_delete = False
412
+
413
+ # Delete the process or execution. If an execution id has been provided, we will have both an execution and process, hence the order below is important to
414
+ # make sure we do not delete the process as well if just the execution is to be deleted.
415
+ if (process is not None) and (execution is not None):
416
+ self.__delete_with_confirmation(
417
+ execution,
418
+ force,
419
+ i18n.t('command.delete_execution_confirmation', execution=process_name, process=process.name),
420
+ i18n.t('command.delete_execution', execution=process_name, process=process.name)
421
+ )
422
+ elif process is not None:
423
+ self.__delete_with_confirmation(
424
+ process,
425
+ force,
426
+ i18n.t('command.delete_process_confirmation', process=process.name),
427
+ i18n.t('command.delete_process', process=process.name)
428
+ )
429
+
430
+ return attempted_delete
431
+
432
+ def delete_upload(self, organisation, upload_name, force):
433
+ """
434
+ Deletes all uploads matching the upload name or id.
435
+
436
+ Args:
437
+ organisation: The logged in organisation.
438
+ upload_name: The name of the upload to delete.
439
+ force: Force deletion without prompting for confirmation?
440
+
441
+ Returns:
442
+ True if an upload was deleted.
443
+ """
444
+ attempted_delete = True
445
+
446
+ # Find the data item. This will raise an exception if the data item is not found, which we catch.
447
+ data_item = None
448
+
449
+ try:
450
+ data_item = self.get_data(organisation, upload_name)
451
+ except CommandError:
452
+ attempted_delete = False
453
+
454
+ # Delete the data item. We catch any in use error to display a more friendly message.
455
+ if data_item is not None:
456
+ try:
457
+ self.__delete_with_confirmation(
458
+ data_item,
459
+ force,
460
+ i18n.t('command.delete_upload_confirmation', upload=data_item.name),
461
+ i18n.t('command.delete_upload', upload=data_item.name)
462
+ )
463
+ except RequestError as e:
464
+ if Command.__DATA_ITEM_STILL_IN_USE in str(e):
465
+ pass # A more friendly error will have already been displayed.
466
+ else:
467
+ raise
468
+
469
+ return attempted_delete
470
+
413
471
  def display_service(self, organisation, service_name):
414
472
  """
415
473
  Displays the documentation for the service. If the service does not exist, it will be ignored.
@@ -480,8 +538,14 @@ class Command(Base):
480
538
  self.__print(logging.INFO, i18n.t('command.log_bookend'))
481
539
 
482
540
  else:
541
+ has_executions = False
542
+
483
543
  for execution in process.executions:
484
544
  self.__print_execution(execution)
545
+ has_executions = True
546
+
547
+ if not has_executions:
548
+ self.__print(logging.INFO, i18n.t('command.log_no_executions'))
485
549
 
486
550
  self.__print(logging.INFO, i18n.t('command.log_bookend'))
487
551
 
@@ -490,8 +554,8 @@ class Command(Base):
490
554
 
491
555
  return found
492
556
 
493
- def __download_execution(self, process, download_inputs, download_outputs, download_storage, download_intermediate, download_components, output_dir, wait,
494
- execution):
557
+ def __download_execution(self, process, download_inputs, download_outputs, download_storage, download_statistics, download_intermediate, download_components,
558
+ output_dir, wait, execution):
495
559
  """
496
560
  Downloads an execution.
497
561
 
@@ -500,6 +564,7 @@ class Command(Base):
500
564
  download_inputs: Should inputs be downloaded?
501
565
  download_outputs: Should outputs be downloaded?
502
566
  download_storage: Should storage be downloaded?
567
+ download_statistics: Should statistics be downloaded?
503
568
  download_intermediate: Should intermediate components be downloaded?
504
569
  download_components: The names of the components that should be downloaded? If None or [], then all components are downloaded.
505
570
  output_dir: The output directory.
@@ -510,8 +575,6 @@ class Command(Base):
510
575
  The list of file tuples to be downloaded (file model, download path), and the metrics.
511
576
  """
512
577
  download_components = [] if download_components is None else download_components
513
- metrics = []
514
- downloads = []
515
578
 
516
579
  # Optionally wait for the execution to complete. We explicitly wait in this method to keep the SDK free.
517
580
  complete = False
@@ -543,12 +606,15 @@ class Command(Base):
543
606
  components = [
544
607
  component
545
608
  for component in execution.components
546
- if ((not component.intermediate) or (component.intermediate and download_intermediate)) and (
547
- (len(download_components) <= 0) or
548
- ((len(download_components) >= 1) and (component.name in download_components))
549
- )
609
+ if ((not hasattr(component, Model._FIELD_INTERMEDIATE)) or (
610
+ (hasattr(component, Model._FIELD_INTERMEDIATE) and ((not component.intermediate) or (component.intermediate and download_intermediate))))) and (
611
+ (len(download_components) <= 0) or ((len(download_components) >= 1) and (component.name in download_components)))
550
612
  ]
551
613
 
614
+ # Prepare the downloads and metrics for each component.
615
+ metrics = []
616
+ downloads = []
617
+
552
618
  for j, component in enumerate(components):
553
619
  # Download the component.
554
620
  component_name = '_'.join([item for item in re.sub(r'[\W]', '_', component.name).split('_') if len(item) > 0])
@@ -585,8 +651,7 @@ class Command(Base):
585
651
  ]:
586
652
  metric[key] = sum([metric[key] for metric in component.metrics if metric.get(key) is not None])
587
653
 
588
- # Find each input, output and storage to download. While traversing each, save the input and output size. The size might not be immediately available
589
- # for every file, so wait for all items to be analysed.
654
+ # Find each input, output, storage and statistics to download.
590
655
  data_items = []
591
656
 
592
657
  if download_inputs:
@@ -604,44 +669,17 @@ class Command(Base):
604
669
  if storage_data_item is not None:
605
670
  data_items += [(1, Command._DOWNLOAD_STORAGE, storage_data_item)]
606
671
 
607
- input_sizes = []
608
- output_sizes = []
609
-
610
- for k, data_type, data_item in data_items:
611
- # Download the data item once it is complete.
612
- data_item.check_analysis_complete(wait=True)
613
-
614
- name = re.sub('_+', '_', f"{data_type.capitalize()}_{str(k)}_{self.__data_name_to_file_name(data_item.name)}").rstrip('_')
615
- download_dir = os.path.join(execution_dir, component_dir, name)
616
- os.makedirs(download_dir, exist_ok=True)
617
-
618
- # We also create the corresponding STAC definitions.
619
- stac_definitions = []
620
-
621
- for file in data_item.files:
622
- path = os.path.join(download_dir, file.file_name)
623
-
624
- # Download each file for the data item. We don't do anything with "storage".
625
- if data_type == Command._DOWNLOAD_INPUT:
626
- input_sizes.append(file.size)
627
- elif data_type == Command._DOWNLOAD_OUTPUT:
628
- output_sizes.append(file.size)
629
-
630
- downloads.append((file, path, file.size))
631
- stac_item, stac_item_file = file.get_stac_item()
672
+ if download_statistics:
673
+ statistics_data_item = self.__get_statistics_data_item(component)
632
674
 
633
- if (stac_item is not None) and (stac_item_file is not None):
634
- stac_definitions.append((stac_item, stac_item_file))
675
+ if statistics_data_item is not None:
676
+ data_items += [(1, Command._DOWNLOAD_STATISTICS, statistics_data_item)]
635
677
 
636
- if len(stac_definitions) > 0:
637
- stac_collection = data_item.get_stac_collection(stac_definitions, owner=component.name, created_at=component.created_at,
638
- detail=component_options)
639
- stac_definitions.append(stac_collection)
640
-
641
- # Write the STAC definitions.
642
- for stac_definition, stac_file_name in stac_definitions:
643
- with open(os.path.join(download_dir, stac_file_name), 'w') as stac_file:
644
- stac_file.write(json.dumps(stac_definition, default=json_default))
678
+ # Prepare each download data item by finding the constituent files, creating the download directories and calculating the input and output sizes.
679
+ download_base_dir = os.path.join(execution_dir, component_dir)
680
+ input_sizes, output_sizes, component_downloads = self.__prepare_downloads(data_items, download_base_dir, owner=component.name,
681
+ created_at=component.created_at, detail=component_options)
682
+ downloads.extend(component_downloads)
645
683
 
646
684
  metric[Command._METRIC_S3_INPUT_SIZE] = sum(input_sizes) if len(input_sizes) > 0 else ''
647
685
  metric[Command._METRIC_S3_OUTPUT_SIZE] = sum(output_sizes) if len(output_sizes) > 0 else ''
@@ -717,8 +755,8 @@ class Command(Base):
717
755
  progress_bar.colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
718
756
  progress_bar.close()
719
757
 
720
- def download_process_execution(self, organisation, process_name, download_inputs, download_outputs, download_storage, download_intermediate,
721
- download_components, save_metrics, stac_only, wait_for_execution_to_complete):
758
+ def download_process_execution(self, organisation, process_name, download_inputs, download_outputs, download_storage, download_statistics,
759
+ download_intermediate, download_components, save_metrics, stac_only, wait_for_execution_to_complete):
722
760
  """
723
761
  Downloads the inputs and/or outputs from a process or execution(s).
724
762
 
@@ -728,6 +766,7 @@ class Command(Base):
728
766
  download_inputs: Should inputs be downloaded?
729
767
  download_outputs: Should outputs be downloaded?
730
768
  download_storage: Should storage be downloaded?
769
+ download_statistics: Should statistics be downloaded?
731
770
  download_intermediate: Should intermediate components be downloaded?
732
771
  download_components: The names of the components that should be downloaded? If None or [], then all components are downloaded.
733
772
  save_metrics: Save process metrics to file?
@@ -739,18 +778,19 @@ class Command(Base):
739
778
  """
740
779
 
741
780
  # If we are downloading nothing, at least download the outputs.
742
- if (not download_inputs) and (not download_outputs) and (not download_storage):
781
+ if (not download_inputs) and (not download_outputs) and (not download_storage) and (not download_statistics):
743
782
  download_outputs = True
744
783
 
745
- self.__print(logging.INFO, i18n.t('command.download_process_execution', process=process_name, inputs=download_inputs, outputs=download_outputs,
746
- storage=download_storage, intermediate=download_intermediate, stac_only=stac_only, metrics=save_metrics,
747
- components=download_components))
784
+ self.__print(logging.INFO,
785
+ i18n.t('command.download_process_execution', process=process_name, inputs=download_inputs, outputs=download_outputs, storage=download_storage,
786
+ statistics=download_statistics, intermediate=download_intermediate, stac_only=stac_only, metrics=save_metrics,
787
+ components=download_components))
748
788
 
749
789
  # We are downloading from an existing process.
750
790
  process, execution = self.get_process_or_execution(organisation, process_name)
751
791
 
752
792
  # Get the output directory and remove it if it exists.
753
- output_dir = self.__process_name_to_file_name(process_name)
793
+ output_dir = self.__name_to_file_name(process.name)
754
794
  shutil.rmtree(output_dir, ignore_errors=True)
755
795
 
756
796
  if execution is None:
@@ -785,8 +825,8 @@ class Command(Base):
785
825
 
786
826
  try:
787
827
  # Now download each execution in batches.
788
- partial_download_execution = partial(self.__download_execution, process, download_inputs, download_outputs, download_storage, download_intermediate,
789
- download_components, output_dir, wait_for_execution_to_complete)
828
+ partial_download_execution = partial(self.__download_execution, process, download_inputs, download_outputs, download_storage, download_statistics,
829
+ download_intermediate, download_components, output_dir, wait_for_execution_to_complete)
790
830
  results = []
791
831
 
792
832
  # We use a thread pool as it preserves the marshmallow configuration and log level. Using a ProcessPool resulted in fields being labelled as missing
@@ -811,7 +851,7 @@ class Command(Base):
811
851
  downloads.extend(execution_downloads)
812
852
  metrics.extend(execution_metrics)
813
853
 
814
- # Download the files unless we only want the stac information
854
+ # Download the files unless we only want the stac information.
815
855
  if not stac_only:
816
856
  self.__download_files(downloads)
817
857
 
@@ -824,6 +864,30 @@ class Command(Base):
824
864
 
825
865
  return process
826
866
 
867
+ def download_upload(self, organisation, upload_name):
868
+ """
869
+ Downloads the upload with the specified name.
870
+
871
+ Args:
872
+ organisation: The logged in organisation.
873
+ upload_name: The name of the upload to download.
874
+ """
875
+
876
+ # We are downloading from an existing upload.
877
+ data_item = self.get_data(organisation, upload_name)
878
+
879
+ # Get the output directory and remove it if it exists.
880
+ output_dir = self.__name_to_file_name(data_item.name)
881
+ shutil.rmtree(output_dir, ignore_errors=True)
882
+
883
+ self.__print(logging.INFO, i18n.t('command.download_upload', upload=upload_name, output=output_dir))
884
+
885
+ # Prepare each download data item by finding the constituent files.
886
+ _, _, downloads = self.__prepare_downloads([(1, Command._DOWNLOAD_UPLOAD, data_item)], output_dir, owner=None, created_at=data_item.created_at, detail=None)
887
+
888
+ # Download the files.
889
+ self.__download_files(downloads)
890
+
827
891
  def extract_start_parameters(self, service_or_yaml, input_list, option_list):
828
892
  """
829
893
  Extracts the start parameters from either the command line arguments or the supplied YAML file.
@@ -836,7 +900,6 @@ class Command(Base):
836
900
  Returns:
837
901
  The service name, process name, input files, storage_files, options and dispatchers.
838
902
  """
839
-
840
903
  yaml_file = None
841
904
  service_name = None
842
905
  process_name = None
@@ -916,6 +979,33 @@ class Command(Base):
916
979
 
917
980
  return formatted
918
981
 
982
+ def get_data(self, organisation, data_name):
983
+ """
984
+ Attempts to find a data item using the given data name or id.
985
+
986
+ Args:
987
+ organisation: The logged in organisation.
988
+ data_name: The name of the data item. This can also be the id.
989
+
990
+ Returns:
991
+ The found data item.
992
+
993
+ Raises:
994
+ CommandError: if a data item could not be found.
995
+ """
996
+
997
+ # Find the data item using its case-sensitive name (starts with).
998
+ data_item, _ = organisation.find_data(name=data_name)
999
+
1000
+ # If the data item cannot be found, try to find it by its id.
1001
+ if data_item is None:
1002
+ data_item = next((item for item in organisation.data if (item.name == data_name) or (str(item.id) == data_name)), None)
1003
+
1004
+ if data_item is None:
1005
+ raise CommandError(i18n.t('command.no_such_data_item', data=data_name))
1006
+
1007
+ return data_item
1008
+
919
1009
  def __get_input(self, input_prompt, is_password=False, default=None, constrained_values=None):
920
1010
  """
921
1011
  Gets an input from the user.
@@ -1022,9 +1112,32 @@ class Command(Base):
1022
1112
 
1023
1113
  return service
1024
1114
 
1115
+ def __get_statistics_data_item(self, process_service_execution):
1116
+ """
1117
+ Gets the statistics data item for a process service execution.
1118
+
1119
+ Args:
1120
+ process_service_execution: The process service execution for which the statistics item is to be obtained.
1121
+
1122
+ Returns:
1123
+ The statistics data item if found. Otherwise, None.
1124
+ """
1125
+ statistics_data_item = None
1126
+ statistics_id = process_service_execution.statistics_id if hasattr(process_service_execution, Model._FIELD_STATISTICS_ID) and (
1127
+ process_service_execution.statistics_id is not None) else None
1128
+
1129
+ if statistics_id is not None:
1130
+ try:
1131
+ statistics_data_item = Data._model_from_api_id(process_service_execution._session, organisation_id=process_service_execution.organisation_id,
1132
+ data_id=statistics_id)
1133
+ except:
1134
+ pass # There is no statistics item.
1135
+
1136
+ return statistics_data_item
1137
+
1025
1138
  def __get_storage_data_item(self, process, group_index, group_count, ssd_id, chain_index):
1026
1139
  """
1027
- Gets the storage date item for a process' group and chain index.
1140
+ Gets the storage data item for a process' group and chain index.
1028
1141
 
1029
1142
  Args:
1030
1143
  process: The process for which the storage item is to be obtained.
@@ -1078,6 +1191,8 @@ class Command(Base):
1078
1191
  Args:
1079
1192
  organisation: The logged in organisation.
1080
1193
  """
1194
+ count = 0
1195
+
1081
1196
  for process in organisation.processes:
1082
1197
  executions = [execution for execution in process.executions]
1083
1198
 
@@ -1094,16 +1209,104 @@ class Command(Base):
1094
1209
  progress = sum([execution.progress for execution in executions]) // len(executions) if len(executions) > 0 else 0
1095
1210
  group_progress_summary = ''
1096
1211
 
1097
- for i, group in enumerate(groups.values()):
1098
- group_progress = sum([execution.progress for execution in group]) // len(group)
1099
- group_progress_summary += i18n.t('command.log_process_group', group_number=(i + 1), executions=len(group), progress=group_progress)
1212
+ if len(groups) > 1:
1213
+ for i, group in enumerate(groups.values()):
1214
+ group_progress = sum([execution.progress for execution in group]) // len(group)
1215
+ group_progress_summary += i18n.t('command.log_process_group', group_number=(i + 1), progress=group_progress)
1216
+
1217
+ # Show the process information using a progress bar.
1218
+ process_status = i18n.t(f"command.log_process_status.{process.process_status}")
1219
+ warnings = sum([execution.exit_type in [Command._EXIT_NON_FATAL_WARNING, Command._EXIT_FATAL_WARNING] for execution in executions if
1220
+ hasattr(execution, Model._FIELD_EXIT_TYPE)])
1221
+ errors = sum([execution.exit_type == Command._EXIT_FATAL_ERROR for execution in executions if hasattr(execution, Model._FIELD_EXIT_TYPE)])
1222
+ successes = sum([execution.success for execution in executions]) - warnings # Warnings also count as successes, but we want to report a valid total.
1223
+ stopped = sum([execution.stopped for execution in executions])
1224
+ current = sum([1 if execution.progress >= 100.0 else 0 for execution in executions])
1225
+ total = len(executions) if len(executions) > 0 else 0
1226
+
1227
+ if progress < 100.0:
1228
+ colour = Command.__PROGRESS_BAR_COLOUR_ACTIVE
1229
+ elif errors > 0:
1230
+ colour = Command.__PROGRESS_BAR_COLOUR_ERROR
1231
+ elif warnings > 0:
1232
+ colour = Command.__PROGRESS_BAR_COLOUR_WARNING
1233
+ elif successes >= total:
1234
+ colour = Command.__PROGRESS_BAR_COLOUR_COMPLETE
1235
+ else:
1236
+ colour = Command.__PROGRESS_BAR_COLOUR_ERROR
1237
+
1238
+ description = i18n.t('command.log_process_summary', process=process.name, group_progress=group_progress_summary)
1239
+ bar_format = Command.__PROGRESS_BAR_FORMAT_PROCESS.format(current=current, total=total, process_status=process_status, successes=successes,
1240
+ warnings=warnings, errors=errors, stopped=stopped, description=description)
1241
+ progress_bar = tqdm(total=100.0, bar_format=bar_format, colour=colour)
1242
+ progress_bar.update(progress)
1243
+ progress_bar.close()
1244
+
1245
+ count += 1
1246
+
1247
+ if count <= 0:
1248
+ self.__print(logging.INFO, i18n.t('command.log_no_processes'))
1249
+
1250
+ def list_services(self, organisation):
1251
+ """
1252
+ Lists all available services.
1253
+
1254
+ Args:
1255
+ organisation: The logged in organisation.
1256
+ """
1257
+ count = 0
1258
+
1259
+ # Get a unique list of the latest services and sort them by name.
1260
+ services = sorted(organisation.services, key=lambda service: service.name)
1261
+
1262
+ for service in services:
1263
+ self.__print(logging.INFO, i18n.t('command.log_service_detail', name=service.name, summary=service.documentation_summary))
1264
+ count += 1
1100
1265
 
1101
- # Log the process information.
1102
- self.__print(logging.INFO,
1103
- i18n.t('command.log_process_summary', process=process.name, executions=len(executions), progress=progress, groups=len(groups),
1104
- group_progress=group_progress_summary))
1266
+ if count <= 0:
1267
+ self.__print(logging.INFO, i18n.t('command.log_no_services'))
1105
1268
 
1106
- def login(self, deployment, email, organisation_name):
1269
+ def list_uploads(self, organisation):
1270
+ """
1271
+ Lists all uploads.
1272
+
1273
+ Args:
1274
+ organisation: The logged in organisation.
1275
+ """
1276
+ count = 0
1277
+
1278
+ # Get the width of all upload names.
1279
+ uploads = [data for data in organisation.data]
1280
+ maximum_name_width = max([len(data.name) for data in uploads]) if len(uploads) > 0 else 0
1281
+ blank_name = ' ' * maximum_name_width
1282
+
1283
+ for data in uploads:
1284
+ name = data.name.ljust(maximum_name_width)
1285
+
1286
+ for i, file in enumerate(data.files):
1287
+ file_name = file.file_name if hasattr(file, Model._FIELD_FILE_NAME) and (file.file_name is not None) else ''
1288
+ crs = file.crs if hasattr(file, Model._FIELD_CRS) and (file.crs is not None) else ''
1289
+ source = file.source if hasattr(file, Model._FIELD_SOURCE) and (file.source is not None) else None
1290
+ size = self.__bytes_to_human(file.size) if hasattr(file, Model._FIELD_SIZE) and (file.size is not None) else ''
1291
+
1292
+ file_type = f" {file.file_type.ljust(14)}"
1293
+ file_name = f" {file_name.ljust(20)}"
1294
+ crs = f" {crs.ljust(10)}"
1295
+ source = ' ' if source is not None else ' >'
1296
+ size = f" {size.rjust(10)}"
1297
+
1298
+ self.__print(
1299
+ logging.INFO,
1300
+ i18n.t('command.log_data_file', name=name if i == 0 else blank_name, file_type=file_type, file_name=file_name, source=source, crs=crs,
1301
+ size=size)
1302
+ )
1303
+
1304
+ count += 1
1305
+
1306
+ if count <= 0:
1307
+ self.__print(logging.INFO, i18n.t('command.log_no_uploads'))
1308
+
1309
+ def login(self, deployment, email, organisation_name, no_save_password):
1107
1310
  """
1108
1311
  Logs into the desired Fusion Platform(r) deployment and allows the user to select an organisation.
1109
1312
 
@@ -1111,6 +1314,7 @@ class Command(Base):
1111
1314
  deployment: The deployment to use.
1112
1315
  email: The optional email address used to login. If not provided, the email will be prompted for.
1113
1316
  organisation_name: The optional organisation name. If not provided, the organisation will be prompted for.
1317
+ no_save_password: Attempt to save the password using keyring? If this is True, no password will be saved and any existing password will be deleted.
1114
1318
 
1115
1319
  Returns:
1116
1320
  The selected organisation and its corresponding stage name and region.
@@ -1118,8 +1322,29 @@ class Command(Base):
1118
1322
 
1119
1323
  # Which API deployment?
1120
1324
  deployment = deployment.lower()
1121
- stage = Command.DEPLOYMENTS[deployment][Command._DEPLOYMENT_KEY_STAGE]
1122
- region = Command.DEPLOYMENTS[deployment][Command._DEPLOYMENT_KEY_REGION]
1325
+ stage = dict_nested_get(Command.DEPLOYMENTS, [deployment, Command._DEPLOYMENT_KEY_STAGE])
1326
+ region = dict_nested_get(Command.DEPLOYMENTS, [deployment, Command._DEPLOYMENT_KEY_REGION])
1327
+
1328
+ if (stage is None) or (region is None):
1329
+ raise CommandError(i18n.t('command.no_such_deployment', deployment=deployment))
1330
+
1331
+ # If we are not saving passwords, then delete any password associated with the email address.
1332
+ if no_save_password:
1333
+ try:
1334
+ keyring.delete_password(Command._KEYRING_DEPLOYMENT_FORMAT.format(deployment=deployment), email)
1335
+ except:
1336
+ # A Keyring suggests there is no suitable backend. Carry on regardless.
1337
+ pass
1338
+
1339
+ # Attempt to get the password using keyring if we have an email address. This is linked to the deployment.
1340
+ password = None
1341
+
1342
+ if email is not None:
1343
+ try:
1344
+ password = keyring.get_password(Command._KEYRING_DEPLOYMENT_FORMAT.format(deployment=deployment), email)
1345
+ except:
1346
+ # A Keyring suggests there is no suitable backend. Carry on regardless.
1347
+ pass
1123
1348
 
1124
1349
  # Login with email address and password. We retry the email and password up to three times.
1125
1350
  count = 0
@@ -1128,18 +1353,31 @@ class Command(Base):
1128
1353
  if email is None:
1129
1354
  email = self.__get_input(i18n.t('command.email'), default=email)
1130
1355
 
1131
- password = self.__get_input(i18n.t('command.password'), is_password=True)
1356
+ if password is None:
1357
+ password = self.__get_input(i18n.t('command.password'), is_password=True)
1132
1358
 
1133
1359
  try:
1134
- user = fusion_platform.login(email=email, password=password, api_url=Command.DEPLOYMENTS[deployment]['url'])
1360
+ user = fusion_platform.login(email=email, password=password,
1361
+ api_url=dict_nested_get(Command.DEPLOYMENTS, [deployment, Command._DEPLOYMENT_KEY_URL]))
1135
1362
  break
1136
1363
  except RequestError as e:
1137
1364
  count += 1
1138
1365
 
1366
+ # Clear the password, which cannot have been correct for the email address.
1367
+ password = None
1368
+
1139
1369
  # Cope with finger trouble.
1140
1370
  if (i18n.t('command.invalid_login_response') not in str(e)) or (count >= 3):
1141
1371
  raise
1142
1372
 
1373
+ # We have successfully logged in. If allowed, save the email address and password for the deployment.
1374
+ try:
1375
+ if not no_save_password:
1376
+ keyring.set_password(Command._KEYRING_DEPLOYMENT_FORMAT.format(deployment=deployment), email, password)
1377
+ except:
1378
+ # A Keyring suggests there is no suitable backend. Carry on regardless.
1379
+ pass
1380
+
1143
1381
  # Get the list of organisation names the user belongs to if the organisation is not provided.
1144
1382
  if organisation_name is None:
1145
1383
  organisation_names = [(item.name, item.updated_at) for item in user.organisations]
@@ -1176,19 +1414,94 @@ class Command(Base):
1176
1414
 
1177
1415
  try:
1178
1416
  # Login to the correct deployment and select the organisation.
1179
- organisation, _, _ = self.login(arguments.deployment, arguments.email, arguments.organisation)
1417
+ organisation, _, _ = self.login(arguments.deployment, arguments.email, arguments.organisation, arguments.no_save_password)
1180
1418
 
1181
- # Perform the list, display, start, define or download.
1182
- if arguments.command == i18n.t('command.list.command'):
1183
- # List all processes.
1184
- self.list_processes(organisation)
1419
+ # Perform the define, display, download, list, start or template.
1420
+ if arguments.command == i18n.t('command.define.command'):
1421
+ # Define one or more processes as YAML files.
1422
+ for process_name in arguments.process:
1423
+ # Get the process definition, optionally downloading the inputs and storage to the current directory as well.
1424
+ process_definition = self.define_process(organisation, process_name, download_inputs=arguments.inputs, download_storage=arguments.storage)
1425
+
1426
+ # Write the YAML to file. This will overwrite the existing file.
1427
+ file_name = f"{self.__name_to_file_name(process_name)}{Command._YAML_EXTENSION}"
1428
+
1429
+ with open(file_name, 'w') as file:
1430
+ yaml.dump(process_definition, file, sort_keys=False, width=float('inf'))
1431
+
1432
+ elif arguments.command == i18n.t('command.delete.command'):
1433
+ if arguments.process_or_upload == i18n.t('command.delete.process.command'):
1434
+ # Delete a process with the specified name or ids, or execution with ids.
1435
+ for process in arguments.process:
1436
+ if not self.delete_process(organisation, process, arguments.force):
1437
+ self.__print(logging.WARNING, i18n.t('command.no_such_process', process=process))
1438
+
1439
+ elif arguments.process_or_upload == i18n.t('command.delete.upload.command'):
1440
+ # Delete an upload with the specified name or ids.
1441
+ for upload in arguments.upload:
1442
+ if not self.delete_upload(organisation, upload, arguments.force):
1443
+ self.__print(logging.WARNING, i18n.t('command.no_such_upload', upload=upload))
1444
+
1445
+ else:
1446
+ # Unknown command.
1447
+ raise CommandError(i18n.t('command.unknown_command', command=arguments.process_or_upload))
1185
1448
 
1186
1449
  elif arguments.command == i18n.t('command.display.command'):
1187
- # Display a service or process with the specified name or ids. If a process is displayed, we do not display a service.
1188
- for process_or_service in arguments.process_or_service:
1189
- if not self.display_process(organisation, process_or_service):
1190
- if not self.display_service(organisation, process_or_service):
1191
- self.__print(logging.WARNING, i18n.t('command.no_process_or_service', process_or_service=process_or_service))
1450
+ if arguments.process_or_service == i18n.t('command.display.process.command'):
1451
+ # Display a process with the specified name or ids.
1452
+ for process in arguments.process:
1453
+ if not self.display_process(organisation, process):
1454
+ self.__print(logging.WARNING, i18n.t('command.no_such_process', process=process))
1455
+
1456
+ elif arguments.process_or_service == i18n.t('command.display.service.command'):
1457
+ # Display a service with the specified name or ids.
1458
+ for service in arguments.service:
1459
+ if not self.display_service(organisation, service):
1460
+ self.__print(logging.WARNING, i18n.t('command.no_such_service', service=service))
1461
+
1462
+ else:
1463
+ # Unknown command.
1464
+ raise CommandError(i18n.t('command.unknown_command', command=arguments.process_or_service))
1465
+
1466
+ elif arguments.command == i18n.t('command.download.command'):
1467
+ if arguments.process_or_upload == i18n.t('command.download.process.command'):
1468
+ # Download one or more processes.
1469
+ for process_name in arguments.process:
1470
+ # Download the process.
1471
+ process = self.download_process_execution(organisation, process_name, arguments.inputs, arguments.outputs, arguments.storage,
1472
+ arguments.statistics, arguments.intermediate, arguments.component, arguments.metrics,
1473
+ arguments.stac,
1474
+ not arguments.no_wait_for_completion)
1475
+
1476
+ # And optionally remove the process and its inputs.
1477
+ if arguments.remove:
1478
+ self.remove_process_and_inputs(organisation, process)
1479
+
1480
+ elif arguments.process_or_upload == i18n.t('command.download.upload.command'):
1481
+ # Download one or more uploads.
1482
+ for upload_name in arguments.upload:
1483
+ self.download_upload(organisation, upload_name)
1484
+
1485
+ else:
1486
+ # Unknown command.
1487
+ raise CommandError(i18n.t('command.unknown_command', command=arguments.process_or_upload))
1488
+
1489
+ elif arguments.command == i18n.t('command.list.command'):
1490
+ if arguments.process_service_or_upload == i18n.t('command.list.process.command'):
1491
+ # List all processes.
1492
+ self.list_processes(organisation)
1493
+
1494
+ elif arguments.process_service_or_upload == i18n.t('command.list.service.command'):
1495
+ # List all services.
1496
+ self.list_services(organisation)
1497
+
1498
+ elif arguments.process_service_or_upload == i18n.t('command.list.upload.command'):
1499
+ # List all uploads.
1500
+ self.list_uploads(organisation)
1501
+
1502
+ else:
1503
+ # Unknown command.
1504
+ raise CommandError(i18n.t('command.unknown_command', command=arguments.process_service_or_upload))
1192
1505
 
1193
1506
  elif arguments.command == i18n.t('command.start.command'):
1194
1507
  # Start one or more processes.
@@ -1207,36 +1520,28 @@ class Command(Base):
1207
1520
  # Also perform the download, if required.
1208
1521
  if arguments.download:
1209
1522
  process = self.download_process_execution(organisation, process_name, arguments.inputs, arguments.outputs, arguments.storage,
1210
- arguments.intermediate, arguments.component, arguments.metrics, arguments.stac,
1211
- not arguments.no_wait_for_completion)
1523
+ arguments.statistics, arguments.intermediate, arguments.component, arguments.metrics,
1524
+ arguments.stac, not arguments.no_wait_for_completion)
1212
1525
 
1213
1526
  # And optionally remove the process and its inputs.
1214
1527
  if arguments.remove:
1215
1528
  self.remove_process_and_inputs(organisation, process)
1216
1529
 
1217
- elif arguments.command == i18n.t('command.define.command'):
1218
- # Define one or more processes as YAML files.
1219
- for process_name in arguments.process:
1220
- # Get the process definition, optionally downloading the inputs and storage to the current directory as well.
1221
- process_definition = self.define_process(organisation, process_name, download_inputs=arguments.inputs, download_storage=arguments.storage)
1530
+ elif arguments.command == i18n.t('command.template.command'):
1531
+ # Template one or more services.
1532
+ for service_name in arguments.service:
1533
+ # Get the service definition.
1534
+ service_definition = self.template_service(organisation, service_name)
1222
1535
 
1223
1536
  # Write the YAML to file. This will overwrite the existing file.
1224
- file_name = f"{self.__process_name_to_file_name(process_name)}{Command._YAML_EXTENSION}"
1537
+ file_name = f"{self.__name_to_file_name(service_name)}{Command._YAML_EXTENSION}"
1225
1538
 
1226
1539
  with open(file_name, 'w') as file:
1227
- yaml.dump(process_definition, file, sort_keys=False, width=float('inf'))
1228
-
1229
- elif arguments.command == i18n.t('command.download.command'):
1230
- # Download one or more processes.
1231
- for process_name in arguments.process:
1232
- # Download the process.
1233
- process = self.download_process_execution(organisation, process_name, arguments.inputs, arguments.outputs, arguments.storage,
1234
- arguments.intermediate, arguments.component, arguments.metrics, arguments.stac,
1235
- not arguments.no_wait_for_completion)
1540
+ yaml.dump(service_definition, file, sort_keys=False, width=float('inf'))
1236
1541
 
1237
- # And optionally remove the process and its inputs.
1238
- if arguments.remove:
1239
- self.remove_process_and_inputs(organisation, process)
1542
+ elif arguments.command == i18n.t('command.upload.command'):
1543
+ # Upload files to an upload data item.
1544
+ self.upload(organisation, arguments.upload_name, arguments.file_type, arguments.file)
1240
1545
 
1241
1546
  else:
1242
1547
  # Unknown command.
@@ -1249,13 +1554,24 @@ class Command(Base):
1249
1554
  self.__print(logging.ERROR, str(e))
1250
1555
  exit(1) # We want to exit with an error status.
1251
1556
 
1557
+ def __name_to_file_name(self, process_name):
1558
+ """
1559
+ Converts a process name into a sane file name (without extension).
1560
+
1561
+ Args:
1562
+ process_name: The process name to convert.
1563
+
1564
+ Returns:
1565
+ The corresponding file name.
1566
+ """
1567
+ return re.sub(r"[^A-Za-z0-9._\-]", '_', str(process_name)) if process_name is not None else None
1568
+
1252
1569
  def __parse_arguments(self):
1253
1570
  """
1254
1571
  Parses the command line arguments.
1255
1572
 
1256
1573
  :return: The parsed arguments.
1257
1574
  """
1258
-
1259
1575
  parser = argparse.ArgumentParser(
1260
1576
  formatter_class=RawTextHelpFormatter,
1261
1577
  prog=i18n.t('command.program'),
@@ -1264,28 +1580,132 @@ class Command(Base):
1264
1580
  )
1265
1581
 
1266
1582
  # Create the subparsers.
1267
- subparsers = parser.add_subparsers(help=i18n.t('command.subparser'), dest='command')
1583
+ subparsers = parser.add_subparsers(description=i18n.t('command.subparser'), dest='command')
1268
1584
  parser.add_argument(i18n.t('command.version_short'), i18n.t('command.version_long'), help=i18n.t('command.version_help'), action='version',
1269
1585
  version=i18n.t('command.version_content', version=fusion_platform.__version__, version_date=fusion_platform.__version_date__))
1270
1586
 
1271
1587
  subparsers.required = True
1272
- parser_list = subparsers.add_parser(i18n.t('command.list.command'), help=i18n.t('command.list.help'))
1273
- parser_display = subparsers.add_parser(i18n.t('command.display.command'), help=i18n.t('command.display.help'))
1274
- parser_start = subparsers.add_parser(i18n.t('command.start.command'), help=i18n.t('command.start.help'))
1275
- parser_define = subparsers.add_parser(i18n.t('command.define.command'), help=i18n.t('command.define.help'))
1276
- parser_download = subparsers.add_parser(i18n.t('command.download.command'), help=i18n.t('command.download.help'))
1588
+ parser_define = subparsers.add_parser(i18n.t('command.define.command'), help=i18n.t('command.define.help'),
1589
+ description=i18n.t('command.define.description'))
1590
+ parser_delete = subparsers.add_parser(i18n.t('command.delete.command'), help=i18n.t('command.delete.help'),
1591
+ description=i18n.t('command.delete.description'))
1592
+ parser_display = subparsers.add_parser(i18n.t('command.display.command'), help=i18n.t('command.display.help'),
1593
+ description=i18n.t('command.display.description'))
1594
+ parser_download = subparsers.add_parser(i18n.t('command.download.command'), help=i18n.t('command.download.help'),
1595
+ description=i18n.t('command.download.description'))
1596
+ parser_list = subparsers.add_parser(i18n.t('command.list.command'), help=i18n.t('command.list.help'), description=i18n.t('command.list.description'))
1597
+ parser_start = subparsers.add_parser(i18n.t('command.start.command'), help=i18n.t('command.start.help'), description=i18n.t('command.start.description'))
1598
+ parser_template = subparsers.add_parser(i18n.t('command.template.command'), help=i18n.t('command.template.help'),
1599
+ description=i18n.t('command.template.description'))
1600
+ parser_upload = subparsers.add_parser(i18n.t('command.upload.command'), help=i18n.t('command.upload.help'),
1601
+ description=i18n.t('command.upload.description'))
1602
+
1603
+ # Create the subparses for delete.
1604
+ delete_subparsers = parser_delete.add_subparsers(description=i18n.t('command.delete.subparser'), dest='process_or_upload')
1605
+ delete_subparsers.required = True
1606
+
1607
+ parser_delete_process = delete_subparsers.add_parser(i18n.t('command.delete.process.command'), help=i18n.t('command.delete.process.help'),
1608
+ description=i18n.t('command.delete.process.description'))
1609
+ parser_delete_upload = delete_subparsers.add_parser(i18n.t('command.delete.upload.command'), help=i18n.t('command.delete.upload.help'),
1610
+ description=i18n.t('command.delete.upload.description'))
1611
+
1612
+ # Create the subparses for display.
1613
+ display_subparsers = parser_display.add_subparsers(description=i18n.t('command.display.subparser'), dest='process_or_service')
1614
+ display_subparsers.required = True
1615
+
1616
+ parser_display_process = display_subparsers.add_parser(i18n.t('command.display.process.command'), help=i18n.t('command.display.process.help'),
1617
+ description=i18n.t('command.display.process.description'))
1618
+ parser_display_service = display_subparsers.add_parser(i18n.t('command.display.service.command'), help=i18n.t('command.display.service.help'),
1619
+ description=i18n.t('command.display.service.description'))
1620
+
1621
+ # Create the subparses for download.
1622
+ download_subparsers = parser_download.add_subparsers(description=i18n.t('command.download.subparser'), dest='process_or_upload')
1623
+ download_subparsers.required = True
1624
+
1625
+ parser_download_process = download_subparsers.add_parser(i18n.t('command.download.process.command'), help=i18n.t('command.download.process.help'),
1626
+ description=i18n.t('command.download.process.description'))
1627
+ parser_download_upload = download_subparsers.add_parser(i18n.t('command.download.upload.command'), help=i18n.t('command.download.upload.help'),
1628
+ description=i18n.t('command.download.upload.description'))
1629
+
1630
+ # Create the subparses for list.
1631
+ list_subparsers = parser_list.add_subparsers(description=i18n.t('command.list.subparser'), dest='process_service_or_upload')
1632
+ list_subparsers.required = True
1633
+
1634
+ parser_list_process = list_subparsers.add_parser(i18n.t('command.list.process.command'), help=i18n.t('command.list.process.help'),
1635
+ description=i18n.t('command.list.process.description'))
1636
+ parser_list_service = list_subparsers.add_parser(i18n.t('command.list.service.command'), help=i18n.t('command.list.service.help'),
1637
+ description=i18n.t('command.list.service.description'))
1638
+ parser_list_upload = list_subparsers.add_parser(i18n.t('command.list.upload.command'), help=i18n.t('command.list.upload.help'),
1639
+ description=i18n.t('command.list.upload.description'))
1277
1640
 
1278
1641
  # Add in the common arguments.
1279
- for subparser in [parser_list, parser_display, parser_start, parser_define, parser_download]:
1642
+ for subparser in [
1643
+ parser_define,
1644
+ parser_delete_process,
1645
+ parser_delete_upload,
1646
+ parser_display_process,
1647
+ parser_display_service,
1648
+ parser_download_process,
1649
+ parser_download_upload,
1650
+ parser_list_process,
1651
+ parser_list_service,
1652
+ parser_list_upload,
1653
+ parser_start,
1654
+ parser_template,
1655
+ parser_upload
1656
+ ]:
1280
1657
  subparser.add_argument(i18n.t('command.deployment_short'), i18n.t('command.deployment_long'), help=i18n.t('command.deployment_help'),
1281
1658
  default=Command._DEPLOYMENT_PRODUCTION_IRL)
1282
1659
  subparser.add_argument(i18n.t('command.email_short'), i18n.t('command.email_long'), help=i18n.t('command.email_help'))
1283
1660
  subparser.add_argument(i18n.t('command.organisation_short'), i18n.t('command.organisation_long'), help=i18n.t('command.organisation_help'))
1661
+ subparser.add_argument(i18n.t('command.no_save_password_short'), i18n.t('command.no_save_password_long'), help=i18n.t('command.no_save_password_help'),
1662
+ default=False, action='store_true')
1284
1663
  subparser.add_argument(i18n.t('command.debug_short'), i18n.t('command.debug_long'), help=i18n.t('command.debug_help'), default=False,
1285
- action="store_true")
1664
+ action='store_true')
1665
+
1666
+ # Define arguments.
1667
+ parser_define.add_argument(i18n.t('command.define.process_long'), help=i18n.t('command.define.process_help'), nargs='+')
1668
+ parser_define.add_argument(i18n.t('command.start.inputs_short'), i18n.t('command.start.inputs_long'), help=i18n.t('command.start.inputs_help'),
1669
+ default=False, action='store_true')
1670
+ parser_define.add_argument(i18n.t('command.start.storage_short'), i18n.t('command.start.storage_long'), help=i18n.t('command.start.storage_help'),
1671
+ default=False, action='store_true')
1672
+
1673
+ # Delete arguments.
1674
+ parser_delete_upload.add_argument(i18n.t('command.delete.upload_long'), help=i18n.t('command.delete.upload_help'), nargs='+')
1675
+ parser_delete_upload.add_argument(i18n.t('command.delete.force_short'), i18n.t('command.delete.force_long'), help=i18n.t('command.delete.force_help'),
1676
+ default=False, action='store_true')
1677
+ parser_delete_process.add_argument(i18n.t('command.delete.process_long'), help=i18n.t('command.delete.process_help'), nargs='+')
1678
+ parser_delete_process.add_argument(i18n.t('command.delete.force_short'), i18n.t('command.delete.force_long'), help=i18n.t('command.delete.force_help'),
1679
+ default=False, action='store_true')
1286
1680
 
1287
1681
  # Display arguments.
1288
- parser_display.add_argument(i18n.t('command.display.service_or_process_long'), help=i18n.t('command.display.service_or_process_help'), nargs='+')
1682
+ parser_display_process.add_argument(i18n.t('command.display.process_long'), help=i18n.t('command.display.process_help'), nargs='+')
1683
+ parser_display_service.add_argument(i18n.t('command.display.service_long'), help=i18n.t('command.display.service_help'), nargs='+')
1684
+
1685
+ # Download arguments.
1686
+ parser_download_process.add_argument(i18n.t('command.download.process_long'), help=i18n.t('command.download.process_help'), nargs='+')
1687
+ parser_download_process.add_argument(i18n.t('command.start.inputs_short'), i18n.t('command.start.inputs_long'), help=i18n.t('command.start.inputs_help'),
1688
+ default=False, action='store_true')
1689
+ parser_download_process.add_argument(i18n.t('command.start.outputs_short'), i18n.t('command.start.outputs_long'), help=i18n.t('command.start.outputs_help'),
1690
+ default=False, action='store_true')
1691
+ parser_download_process.add_argument(i18n.t('command.start.storage_short'), i18n.t('command.start.storage_long'), help=i18n.t('command.start.storage_help'),
1692
+ default=False, action='store_true')
1693
+ parser_download_process.add_argument(i18n.t('command.start.statistics_short'), i18n.t('command.start.statistics_long'),
1694
+ help=i18n.t('command.start.statistics_help'), default=False, action='store_true')
1695
+ parser_download_process.add_argument(i18n.t('command.start.remove_short'), i18n.t('command.start.remove_long'), help=i18n.t('command.start.remove_help'),
1696
+ default=False, action='store_true')
1697
+ parser_download_process.add_argument(i18n.t('command.start.intermediate_short'), i18n.t('command.start.intermediate_long'),
1698
+ help=i18n.t('command.start.intermediate_help'), default=False, action='store_true')
1699
+ parser_download_process.add_argument(i18n.t('command.start.metrics_short'), i18n.t('command.start.metrics_long'), help=i18n.t('command.start.metrics_help'),
1700
+ default=False, action='store_true')
1701
+ parser_download_process.add_argument(i18n.t('command.start.component_short'), i18n.t('command.start.component_long'),
1702
+ help=i18n.t('command.start.component_help'), nargs='*')
1703
+ parser_download_process.add_argument(i18n.t('command.start.no_wait_for_completion_short'), i18n.t('command.start.no_wait_for_completion_long'),
1704
+ help=i18n.t('command.start.no_wait_for_completion_help'), default=False, action='store_true')
1705
+ parser_download_process.add_argument(i18n.t('command.start.stac_only_short'), i18n.t('command.start.stac_only_long'),
1706
+ help=i18n.t('command.start.stac_only_help'),
1707
+ default=False, action='store_true')
1708
+ parser_download_upload.add_argument(i18n.t('command.download.upload_long'), help=i18n.t('command.download.upload_help'), nargs='+')
1289
1709
 
1290
1710
  # Start arguments.
1291
1711
  parser_start.add_argument(i18n.t('command.start.definition_long'), help=i18n.t('command.start.definition_help'), nargs='+')
@@ -1294,58 +1714,126 @@ class Command(Base):
1294
1714
  parser_start.add_argument(i18n.t('command.start.options_short'), i18n.t('command.start.options_long'), help=i18n.t('command.start.options_help'),
1295
1715
  nargs='*')
1296
1716
  parser_start.add_argument(i18n.t('command.start.wait_for_start_short'), i18n.t('command.start.wait_for_start_long'),
1297
- help=i18n.t('command.start.wait_for_start_help'), default=False, action="store_true")
1717
+ help=i18n.t('command.start.wait_for_start_help'), default=False, action='store_true')
1298
1718
  parser_start.add_argument(i18n.t('command.start.download_short'), i18n.t('command.start.download_long'), help=i18n.t('command.start.download_help'),
1299
- default=False, action="store_true")
1719
+ default=False, action='store_true')
1300
1720
  parser_start.add_argument(i18n.t('command.start.remove_short'), i18n.t('command.start.remove_long'), help=i18n.t('command.start.remove_help'),
1301
- default=False, action="store_true")
1721
+ default=False, action='store_true')
1302
1722
  parser_start.add_argument(i18n.t('command.start.inputs_short'), i18n.t('command.start.inputs_long'), help=i18n.t('command.start.inputs_help'),
1303
- default=False, action="store_true")
1723
+ default=False, action='store_true')
1304
1724
  parser_start.add_argument(i18n.t('command.start.outputs_short'), i18n.t('command.start.outputs_long'), help=i18n.t('command.start.outputs_help'),
1305
- default=False, action="store_true")
1725
+ default=False, action='store_true')
1306
1726
  parser_start.add_argument(i18n.t('command.start.storage_short'), i18n.t('command.start.storage_long'), help=i18n.t('command.start.storage_help'),
1307
- default=False, action="store_true")
1727
+ default=False, action='store_true')
1728
+ parser_start.add_argument(i18n.t('command.start.statistics_short'), i18n.t('command.start.statistics_long'), help=i18n.t('command.start.statistics_help'),
1729
+ default=False, action='store_true')
1308
1730
  parser_start.add_argument(i18n.t('command.start.intermediate_short'), i18n.t('command.start.intermediate_long'),
1309
- help=i18n.t('command.start.intermediate_help'), default=False, action="store_true")
1731
+ help=i18n.t('command.start.intermediate_help'), default=False, action='store_true')
1310
1732
  parser_start.add_argument(i18n.t('command.start.metrics_short'), i18n.t('command.start.metrics_long'), help=i18n.t('command.start.metrics_help'),
1311
- default=False, action="store_true")
1733
+ default=False, action='store_true')
1312
1734
  parser_start.add_argument(i18n.t('command.start.component_short'), i18n.t('command.start.component_long'), help=i18n.t('command.start.component_help'),
1313
1735
  nargs='*')
1314
1736
  parser_start.add_argument(i18n.t('command.start.no_wait_for_completion_short'), i18n.t('command.start.no_wait_for_completion_long'),
1315
- help=i18n.t('command.start.no_wait_for_completion_help'), default=False, action="store_true")
1737
+ help=i18n.t('command.start.no_wait_for_completion_help'), default=False, action='store_true')
1316
1738
  parser_start.add_argument(i18n.t('command.start.stac_only_short'), i18n.t('command.start.stac_only_long'), help=i18n.t('command.start.stac_only_help'),
1317
- default=False, action="store_true")
1739
+ default=False, action='store_true')
1318
1740
 
1319
- # Define arguments.
1320
- parser_define.add_argument(i18n.t('command.define.process_long'), help=i18n.t('command.define.process_help'), nargs='+')
1321
- parser_define.add_argument(i18n.t('command.start.inputs_short'), i18n.t('command.start.inputs_long'), help=i18n.t('command.start.inputs_help'),
1322
- default=False, action="store_true")
1323
- parser_define.add_argument(i18n.t('command.start.storage_short'), i18n.t('command.start.storage_long'), help=i18n.t('command.start.storage_help'),
1324
- default=False, action="store_true")
1741
+ # Template arguments.
1742
+ parser_template.add_argument(i18n.t('command.template.service_long'), help=i18n.t('command.template.service_help'), nargs='+')
1325
1743
 
1326
- # Download arguments.
1327
- parser_download.add_argument(i18n.t('command.download.process_long'), help=i18n.t('command.download.process_help'), nargs='+')
1328
- parser_download.add_argument(i18n.t('command.start.inputs_short'), i18n.t('command.start.inputs_long'), help=i18n.t('command.start.inputs_help'),
1329
- default=False, action="store_true")
1330
- parser_download.add_argument(i18n.t('command.start.outputs_short'), i18n.t('command.start.outputs_long'), help=i18n.t('command.start.outputs_help'),
1331
- default=False, action="store_true")
1332
- parser_download.add_argument(i18n.t('command.start.storage_short'), i18n.t('command.start.storage_long'), help=i18n.t('command.start.storage_help'),
1333
- default=False, action="store_true")
1334
- parser_download.add_argument(i18n.t('command.start.remove_short'), i18n.t('command.start.remove_long'), help=i18n.t('command.start.remove_help'),
1335
- default=False, action="store_true")
1336
- parser_download.add_argument(i18n.t('command.start.intermediate_short'), i18n.t('command.start.intermediate_long'),
1337
- help=i18n.t('command.start.intermediate_help'), default=False, action="store_true")
1338
- parser_download.add_argument(i18n.t('command.start.metrics_short'), i18n.t('command.start.metrics_long'), help=i18n.t('command.start.metrics_help'),
1339
- default=False, action="store_true")
1340
- parser_download.add_argument(i18n.t('command.start.component_short'), i18n.t('command.start.component_long'),
1341
- help=i18n.t('command.start.component_help'), nargs='*')
1342
- parser_download.add_argument(i18n.t('command.start.no_wait_for_completion_short'), i18n.t('command.start.no_wait_for_completion_long'),
1343
- help=i18n.t('command.start.no_wait_for_completion_help'), default=False, action="store_true")
1344
- parser_download.add_argument(i18n.t('command.start.stac_only_short'), i18n.t('command.start.stac_only_long'), help=i18n.t('command.start.stac_only_help'),
1345
- default=False, action="store_true")
1744
+ # Upload arguments.
1745
+ parser_upload.add_argument(i18n.t('command.upload.upload_name_long'), help=i18n.t('command.upload.upload_name_help'))
1746
+ parser_upload.add_argument(i18n.t('command.upload.file_type_long'), help=i18n.t('command.upload.file_type_help'))
1747
+ parser_upload.add_argument(i18n.t('command.upload.file_long'), help=i18n.t('command.upload.file_help'), nargs='+')
1346
1748
 
1347
1749
  return parser.parse_args()
1348
1750
 
1751
+ def __prepare_downloads(self, data_items, download_base_dir, owner=None, created_at=None, detail=None):
1752
+ """
1753
+ Prepare each download data item by finding the constituent files, creating the download directories and calculating the input and output sizes. A STAC
1754
+ collection is also written using the overarching owner, created at and detail for each of the data items.
1755
+
1756
+ Data items are allocated an item number to prefix the download directory with for inputs and outputs. They also have a data type which dictates what
1757
+ download directory is used and whether to add the item to the input or output size lists, or create STAC item. Data types can be: _DOWNLOAD_INPUT,
1758
+ _DOWNLOAD_OUTPUT, _DOWNLOAD_STORAGE, _DOWNLOAD_STATISTICS or _DOWNLOAD_UPLOAD.
1759
+
1760
+ Args:
1761
+ data_items: A list of tuples containing the item number, data type and data model.
1762
+ download_base_dir: The base directory into which the downloads will be placed in a subdirectory based upon the data item name.
1763
+ owner: The optional owner of the STAC collection. Default None.
1764
+ created_at: Optionally when the STAC collection was created. Default None.
1765
+ detail: The optional STAC collection detail as a dictionary. Default None.
1766
+
1767
+ Returns:
1768
+ The list of input sizes, list of output sizes and list of downloads to be performed.
1769
+ """
1770
+
1771
+ # While traversing each, save the input and output size. The size might not be immediately available for every file, so wait for all items to be analysed.
1772
+ input_sizes = []
1773
+ output_sizes = []
1774
+ downloads = []
1775
+
1776
+ for i, data_type, data_item in data_items:
1777
+ # Download the data item once it is complete.
1778
+ data_item.check_analysis_complete(wait=True)
1779
+
1780
+ if data_type in [Command._DOWNLOAD_INPUT, Command._DOWNLOAD_OUTPUT]:
1781
+ name = re.sub('_+', '_', f"{data_type.capitalize()}_{str(i)}_{self.__data_name_to_file_name(data_item.name)}").rstrip('_')
1782
+ else:
1783
+ name = '' # We do not need subdirectories for these data types.
1784
+
1785
+ download_dir = os.path.join(download_base_dir, name)
1786
+ os.makedirs(download_dir, exist_ok=True)
1787
+
1788
+ # Get a list of the unique file names. This will be used in determining whether we need to make the file name unique.
1789
+ files = [file for file in data_item.files] # So that we do not request multiple times.
1790
+ file_names = [file.file_name for file in files]
1791
+ file_names_count = {file_name: file_names.count(file_name) for file_name in file_names}
1792
+
1793
+ # Queue the files for download. We also create the corresponding STAC definitions.
1794
+ stac_definitions = []
1795
+ used_file_names = set()
1796
+
1797
+ for file in files:
1798
+ # We are downloading all the data item's files to a single directory, so we need to make sure the file names are unique.
1799
+ i = 1
1800
+ file_name = f"{i}-{file.file_name}" if file_names_count.get(file.file_name, 1) > 1 else file.file_name
1801
+
1802
+ while file_name in used_file_names:
1803
+ i += 1
1804
+ file_name = f"{i}-{file.file_name}"
1805
+
1806
+ used_file_names.add(file_name)
1807
+ path = os.path.join(download_dir, file_name)
1808
+
1809
+ # Add the download for each file for the data item.
1810
+ if data_type == Command._DOWNLOAD_INPUT:
1811
+ input_sizes.append(file.size)
1812
+ elif data_type == Command._DOWNLOAD_OUTPUT:
1813
+ output_sizes.append(file.size)
1814
+
1815
+ downloads.append((file, path, file.size))
1816
+
1817
+ # Now get the corresponding STAC item. Note that we adjust the STAC item file name to match the unique file name generated above. This is achieved
1818
+ # by replacing the STAC item file name.
1819
+ stac_item_file = DataFile._STAC_ITEM_FILE_NAME_FORMAT.format(file_name=os.path.basename(file_name))
1820
+ stac_item, _ = file.get_stac_item(file_name=file_name, item_file_name=stac_item_file)
1821
+
1822
+ if (data_type in [Command._DOWNLOAD_INPUT, Command._DOWNLOAD_OUTPUT, Command._DOWNLOAD_UPLOAD]) and (stac_item is not None) and (
1823
+ stac_item_file is not None):
1824
+ stac_definitions.append((stac_item, stac_item_file))
1825
+
1826
+ if len(stac_definitions) > 0:
1827
+ stac_collection = data_item.get_stac_collection(stac_definitions, owner=owner, created_at=created_at, detail=detail)
1828
+ stac_definitions.append(stac_collection)
1829
+
1830
+ # Write the STAC definitions.
1831
+ for stac_definition, stac_file_name in stac_definitions:
1832
+ with open(os.path.join(download_dir, stac_file_name), 'w') as stac_file:
1833
+ stac_file.write(json.dumps(stac_definition, default=json_default))
1834
+
1835
+ return input_sizes, output_sizes, downloads
1836
+
1349
1837
  def __print(self, level, message, format=True, **kwargs):
1350
1838
  """
1351
1839
  Prints a message to the console. This works much the same was a log message.
@@ -1502,17 +1990,103 @@ class Command(Base):
1502
1990
 
1503
1991
  self.__print(logging.INFO, i18n.t('command.log_bookend'))
1504
1992
 
1505
- def __process_name_to_file_name(self, process_name):
1993
+ def __provision_process(self, process, inputs, options, dispatchers):
1506
1994
  """
1507
- Converts a process name into a sane file name (without extension).
1995
+ Provisions a template process based upon the inputs, options and dispatchers. The supplied process template is updated and persisted.
1508
1996
 
1509
1997
  Args:
1510
- process_name: The process name to convert.
1511
-
1512
- Returns:
1513
- The corresponding file name.
1998
+ process: The template process.
1999
+ inputs: The list of data items to be used as inputs.
2000
+ options: The list of options.
2001
+ dispatchers: The list of dispatchers.
1514
2002
  """
1515
- return re.sub(r"[^A-Za-z0-9._\-]", '_', str(process_name)) if process_name is not None else None
2003
+
2004
+ # Configure the process to use the inputs and set its options. Note that we filter out model level fields and set those as keyword arguments.
2005
+ for i, input in enumerate(inputs):
2006
+ process.update(input_number=i + 1, data=input)
2007
+
2008
+ kwargs = {}
2009
+
2010
+ for key, value in options.items():
2011
+ if key in Command._MODEL_FIELDS:
2012
+ kwargs[key] = value
2013
+ else:
2014
+ process.update(option_name=key, value=value, coerce_value=True)
2015
+
2016
+ if len(kwargs) > 0:
2017
+ process.update(**kwargs)
2018
+
2019
+ # Add in optional dispatchers.
2020
+ dispatchers_added = 0
2021
+
2022
+ for i, dispatcher in enumerate(process.available_dispatchers):
2023
+ finished = False
2024
+
2025
+ while not finished:
2026
+ # Either find the dispatchers from what has been loaded from the YAML file or prompt the user.
2027
+ add = False
2028
+ dispatch_intermediate = False
2029
+ dispatcher_options = {}
2030
+
2031
+ if dispatchers is not None:
2032
+ # See if any of the remaining dispatchers match this one. Once used, the corresponding dispatcher is marked as used.
2033
+ filtered_dispatchers = [item for item in dispatchers if
2034
+ (item.get(Model._FIELD_NAME) == dispatcher.name) and (not item.get(Model._FIELD_USED, False))]
2035
+
2036
+ if len(filtered_dispatchers) > 0:
2037
+ selected_dispatcher = filtered_dispatchers[0]
2038
+ selected_dispatcher[Model._FIELD_USED] = True
2039
+ add = True
2040
+ dispatch_intermediate = selected_dispatcher.get(Model._FIELD_DISPATCH_INTERMEDIATE) if selected_dispatcher.get(
2041
+ Model._FIELD_DISPATCH_INTERMEDIATE) is not None else False
2042
+ dispatcher_options = selected_dispatcher.get(Model._FIELD_OPTIONS) if selected_dispatcher.get(Model._FIELD_OPTIONS) is not None else {}
2043
+
2044
+ else:
2045
+ response = self.__get_input(i18n.t('command.add_dispatcher', dispatcher=dispatcher.name),
2046
+ constrained_values=[i18n.t('command.prompt_no'), i18n.t('command.prompt_yes')])
2047
+
2048
+ if (response is not None) and response.lower().startswith(i18n.t('command.prompt_yes').lower()[0]):
2049
+ add = True
2050
+
2051
+ if hasattr(dispatcher, Model._FIELD_DISPATCH_INTERMEDIATE):
2052
+ response = self.__get_input(i18n.t('command.dispatch_intermediate'),
2053
+ constrained_values=[i18n.t('command.prompt_no'), i18n.t('command.prompt_yes')])
2054
+
2055
+ if (response is not None) and response.lower().startswith(i18n.t('command.prompt_yes').lower()[0]):
2056
+ dispatch_intermediate = True
2057
+
2058
+ for option in dispatcher.options:
2059
+ finished_options = False
2060
+ option_value = None
2061
+
2062
+ while not finished_options:
2063
+ constrained_values = option.constrained_values if option.data_type == fusion_platform.DATA_TYPE_CONSTRAINED else None
2064
+
2065
+ prompt_constrained_values = i18n.t('models.process.option.representation_constrained_values',
2066
+ constrained_values=constrained_values) if constrained_values is not None else ''
2067
+ prompt_required = i18n.t('models.process.option.representation_required') if option.required else ''
2068
+ prompt = i18n.t('command.prompt_option', title=option.title, name=option.name, data_type=option.data_type,
2069
+ constrained_values=prompt_constrained_values, required=prompt_required)
2070
+
2071
+ option_value = self.__get_input(prompt, default=option.value, constrained_values=constrained_values)
2072
+ finished_options = (not option.required) or (
2073
+ option.required and (option_value is not None) and (not string_blank(str(option_value))))
2074
+
2075
+ if option_value is not None:
2076
+ dispatcher_options[option.name] = option_value
2077
+
2078
+ if add:
2079
+ process.add_dispatcher(number=(i + 1))
2080
+ dispatchers_added += 1
2081
+
2082
+ for option_name, option_value in dispatcher_options.items():
2083
+ process.update(dispatcher_number=dispatchers_added, option_name=option_name, value=option_value, coerce_value=True,
2084
+ dispatch_intermediate=dispatch_intermediate)
2085
+ else:
2086
+ finished = True
2087
+
2088
+ # Create the process, which will validate its options and inputs.
2089
+ process.create()
1516
2090
 
1517
2091
  def remove_process_and_inputs(self, organisation, process):
1518
2092
  """
@@ -1633,18 +2207,22 @@ class Command(Base):
1633
2207
  """
1634
2208
  self.__print(logging.INFO, i18n.t('command.start_process', process=process_name, service=service_name))
1635
2209
 
1636
- # Upload the inputs.
1637
- if (input_files is not None) and (len(input_files) > 0):
1638
- data_items = self.__upload_or_find_inputs(organisation, process_name, input_files)
1639
- else:
1640
- data_items = []
1641
-
1642
2210
  # Find the service.
1643
2211
  service = self.get_service(organisation, service_name)
1644
2212
  self.__print(logging.DEBUG, i18n.t('command.using_service', service=service.name))
1645
2213
 
1646
- # Create the process with its options and inputs.
1647
- process = self.__create_process(organisation, process_name, options, dispatchers, service, data_items)
2214
+ # Create a template process from the service.
2215
+ process = organisation.new_process(name=process_name, service=service)
2216
+
2217
+ # Upload or find the inputs. We upload using the known file types.
2218
+ if (input_files is not None) and (len(input_files) > 0):
2219
+ file_types = [input.file_type for input in process.inputs]
2220
+ data_items = self.__upload_or_find_inputs(organisation, process_name, input_files, file_types)
2221
+ else:
2222
+ data_items = []
2223
+
2224
+ # Provision the process with its options, inputs and dispatchers.
2225
+ self.__provision_process(process, data_items, options, dispatchers)
1648
2226
 
1649
2227
  # Upload the optional storage data items now that the process has been created because we need the process id.
1650
2228
  if (storage_files is not None) and (len(storage_files) > 0):
@@ -1661,7 +2239,111 @@ class Command(Base):
1661
2239
 
1662
2240
  return process
1663
2241
 
1664
- def __upload_or_find_inputs(self, organisation, process_name, input_files):
2242
+ def template_service(self, organisation, service_name):
2243
+ """
2244
+ Obtains the service definition for a service.
2245
+
2246
+ Args:
2247
+ organisation: The logged in organisation.
2248
+ service_name: The name of the service to be defined. This can also be the id or SSD id.
2249
+
2250
+ Returns:
2251
+ The service definition.
2252
+ """
2253
+ self.__print(logging.INFO, i18n.t('command.template_service', service=service_name))
2254
+
2255
+ # We are defining a service. This will raise an exception if the service is not found.
2256
+ service = self.get_service(organisation, service_name)
2257
+
2258
+ # Create a template process from the service so that we can obtain the options, inputs and dispatchers.
2259
+ process_name = f"{service.name} {datetime.now(timezone.utc).isoformat()}"
2260
+ process = organisation.new_process(name=process_name, service=service)
2261
+
2262
+ # Get the options and add in any model-level attributes that are required. Any missing attributes are set to None.
2263
+ options = {option.name: self.__format_value(option.value, data_type=option.data_type) for option in process.options}
2264
+
2265
+ for key in Command._MODEL_FIELDS:
2266
+ options[key] = self.__format_value(getattr(process, key)) if hasattr(process, key) else self.__format_value(None)
2267
+
2268
+ # Add in the inputs.
2269
+ inputs = []
2270
+
2271
+ for i, input in enumerate(process.inputs):
2272
+ title = input.title if hasattr(input, Model._FIELD_TITLE) and (input.title is not None) else str(i + 1)
2273
+ file_type = input.file_type if hasattr(input, Model._FIELD_FILE_TYPE) and (input.file_type is not None) else fusion_platform.FILE_TYPE_OTHER
2274
+ extension = Command._FILE_TYPES_TO_EXTENSIONS.get(file_type)
2275
+ extension = extension[0] if (extension is not None) and len(extension) > 0 else None
2276
+ file = f"{self.__data_name_to_file_name(title.lower())}{extension if extension is not None else ''}"
2277
+ inputs.append({Model._FIELD_NAME: title, Model._FIELD_FILE: file})
2278
+
2279
+ # Add in all available dispatchers and their options.
2280
+ dispatchers = []
2281
+
2282
+ for dispatcher in process.available_dispatchers:
2283
+ found_dispatcher = {Model._FIELD_NAME: dispatcher.name}
2284
+ found_dispatcher[Model._FIELD_OPTIONS] = {option.name: self.__format_value(option.value, data_type=option.data_type) for option in dispatcher.options}
2285
+ dispatchers.append(found_dispatcher)
2286
+
2287
+ # Build the definition.
2288
+ service_definition = {
2289
+ Command._YAML_SERVICE_NAME: service.name,
2290
+ Command._YAML_PROCESS_NAME: process_name,
2291
+ Command._YAML_INPUTS: inputs,
2292
+ Command._YAML_OPTIONS: options,
2293
+ Command._YAML_DISPATCHERS: dispatchers
2294
+ }
2295
+
2296
+ return service_definition
2297
+
2298
+ def upload(self, organisation, upload_name, file_type, files):
2299
+ """
2300
+ Creates a new upload item with the specified file type and uploads the files to it.
2301
+
2302
+ Args:
2303
+ organisation: The logged in organisation.
2304
+ upload_name: The name of the upload to create.
2305
+ file_type: The file type to be associated with the files.
2306
+ files: The files to be uploaded.
2307
+ """
2308
+
2309
+ # Make sure the file type is valid.
2310
+ validated_file_types = [
2311
+ item for item in list(Command._FILE_TYPES_TO_EXTENSIONS.keys()) + [fusion_platform.FILE_TYPE_OTHER] if file_type.lower() == item.lower()
2312
+ ]
2313
+
2314
+ if len(validated_file_types) <= 0:
2315
+ raise CommandError(i18n.t('command.unknown_file_type', file_type=file_type))
2316
+
2317
+ validated_file_type = validated_file_types[0]
2318
+
2319
+ # Check that all the files exist and that they have a valid extension.
2320
+ total_size = 0
2321
+
2322
+ for file in files:
2323
+ if not os.path.exists(file):
2324
+ raise CommandError(i18n.t('command.missing_file', file=file))
2325
+ else:
2326
+ total_size += os.path.getsize(file)
2327
+
2328
+ # Check the extension for anything but the "Other" file type.
2329
+ if validated_file_type != fusion_platform.FILE_TYPE_OTHER:
2330
+ extension = os.path.splitext(file.lower())[1]
2331
+
2332
+ if extension not in Command._FILE_TYPES_TO_EXTENSIONS[validated_file_type]:
2333
+ raise CommandError(i18n.t('command.invalid_file_extension', file=file, extension=extension, file_type=validated_file_type))
2334
+
2335
+ # Make sure we have files to upload.
2336
+ if len(files) <= 0:
2337
+ raise CommandError(i18n.t('command.no_upload_files'))
2338
+
2339
+ # Create the required item. The upload has a progress bar.
2340
+ self.__print(logging.INFO, i18n.t('command.upload_files', upload_name=upload_name, file_type=validated_file_type, number_of_files=len(files)))
2341
+ created = organisation.create_data(name=upload_name, file_type=validated_file_type, files=files, wait=False)
2342
+
2343
+ # Wait for the analysis of any created items to complete.
2344
+ self.__wait_for_uploads([(created, False, 0, total_size)])
2345
+
2346
+ def __upload_or_find_inputs(self, organisation, process_name, input_files, file_types):
1665
2347
  """
1666
2348
  Uploads or finds the inputs ready to start a process.
1667
2349
 
@@ -1669,10 +2351,13 @@ class Command(Base):
1669
2351
  organisation: The logged in organisation.
1670
2352
  process_name: The name of the process.
1671
2353
  input_files: The list of input files to be uploaded or pre-existing input names to be found.
2354
+ file_type: The list of file types to be associated with the inputs.
1672
2355
 
1673
2356
  Returns:
1674
2357
  The found data items in the same order as the input files.
1675
2358
  """
2359
+ file_types = [] if file_types is None else file_types
2360
+ file_types = file_types + ([None] * (len(input_files) - len(file_types)))
1676
2361
  data_items = [None] * len(input_files)
1677
2362
  find_items = []
1678
2363
  create_items = []
@@ -1698,29 +2383,16 @@ class Command(Base):
1698
2383
  extension = None
1699
2384
 
1700
2385
  if extension is not None:
1701
- # Create a data item for each input.
1702
- if (extension == '.tif') or (extension == '.tiff'):
1703
- file_type = fusion_platform.FILE_TYPE_GEOTIFF
1704
- elif extension == '.jp2':
1705
- file_type = fusion_platform.FILE_TYPE_JPEG2000
1706
- elif extension == '.dem':
1707
- file_type = fusion_platform.FILE_TYPE_DEM
1708
- elif (extension == '.json') or (extension == '.geojson'):
1709
- file_type = fusion_platform.FILE_TYPE_GEOJSON
1710
- elif extension == '.kml':
1711
- file_type = fusion_platform.FILE_TYPE_KML
1712
- elif extension == '.kmz':
1713
- file_type = fusion_platform.FILE_TYPE_KMZ
1714
- elif extension == '.csv':
1715
- file_type = fusion_platform.FILE_TYPE_CSV
1716
- elif extension == '.zip':
1717
- file_type = fusion_platform.FILE_TYPE_ESRI_SHAPEFILE
1718
- elif (extension == '.jpeg') or (extension == '.jpg'):
1719
- file_type = fusion_platform.FILE_TYPE_JPEG
1720
- elif extension == '.png':
1721
- file_type = fusion_platform.FILE_TYPE_PNG
1722
- else:
1723
- file_type = fusion_platform.FILE_TYPE_OTHER
2386
+ # Create a data item for each input. We try to use the supplied file types, but if they are not available, we guess the file type from the extension.
2387
+ file_type = file_types[i]
2388
+
2389
+ if file_type is None:
2390
+ for key, value in Command._FILE_TYPES_TO_EXTENSIONS.items():
2391
+ if extension in value:
2392
+ file_type = key
2393
+ break
2394
+
2395
+ file_type = fusion_platform.FILE_TYPE_OTHER if file_type is None else file_type
1724
2396
 
1725
2397
  input_name = f"{process_name} {i + 1}" if input_name is None else input_name
1726
2398
  create_items.append((i, input_name, file_type, filename))
@@ -1773,7 +2445,7 @@ class Command(Base):
1773
2445
 
1774
2446
  Args:
1775
2447
  organisation: The logged in organisation.
1776
- storage_files: The list of storage files to be uploaded.
2448
+ storage_files: The dictionary of storage files to be uploaded.
1777
2449
  process: The newly created process.
1778
2450
  """
1779
2451
  self.__print(logging.INFO, i18n.t('command.upload_storage', files=len(storage_files)))
@@ -1801,7 +2473,8 @@ class Command(Base):
1801
2473
  created = organisation.create_data(name=storage_name, file_type=fusion_platform.FILE_TYPE_GZIP, files=[upload_file], wait=False, id=storage_id)
1802
2474
  created_items.append((created, False, 0, os.path.getsize(upload_file)))
1803
2475
 
1804
- os.remove(upload_file)
2476
+ if os.path.exists(upload_file):
2477
+ os.remove(upload_file)
1805
2478
 
1806
2479
  # Wait for the analysis of any created items to complete.
1807
2480
  self.__wait_for_uploads(created_items)