cloud-submit 0.1__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.
cloud_submit/cli.py ADDED
@@ -0,0 +1,1014 @@
1
+ import os
2
+ import sys
3
+ import datetime as dt
4
+
5
+ import click
6
+ import yaml
7
+
8
+ from .execution.utils import CloudSubmitError
9
+ from .controller import Controller
10
+
11
+
12
+ def abort(msg):
13
+ sys.stderr.write(msg)
14
+ sys.stderr.write('\n')
15
+ sys.exit(1)
16
+
17
+
18
+ def init(ctx):
19
+ ctx.ensure_object(dict)
20
+ if ctx.obj['controller'] is not None:
21
+ return
22
+ project_root = os.path.abspath(ctx.obj['project_root'])
23
+ ctx.obj['project_root'] = project_root
24
+ user = ctx.obj['user']
25
+
26
+ sys.path.insert(0, os.path.join(project_root, 'src'))
27
+ userconfig_path = os.path.join(project_root, 'userconfig', f'{user}.yaml')
28
+ try:
29
+ stream = open(userconfig_path, 'r')
30
+ except FileNotFoundError:
31
+ abort(
32
+ f'Could not find user config at {userconfig_path}. Are you in the '
33
+ 'project root directory? When working outside the project root '
34
+ 'directory you must specify it with -p.'
35
+ )
36
+ try:
37
+ userconfig = yaml.safe_load(stream)
38
+ except yaml.YAMLError as e:
39
+ abort(f'Error reading user config at {userconfig_path}: {str(e)}')
40
+ finally:
41
+ stream.close()
42
+
43
+ from csub.build_config import build_config
44
+ csubconfig = build_config(project_root, userconfig)
45
+ controller = Controller(csubconfig)
46
+ ctx.obj['config'] = csubconfig
47
+ ctx.obj['controller'] = controller
48
+
49
+
50
+ def tabulate(data, header=None):
51
+ if not data:
52
+ return ''
53
+ if header is not None:
54
+ data = [header] + data
55
+ data = [
56
+ [('' if cell is None else cell) for cell in row]
57
+ for row in data
58
+ ]
59
+ maxlen = None
60
+ for row in data:
61
+ if maxlen is None:
62
+ maxlen = [len(cell) for cell in row]
63
+ elif len(row) == len(maxlen):
64
+ maxlen = [max(len(cell), l) for cell, l in zip(row, maxlen)]
65
+ else:
66
+ raise ValueError('Inconsistent element count in rows.')
67
+
68
+ result = []
69
+ for row in data:
70
+ output_row = []
71
+ for cell, l in zip(row, maxlen):
72
+ output_row.append(cell.ljust(l))
73
+ result.append(' '.join(output_row))
74
+ return '\n'.join(result)
75
+
76
+
77
+ def get_steps(pipeline, steps_arg):
78
+ if steps_arg is None:
79
+ return None
80
+
81
+ tokens = steps_arg.split(',')
82
+ all_steps = [step.name for step in pipeline.steps]
83
+ indices = {step: i for i, step in enumerate(all_steps)}
84
+
85
+ selected = set()
86
+ for token in tokens:
87
+ token = token.split(':')
88
+ if len(token) == 1:
89
+ if token[0] not in indices:
90
+ raise CloudSubmitError(f'Invalid step name: {token[0]}')
91
+ selected.add(token[0])
92
+ elif len(token) == 2:
93
+ try:
94
+ begin = indices[token[0]]
95
+ except KeyError:
96
+ raise CloudSubmitError(f'Invalid step name: {token[0]}')
97
+ try:
98
+ end = indices[token[1]]
99
+ except KeyError:
100
+ raise CloudSubmitError(f'Invalid step name: {token[1]}')
101
+ for step in all_steps[beg:end+1]:
102
+ selected.add(step)
103
+ else:
104
+ raise CloudSubmitError(f'Invalid step range: {":".join(token)}')
105
+
106
+ return [step for step in all_steps if step in selected]
107
+
108
+
109
+ @click.group()
110
+ @click.option(
111
+ '--project-root', '-p',
112
+ type=click.Path(exists=True, file_okay=False),
113
+ default='.',
114
+ help='Path to the project root directory. Defaults to current directory.',
115
+ )
116
+ @click.option(
117
+ '--user', '-u',
118
+ type=str,
119
+ default='default',
120
+ help='Path to the project root directory. Defaults to current directory.',
121
+ )
122
+ @click.pass_context
123
+ def main(ctx, project_root, user):
124
+ ctx.ensure_object(dict)
125
+ ctx.obj['project_root'] = project_root
126
+ ctx.obj['user'] = user
127
+ ctx.obj['controller'] = None
128
+ ctx.obj['config'] = None
129
+
130
+
131
+ # pipelines subcommand
132
+
133
+
134
+ @main.group('pipelines', help='View and execute pipelines.')
135
+ def pipelines():
136
+ pass
137
+
138
+
139
+ @pipelines.command(
140
+ name='list',
141
+ help="""List available pipelines and their steps.
142
+
143
+ If one or more PIPELINES arguments are given those pipelines will be
144
+ listed (if they exist). Otherwise all available pipelines are listed.
145
+ """,
146
+ )
147
+ @click.option(
148
+ '--steps', '-s',
149
+ is_flag=True,
150
+ help='Also list the steps of each pipeline.'
151
+ )
152
+ @click.argument(
153
+ 'pipelines',
154
+ type=str,
155
+ nargs=-1,
156
+ )
157
+ @click.pass_context
158
+ def list_pipelines(ctx, steps, pipelines):
159
+ init(ctx)
160
+ config = ctx.obj['config']
161
+ if pipelines:
162
+ pipelines = set(pipelines)
163
+ else:
164
+ pipelines = None
165
+ for pipeline in config.pipelines.values():
166
+ if pipelines is None or pipeline.name in pipelines:
167
+ print(pipeline.name)
168
+ if steps:
169
+ for step in pipeline.steps:
170
+ print(' ' + step.name)
171
+
172
+
173
+ @pipelines.command(
174
+ name='run',
175
+ help="""Run a pipeline (or a part of it).
176
+
177
+ Run the pipeline PIPELINE. Specify STEPS to select a subset of steps to run.
178
+ STEPS should be a comma-separated list of step names or step ranges of the form
179
+ start:end where start and end are step names. If steps is not specified all
180
+ steps are executed. By default, all *execution* images needed for the run are
181
+ re-built (but you can use --no-rebuild to prevent that).
182
+ """
183
+ )
184
+ @click.option(
185
+ '--env', '-e',
186
+ type=str,
187
+ default=None,
188
+ help='The name of the environment to use for the run.',
189
+ )
190
+ @click.option(
191
+ '--build-env',
192
+ type=str,
193
+ default=None,
194
+ help='The name of the environment to use for building images.',
195
+ )
196
+ @click.option(
197
+ '--run-id', '-r',
198
+ type=str,
199
+ default=None,
200
+ help=(
201
+ 'The ID for the run. This will be used, for example, to uniquely name '
202
+ 'artifacts. Defaults to the current timestamp in YYYYMMDD-HHMMSS-XXXX '
203
+ 'format, where XXXX is a four-character UUID.'
204
+ ),
205
+ )
206
+ @click.option(
207
+ '--timestamp', '-t',
208
+ type=str,
209
+ default=None,
210
+ help=(
211
+ 'The timestamp used as the submit time of the pipeline. '
212
+ 'If not specified the default submit timestamp of the pipeline is '
213
+ 'used. If that is not specified either the current time is used. '
214
+ 'You can use this option to simulate runs submitted at a different '
215
+ 'time. The value must be in ISO format (YYYY-MM-DDTHH:MM:SS). '
216
+ 'You can also use "-t now" to use the current time irrespective of '
217
+ 'the pipeline default.'
218
+ ),
219
+ )
220
+ @click.option('--stream-logs', is_flag=True)
221
+ @click.argument(
222
+ 'pipeline',
223
+ type=str,
224
+ required=True,
225
+ )
226
+ @click.argument(
227
+ 'steps',
228
+ type=str,
229
+ default=None,
230
+ )
231
+ @click.pass_context
232
+ def run_pipeline(
233
+ ctx,
234
+ env,
235
+ build_env,
236
+ run_id,
237
+ timestamp,
238
+ stream_logs,
239
+ pipeline,
240
+ steps
241
+ ):
242
+ init(ctx)
243
+ config = ctx.obj['config']
244
+ controller = ctx.obj['controller']
245
+
246
+ try:
247
+ pipeline = config.pipelines[pipeline]
248
+ except KeyError:
249
+ abort(f'Pipeline not found: {pipeline}')
250
+
251
+ if timestamp is not None and timestamp != 'now':
252
+ timestamp = dt.datetime.fromisoformat(timestamp)
253
+
254
+ try:
255
+ steps = get_steps(pipeline, steps)
256
+ execution_data = controller.run_pipeline(
257
+ pipeline.name,
258
+ steps=steps,
259
+ run_id=run_id,
260
+ timestamp=timestamp,
261
+ env=env,
262
+ build_env=build_env,
263
+ )
264
+ if stream_logs:
265
+ controller.print_logs(
266
+ run_id=execution_data['run_id'],
267
+ since=execution_data['start_timestamp'],
268
+ env=env,
269
+ stream=True,
270
+ )
271
+ except CloudSubmitError as e:
272
+ abort(str(e))
273
+
274
+
275
+ # images subgroup
276
+
277
+
278
+ @main.group('images', help='Manage docker images.')
279
+ def images():
280
+ pass
281
+
282
+
283
+ @images.command(
284
+ name='build',
285
+ help='Build one or more docker images.',
286
+ )
287
+ @click.option(
288
+ '--env', '-e',
289
+ type=str,
290
+ default=None,
291
+ help='The name of the build environment to use.',
292
+ )
293
+ @click.option(
294
+ '--build-id', '-b',
295
+ type=str,
296
+ default=None,
297
+ help=(
298
+ 'The ID for the build. This will be used as the image tag. '
299
+ 'Defaults to the current timestamp in YYYYMMDD-HHMMSS-XXXX format, '
300
+ 'where XXXX is a four-character UUID.'
301
+ ),
302
+ )
303
+ @click.option(
304
+ '--all', '-a', 'build_all',
305
+ is_flag=True,
306
+ help='Build all images.',
307
+ )
308
+ @click.argument('images', type=str, nargs=-1)
309
+ @click.pass_context
310
+ def build(ctx, env, build_id, build_all, images):
311
+ init(ctx)
312
+ config = ctx.obj['config']
313
+ controller = ctx.obj['controller']
314
+
315
+ for image in images:
316
+ if image not in config.images:
317
+ abort(f'No definition found for image {image}.')
318
+ if build_all:
319
+ images = list(config.images.keys())
320
+ try:
321
+ controller.build(images, build_id=build_id, env=env)
322
+ except CloudSubmitError as e:
323
+ abort(str(e))
324
+
325
+
326
+ @images.command(
327
+ name='list',
328
+ help='List the images associated with the project.',
329
+ )
330
+ @click.option(
331
+ '--env', '-e',
332
+ type=str,
333
+ default=None,
334
+ help='The name of the environment to use for listing images.',
335
+ )
336
+ @click.option(
337
+ '--images', '-i',
338
+ type=str,
339
+ default=None,
340
+ help=(
341
+ 'Comma-separated list of names for the images to show. '
342
+ 'If absent, all declared images are shown.'
343
+ ),
344
+ )
345
+ @click.option(
346
+ '--local', '-l',
347
+ is_flag=True,
348
+ help='Show local image refs.',
349
+ )
350
+ @click.option(
351
+ '--remote', '-r',
352
+ is_flag=True,
353
+ help='Show image refs in remote registry.',
354
+ )
355
+ @click.option(
356
+ '--build-ids', '-b',
357
+ type=str,
358
+ default=None,
359
+ help=(
360
+ 'Comma-separated list of build IDs. Only images '
361
+ 'with the given build IDs are shown.'
362
+ ),
363
+ )
364
+ @click.pass_context
365
+ def list_images(ctx, env, images, local, remote, build_ids):
366
+ if local and remote:
367
+ abort('The flags --local and --remote are mutually exclusive.')
368
+ init(ctx)
369
+ config = ctx.obj['config']
370
+ controller = ctx.obj['controller']
371
+
372
+ if images is not None:
373
+ images = list(set(images.split(',')))
374
+ if build_ids is not None:
375
+ build_ids = list(set(build_ids.split(',')))
376
+
377
+ if local or remote:
378
+ try:
379
+ results = controller.list_image_refs(
380
+ images=images,
381
+ ids=build_ids,
382
+ env=env,
383
+ remote=remote,
384
+ )
385
+ except CloudSubmitError as e:
386
+ abort(str(e))
387
+ for ref in results:
388
+ print(ref)
389
+ else:
390
+ data = controller.list_images(images=images)
391
+ data = [
392
+ (name, tpe, ('' if ref is None else ref.split('@')[0]))
393
+ for name, tpe, ref in data
394
+ ]
395
+ table = tabulate(data, header=['NAME', 'TYPE', 'REF'])
396
+ if table:
397
+ print(table)
398
+
399
+
400
+ @images.command(
401
+ name='remove',
402
+ help='Remove images associated with the project.',
403
+ )
404
+ @click.option(
405
+ '--env', '-e',
406
+ type=str,
407
+ default=None,
408
+ help='The name of the environment to use for listing images.',
409
+ )
410
+ @click.option(
411
+ '--images', '-i',
412
+ type=str,
413
+ default=None,
414
+ help=(
415
+ 'Comma-separated list of names for the images to remove. '
416
+ 'If absent, all declared images will be removed.'
417
+ ),
418
+ )
419
+ @click.option(
420
+ '--local', '-l',
421
+ is_flag=True,
422
+ help='Remove images only locally.',
423
+ )
424
+ @click.option(
425
+ '--remote', '-r',
426
+ is_flag=True,
427
+ help='Remove images only from remote registry.',
428
+ )
429
+ @click.option(
430
+ '--build-ids', '-b',
431
+ type=str,
432
+ default=None,
433
+ help=(
434
+ 'Comma-separated list of build IDs. Only images '
435
+ 'with the given build IDs are removed.'
436
+ ),
437
+ )
438
+ @click.pass_context
439
+ def remove_images(ctx, env, images, local, remote, build_ids):
440
+ if local and remote:
441
+ abort('The flags --local and --remote are mutually exclusive.')
442
+ init(ctx)
443
+ config = ctx.obj['config']
444
+ controller = ctx.obj['controller']
445
+
446
+ remove_local = True
447
+ remove_remote = True
448
+ if local:
449
+ remove_remote = False
450
+ if remote:
451
+ remove_local = False
452
+
453
+ if images is not None:
454
+ images = list(set(images.split(',')))
455
+ if build_ids is not None:
456
+ build_ids = list(set(build_ids.split(',')))
457
+
458
+ if remove_local:
459
+ try:
460
+ refs = controller.list_image_refs(
461
+ images=images,
462
+ ids=build_ids,
463
+ env=env,
464
+ remote=False,
465
+ )
466
+ controller.remove_image_refs(refs, remote=False, env=env)
467
+ except CloudSubmitError as e:
468
+ abort(str(e))
469
+ if remove_remote:
470
+ try:
471
+ refs = controller.list_image_refs(
472
+ images=images,
473
+ ids=build_ids,
474
+ env=env,
475
+ remote=True,
476
+ )
477
+ controller.remove_image_refs(refs, remote=True, env=env)
478
+ except CloudSubmitError as e:
479
+ abort(str(e))
480
+
481
+
482
+ @images.command(
483
+ name='set',
484
+ help='Set the image reference for image IMAGE to REF.'
485
+ )
486
+ @click.option(
487
+ '--env', '-e',
488
+ type=str,
489
+ default=None,
490
+ help='The name of the environment to use.',
491
+ )
492
+ @click.argument('image')
493
+ @click.argument('ref')
494
+ @click.pass_context
495
+ def set_image(ctx, env, image, ref):
496
+ init(ctx)
497
+ controller = ctx.obj['controller']
498
+ controller.set_image(image, ref, env=env)
499
+
500
+
501
+ @images.command(
502
+ name='unset',
503
+ help='Clear the image references for IMAGES.'
504
+ )
505
+ @click.option(
506
+ '--env', '-e',
507
+ type=str,
508
+ default=None,
509
+ help='The name of the environment to use.',
510
+ )
511
+ @click.option(
512
+ '--all', '-a', 'unset_all',
513
+ is_flag=True,
514
+ help='Clear all image references.',
515
+ )
516
+ @click.argument('images', type=str, nargs=-1)
517
+ @click.pass_context
518
+ def unset_image(ctx, env, unset_all, images):
519
+ init(ctx)
520
+ config = ctx.obj['config']
521
+ controller = ctx.obj['controller']
522
+ if unset_all:
523
+ images = list(config.images.keys())
524
+ for image in images:
525
+ controller.unset_image(image, env=env)
526
+
527
+
528
+ # artifacts subgroup
529
+
530
+
531
+ @main.group('artifacts', help='Manage artifacts.')
532
+ def artifacts():
533
+ pass
534
+
535
+
536
+ @artifacts.command(
537
+ name='list',
538
+ help="""List local or remote artifacts.
539
+
540
+ If neither --local nor --remote are specified this just lists the declared
541
+ artifacts for the project. Otherwise it lists the artifacts that are stored
542
+ locally or remotely (from different runs).
543
+ """,
544
+ )
545
+ @click.option(
546
+ '--env', '-e',
547
+ type=str,
548
+ default=None,
549
+ help='The name of the environment to use for listing artifacts.',
550
+ )
551
+ @click.option(
552
+ '--artifacts', '-a',
553
+ type=str,
554
+ default=None,
555
+ help=(
556
+ 'Comma-separated list of names for the artifacts to show. '
557
+ 'If absent, all declared artifacts are shown.'
558
+ ),
559
+ )
560
+ @click.option(
561
+ '--local',
562
+ is_flag=True,
563
+ help='Show local artifact paths.',
564
+ )
565
+ @click.option(
566
+ '--remote',
567
+ is_flag=True,
568
+ help='Show remote artifact paths.',
569
+ )
570
+ @click.option(
571
+ '--run-ids', '-r',
572
+ type=str,
573
+ default=None,
574
+ help=(
575
+ 'Comma-separated list of run IDs. Only artifacts associated with '
576
+ 'the given runs are shown.'
577
+ ),
578
+ )
579
+ @click.pass_context
580
+ def list_artifacts(ctx, env, artifacts, local, remote, run_ids):
581
+ if local and remote:
582
+ abort('The flags --local and --remote are mutually exclusive.')
583
+ init(ctx)
584
+ config = ctx.obj['config']
585
+ controller = ctx.obj['controller']
586
+
587
+ if artifacts is not None:
588
+ artifacts = sorted(set(artifacts.split(',')))
589
+ if run_ids is not None:
590
+ run_ids = sorted(set(run_ids.split(',')))
591
+
592
+ if local or remote:
593
+ try:
594
+ env_handler = config.get_run_env(env)
595
+ artifact_names, run_id_lists = controller.list_artifacts(
596
+ artifact_names=artifacts,
597
+ run_ids=run_ids,
598
+ env=env,
599
+ remote=remote,
600
+ )
601
+ data = []
602
+ for name, runs in zip(artifact_names, run_id_lists):
603
+ if name not in config.artifacts:
604
+ continue
605
+ artifact = config.artifacts[name]
606
+ for run_id in runs:
607
+ if local:
608
+ path = env_handler.get_local_artifact_path(
609
+ artifact, run_id)
610
+ else:
611
+ path = env_handler.get_remote_artifact_path(
612
+ artifact, run_id)
613
+ data.append((
614
+ name,
615
+ artifact.kind,
616
+ artifact.scope,
617
+ run_id,
618
+ path,
619
+ ))
620
+ except CloudSubmitError as e:
621
+ abort(str(e))
622
+ table = tabulate(
623
+ data,
624
+ header=['NAME', 'KIND', 'SCOPE', 'RUN_ID', 'PATH'],
625
+ )
626
+ if table:
627
+ print(table)
628
+ else:
629
+ data = [
630
+ (a.name, a.kind, a.scope)
631
+ for a in config.artifacts.values()
632
+ if artifacts is None or a.name in artifacts
633
+ ]
634
+ table = tabulate(data, header=['NAME', 'KIND', 'SCOPE'])
635
+ if table:
636
+ print(table)
637
+
638
+
639
+ @artifacts.command(
640
+ name='remove',
641
+ help='Remove artifacts.',
642
+ )
643
+ @click.option(
644
+ '--env', '-e',
645
+ type=str,
646
+ default=None,
647
+ help='The name of the environment to use for removing artifacts.',
648
+ )
649
+ @click.option(
650
+ '--artifacts', '-a', 'artifact_names',
651
+ type=str,
652
+ default=None,
653
+ help=(
654
+ 'Comma-separated list of names for the artifacts to remove. '
655
+ 'If absent, all declared artifacts will be removed.'
656
+ ),
657
+ )
658
+ @click.option(
659
+ '--local',
660
+ is_flag=True,
661
+ help='Remove artifacts only locally.',
662
+ )
663
+ @click.option(
664
+ '--remote',
665
+ is_flag=True,
666
+ help='Remove artifacts only from remote storage.',
667
+ )
668
+ @click.option(
669
+ '--run-ids', '-r',
670
+ type=str,
671
+ default=None,
672
+ help=(
673
+ 'Comma-separated list of run IDs. Only artifacts '
674
+ 'from the given runs are removed. If absent, artifacts from '
675
+ 'ALL runs are removed.'
676
+ ),
677
+ )
678
+ @click.pass_context
679
+ def remove_artifacts(ctx, env, artifact_names, local, remote, run_ids):
680
+ if local and remote:
681
+ abort('The flags --local and --remote are mutually exclusive.')
682
+ init(ctx)
683
+ config = ctx.obj['config']
684
+ controller = ctx.obj['controller']
685
+
686
+ remove_local = True
687
+ remove_remote = True
688
+ if local:
689
+ remove_remote = False
690
+ if remote:
691
+ remove_local = False
692
+
693
+ if artifact_names is not None:
694
+ artifact_names = list(set(artifact_names.split(',')))
695
+ if run_ids is not None:
696
+ run_ids = list(set(run_ids.split(',')))
697
+
698
+ with config.in_project_root():
699
+ if remove_local:
700
+ try:
701
+ artifact_names, run_id_lists = controller.list_artifacts(
702
+ artifact_names=artifact_names,
703
+ run_ids=run_ids,
704
+ env=env,
705
+ remote=False,
706
+ )
707
+ controller.remove_artifacts(
708
+ artifact_names, run_id_lists, remote=False, env=env)
709
+ except CloudSubmitError as e:
710
+ abort(str(e))
711
+ if remove_remote:
712
+ try:
713
+ artifact_names, run_id_lists = controller.list_artifacts(
714
+ artifact_names=artifact_names,
715
+ run_ids=run_ids,
716
+ env=env,
717
+ remote=True,
718
+ )
719
+ controller.remove_artifacts(
720
+ artifact_names, run_id_lists, remote=True, env=env)
721
+ except CloudSubmitError as e:
722
+ abort(str(e))
723
+
724
+
725
+ @artifacts.command(
726
+ name='pull',
727
+ help='Download artifacts from remote storage.',
728
+ )
729
+ @click.option(
730
+ '--env', '-e',
731
+ type=str,
732
+ default=None,
733
+ help='The name of the environment to use for pulling artifacts.',
734
+ )
735
+ @click.option(
736
+ '--artifacts', '-a', 'artifact_names',
737
+ type=str,
738
+ default=None,
739
+ help=(
740
+ 'Comma-separated list of names for the artifacts to pull. '
741
+ 'If absent, all declared artifacts will be pulled.'
742
+ ),
743
+ )
744
+ @click.option(
745
+ '--run-ids', '-r',
746
+ type=str,
747
+ default=None,
748
+ help=(
749
+ 'Comma-separated list of run IDs. Only artifacts '
750
+ 'from the given runs are pulled. If absent, artifacts from '
751
+ 'ALL runs are pulled.'
752
+ ),
753
+ )
754
+ @click.pass_context
755
+ def pull_artifacts(ctx, env, artifact_names, run_ids):
756
+ init(ctx)
757
+ config = ctx.obj['config']
758
+ controller = ctx.obj['controller']
759
+
760
+ if artifact_names is not None:
761
+ artifact_names = list(set(artifact_names.split(',')))
762
+ if run_ids is not None:
763
+ run_ids = list(set(run_ids.split(',')))
764
+
765
+ artifact_names, run_id_lists = controller.list_artifacts(
766
+ artifact_names=artifact_names,
767
+ run_ids=run_ids,
768
+ env=env,
769
+ remote=True,
770
+ )
771
+ try:
772
+ controller.pull_artifacts(artifact_names, run_id_lists)
773
+ except CloudSubmitError as e:
774
+ abort(str(e))
775
+
776
+
777
+ @artifacts.command(
778
+ name='push',
779
+ help='Upload artifacts to remote storage.',
780
+ )
781
+ @click.option(
782
+ '--env', '-e',
783
+ type=str,
784
+ default=None,
785
+ help='The name of the environment to use for pushing artifacts.',
786
+ )
787
+ @click.option(
788
+ '--artifacts', '-a', 'artifact_names',
789
+ type=str,
790
+ default=None,
791
+ help=(
792
+ 'Comma-separated list of names for the artifacts to push. '
793
+ 'If absent, all declared artifacts will be pushed.'
794
+ ),
795
+ )
796
+ @click.option(
797
+ '--run-ids', '-r',
798
+ type=str,
799
+ default=None,
800
+ help=(
801
+ 'Comma-separated list of run IDs. Only artifacts '
802
+ 'from the given runs are pushed. If absent, artifacts from '
803
+ 'ALL runs are pushed.'
804
+ ),
805
+ )
806
+ @click.pass_context
807
+ def push_artifacts(ctx, env, artifact_names, run_ids):
808
+ init(ctx)
809
+ config = ctx.obj['config']
810
+ controller = ctx.obj['controller']
811
+
812
+ if artifact_names is not None:
813
+ artifact_names = list(set(artifact_names.split(',')))
814
+ if run_ids is not None:
815
+ run_ids = list(set(run_ids.split(',')))
816
+
817
+ artifact_names, run_id_lists = controller.list_artifacts(
818
+ artifact_names=artifact_names,
819
+ run_ids=run_ids,
820
+ env=env,
821
+ remote=False,
822
+ )
823
+ try:
824
+ controller.push_artifacts(artifact_names, run_id_lists)
825
+ except CloudSubmitError as e:
826
+ abort(str(e))
827
+
828
+
829
+ @artifacts.command(
830
+ name='copy',
831
+ help='Copy artifacts from FROM_RUN_ID to TO_RUN_ID.',
832
+ )
833
+ @click.option(
834
+ '--env', '-e',
835
+ type=str,
836
+ default=None,
837
+ help='The name of the environment to use for copying artifacts.',
838
+ )
839
+ @click.option(
840
+ '--artifacts', '-a', 'artifact_names',
841
+ type=str,
842
+ default=None,
843
+ help=(
844
+ 'Comma-separated list of names for the artifacts to copy. '
845
+ 'If absent, all declared artifacts will be copied.'
846
+ ),
847
+ )
848
+ @click.option(
849
+ '--local',
850
+ is_flag=True,
851
+ help='Copy artifacts only locally.',
852
+ )
853
+ @click.option(
854
+ '--remote',
855
+ is_flag=True,
856
+ help='Copy artifacts only in remote storage.',
857
+ )
858
+ @click.argument('from_run_id', type=str)
859
+ @click.argument('to_run_id', type=str)
860
+ @click.pass_context
861
+ def copy_artifacts(
862
+ ctx,
863
+ env,
864
+ artifact_names,
865
+ local,
866
+ remote,
867
+ from_run_id,
868
+ to_run_id
869
+ ):
870
+ if local and remote:
871
+ abort('The flags --local and --remote are mutually exclusive.')
872
+ init(ctx)
873
+ config = ctx.obj['config']
874
+ controller = ctx.obj['controller']
875
+
876
+ copy_local = True
877
+ copy_remote = True
878
+ if local:
879
+ copy_remote = False
880
+ if remote:
881
+ copy_local = False
882
+
883
+ if artifact_names is None:
884
+ artifact_names = list(config.artifacts.keys())
885
+ else:
886
+ artifact_names = list(set(artifact_names.split(',')))
887
+
888
+ try:
889
+ if copy_local:
890
+ controller.copy_artifacts(
891
+ artifact_names, from_run_id, to_run_id, remote=False, env=env)
892
+ if copy_remote:
893
+ controller.copy_artifacts(
894
+ artifact_names, from_run_id, to_run_id, remote=True, env=env)
895
+ except CloudSubmitError as e:
896
+ abort(str(e))
897
+
898
+
899
+ @artifacts.command(
900
+ name='move',
901
+ help='Move artifacts from FROM_RUN_ID to TO_RUN_ID.',
902
+ )
903
+ @click.option(
904
+ '--env', '-e',
905
+ type=str,
906
+ default=None,
907
+ help='The name of the environment to use for moving artifacts.',
908
+ )
909
+ @click.option(
910
+ '--artifacts', '-a', 'artifact_names',
911
+ type=str,
912
+ default=None,
913
+ help=(
914
+ 'Comma-separated list of names for the artifacts to move. '
915
+ 'If absent, all declared artifacts will be moved.'
916
+ ),
917
+ )
918
+ @click.option(
919
+ '--local',
920
+ is_flag=True,
921
+ help='Move artifacts only locally.',
922
+ )
923
+ @click.option(
924
+ '--remote',
925
+ is_flag=True,
926
+ help='Move artifacts only in remote storage.',
927
+ )
928
+ @click.argument('from_run_id', type=str)
929
+ @click.argument('to_run_id', type=str)
930
+ @click.pass_context
931
+ def move_artifacts(
932
+ ctx,
933
+ env,
934
+ artifact_names,
935
+ local,
936
+ remote,
937
+ from_run_id,
938
+ to_run_id
939
+ ):
940
+ if local and remote:
941
+ abort('The flags --local and --remote are mutually exclusive.')
942
+ init(ctx)
943
+ config = ctx.obj['config']
944
+ controller = ctx.obj['controller']
945
+
946
+ move_local = True
947
+ move_remote = True
948
+ if local:
949
+ move_remote = False
950
+ if remote:
951
+ move_local = False
952
+
953
+ if artifact_names is None:
954
+ artifact_names = list(config.artifacts.keys())
955
+ else:
956
+ artifact_names = list(set(artifact_names.split(',')))
957
+
958
+ try:
959
+ if move_local:
960
+ controller.move_artifacts(
961
+ artifact_names, from_run_id, to_run_id, remote=False, env=env)
962
+ if move_remote:
963
+ controller.move_artifacts(
964
+ artifact_names, from_run_id, to_run_id, remote=True, env=env)
965
+ except CloudSubmitError as e:
966
+ abort(str(e))
967
+
968
+
969
+ # logs command
970
+
971
+
972
+ @main.command(
973
+ name='logs',
974
+ help='Show logs from run RUN_ID.',
975
+ )
976
+ @click.option(
977
+ '--env', '-e',
978
+ type=str,
979
+ default=None,
980
+ help='The name of the build environment to use.',
981
+ )
982
+ @click.option(
983
+ '--since',
984
+ type=str,
985
+ default='1m',
986
+ help=(
987
+ 'The time from which to retrieve the logs. This should be a timestamp '
988
+ 'in ISO format, e.g. 2025-01-01T00:00:00. You can also pass a number '
989
+ 'N suffixed h, m, or s to retrieve logs from the last N hours, minutes '
990
+ 'or seconds.'
991
+ ),
992
+ )
993
+ @click.option('--stream', '-s', is_flag=True)
994
+ @click.argument('run_id', type=str)
995
+ @click.pass_context
996
+ def print_logs(ctx, env, since, stream, run_id):
997
+ init(ctx)
998
+ controller = ctx.obj['controller']
999
+
1000
+ try:
1001
+ if since.endswith('h'):
1002
+ since = dt.timedelta(seconds=float(since[:-1])*3600)
1003
+ elif since.endswith('m'):
1004
+ since = dt.timedelta(seconds=float(since[:-1])*60)
1005
+ elif since.endswith('s'):
1006
+ since = dt.timedelta(seconds=float(since[:-1]))
1007
+ else:
1008
+ since = dt.datetime.fromisoformat(since)
1009
+ except ValueError:
1010
+ abort(f'Invalid --since argument: {since}')
1011
+ try:
1012
+ controller.print_logs(run_id, since=since, env=env, stream=stream)
1013
+ except CloudSubmitError as e:
1014
+ abort(str(e))