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.
@@ -0,0 +1,398 @@
1
+ import os
2
+ import sys
3
+ import datetime as dt
4
+ import uuid
5
+ import subprocess
6
+ import glob
7
+ import shutil
8
+ import errno
9
+
10
+ from .execution.utils import (
11
+ CloudSubmitError,
12
+ run_command,
13
+ ensure_path,
14
+ clear_path,
15
+ )
16
+ from .images import BaseImage, ExecutionImage
17
+ from .execution.config import to_utc
18
+
19
+
20
+ class EnvironmentHandler:
21
+ def __init__(
22
+ self,
23
+ name,
24
+ project,
25
+ user,
26
+ docker_command='docker',
27
+ docker_registry=None,
28
+ docker_namespace='csub',
29
+ ):
30
+ self.name = name
31
+ self._project = project
32
+ self._user = user
33
+ self._docker_command = str(docker_command)
34
+ self._docker_registry = docker_registry
35
+ self._docker_namespace = docker_namespace
36
+
37
+ def _generate_id(self, timestamp, id):
38
+ if id is not None:
39
+ return id
40
+ uid = str(uuid.uuid4())
41
+ timestamp=to_utc(timestamp)
42
+ return timestamp.strftime('%Y%m%d-%H%M%S-') + uid[:4]
43
+
44
+ def generate_build_id(self, timestamp, build_id=None):
45
+ """Generate an ID for an image build.
46
+
47
+ Args:
48
+ timestamp (datetime): The current date and time.
49
+ build_id (str, optional): The build ID to return. A new unique ID
50
+ is only generated when `build_id` is ``None``.
51
+
52
+ Returns:
53
+ generated_build_id (str): Either `build_id` when `build_id` was
54
+ not ``None`` or a new unique ID generated based on `timestamp`.
55
+ """
56
+ return self._generate_id(timestamp, build_id)
57
+
58
+ def generate_run_id(self, timestamp, run_id=None):
59
+ """Generate an ID for a pipeline run.
60
+
61
+ Args:
62
+ timestamp (datetime): The current date and time.
63
+ run_id (str, optional): The run ID to return. A new unique ID
64
+ is only generated when `run_id` is ``None``.
65
+
66
+ Returns:
67
+ generated_run_id (str): Either `run_id` when `run_id` was
68
+ not ``None`` or a new unique ID generated based on `timestamp`.
69
+ """
70
+ return self._generate_id(timestamp, run_id)
71
+
72
+ def install_execution_handler(self, path):
73
+ """Install the execution handler under `path`.
74
+
75
+ Note:
76
+ Derived classes must implement this. They may assume that `path`
77
+ already exists and is a directory. This method must put a Python
78
+ module called ``execution_handler.py`` under `path`. That module
79
+ must define a function called ``create_execution_handler`` which
80
+ takes no arguments and returns an instance of ``BaseExecutionHandler``
81
+ (see below) or of a subclass thereof. When a cloud-submit container is
82
+ executed the ``create_execution_hander`` function is called and the
83
+ returned object is used to perform environment-specific operations
84
+ like uploading or downloading artifacts.
85
+
86
+ Typically, your ``execution_handler.py`` module will define a
87
+ subclass of ``BaseExecutionHandler`` and the
88
+ ``create_execution_hander`` function will return an instance of that
89
+ subclass. You may install additional modules or config files under
90
+ `path` if you wish. For example, you may want to include a
91
+ ``config.json`` file which holds additional config parameters like
92
+ the S3 bucket where you're artifacts are stored.
93
+
94
+ A few core cloud-submit modules and config files already exist in
95
+ `path` and should not be overwritten (unless you know what you're
96
+ doing). These files are:
97
+
98
+ base_execution_handler.py
99
+ A Python module providing the base class for all execution
100
+ handlers (``BaseExecutionHandler``).
101
+ config.py
102
+ A Python module providing classes commonly found in a cloud-submit
103
+ project configuration (e.g. ``Pipeline``, ``Artifact``,
104
+ ``ArtifactLocation`` or ``Step``).
105
+ execute.py
106
+ The Python module that serves as the main entrypoint of every
107
+ execution image.
108
+ utils.py
109
+ A Python module which provides some of the utility functions
110
+ from the ``cloud_submit`` package. (E.g. ``run_command``,
111
+ ``read_json`` or ``write_json``.)
112
+ artifacts.json
113
+ A JSON file with information about the artifacts defined for
114
+ the project.
115
+ pipelines.json
116
+ A JSON file with information about the pipelines defined for
117
+ the project.
118
+
119
+ Of course your ``execution_handler.py`` module (or any other modules
120
+ you install) may access the pre-installed modules via relative
121
+ imports.
122
+
123
+ Args:
124
+ path (str): The directory in which the execution handler should be
125
+ installed.
126
+ """
127
+ raise NotImplementedError
128
+
129
+ def _get_image_ref_path(self, image):
130
+ if isinstance(image, BaseImage):
131
+ image_name = image.name
132
+ elif isinstance(image, ExecutionImage):
133
+ image_name = '.'.join([image.name, self.name])
134
+ else:
135
+ raise ValueError(f'Cannot handle image of type {type(image)}')
136
+
137
+ return os.path.join('images', self._user, image_name)
138
+
139
+ def get_image_ref(self, image):
140
+ ensure_path(os.path.join('images', self._user))
141
+ path = self._get_image_ref_path(image)
142
+ try:
143
+ with open(path, 'r') as stream:
144
+ ref = stream.read().strip()
145
+ except FileNotFoundError:
146
+ return None
147
+ return ref
148
+
149
+ def save_image_ref(self, image, ref):
150
+ ensure_path(os.path.join('images', self._user))
151
+ path = self._get_image_ref_path(image)
152
+ with open(path, 'w') as stream:
153
+ stream.write(ref)
154
+
155
+ def clear_image_ref(self, image):
156
+ ensure_path(os.path.join('images', self._user))
157
+ path = self._get_image_ref_path(image)
158
+ try:
159
+ os.remove(path)
160
+ except FileNotFoundError:
161
+ pass
162
+
163
+ def get_image_repo_name(self, image):
164
+ prefix = []
165
+ if self._docker_registry is not None:
166
+ prefix.append(self._docker_registry)
167
+ else:
168
+ prefix.append('localhost')
169
+ if self._docker_namespace is not None:
170
+ prefix.append(self._docker_namespace)
171
+ prefix = '/'.join(prefix)
172
+
173
+ if isinstance(image, BaseImage):
174
+ return (
175
+ f'{prefix}'
176
+ f'/{self._project}'
177
+ f'/{self._user}'
178
+ f'/{image.name}'
179
+ )
180
+ elif isinstance(image, ExecutionImage):
181
+ return (
182
+ f'{prefix}'
183
+ f'/{self._project}'
184
+ f'/{self._user}'
185
+ f'/{image.name}'
186
+ f'.{self.name}'
187
+ )
188
+
189
+ def pull_base_image(self, ref):
190
+ """Pull an image from the remote registry.
191
+
192
+ Note:
193
+ Derived classes usually need to overload this to handle authentication
194
+ with the remote registry. In a local environment where there is no
195
+ remote registry this function should check if the requested image
196
+ exists locally and raise a ``CloudSubmitError`` if it does not.
197
+ This function is never called for execution images.
198
+
199
+ Args:
200
+ ref (str): The full docker image reference for the requested image.
201
+ """
202
+ run_command([
203
+ self._docker_command,
204
+ 'pull',
205
+ ref,
206
+ ])
207
+
208
+ def list_local_image_tags(self, repo_name):
209
+ """List locally available tags for a docker image repo.
210
+
211
+ Args:
212
+ repo_name (str): The fully qualified name of the docker image repo.
213
+
214
+ Returns:
215
+ tags (list of str): The list of tags that are available locally.
216
+ """
217
+ result = run_command(
218
+ [
219
+ self._docker_command,
220
+ 'image',
221
+ 'list',
222
+ '--format', '{{.Tag}}',
223
+ repo_name,
224
+ ],
225
+ stdout=subprocess.PIPE,
226
+ text=True,
227
+ )
228
+ result = result.stdout.strip()
229
+ if not result:
230
+ return []
231
+ return result.split('\n')
232
+
233
+ def list_remote_image_tags(self, repo_name):
234
+ """List remotely available tags for a docker image repo.
235
+
236
+ Note:
237
+ Derived classes must overload this to handle communication with the
238
+ remote registry.
239
+
240
+ Args:
241
+ repo_name (str): The fully qualified name of the docker image repo.
242
+
243
+ Returns:
244
+ tags (list of str): The list of the tags that are available in the
245
+ remote registry.
246
+ """
247
+ raise NotImplementedError
248
+
249
+ def remove_local_image_refs(self, refs):
250
+ run_command([
251
+ self._docker_command,
252
+ 'image',
253
+ 'remove',
254
+ *refs,
255
+ ])
256
+
257
+ def remove_remote_image_refs(self, refs):
258
+ raise NotImplementedError
259
+
260
+ def build_image(self, path, image, build_id):
261
+ repo = self.get_image_repo_name(image)
262
+ ref = ':'.join([repo, build_id])
263
+ run_command([
264
+ self._docker_command,
265
+ 'build',
266
+ '-t', ref,
267
+ path,
268
+ ])
269
+ return ref
270
+
271
+ def get_local_artifact_path(self, artifact, run_id=None):
272
+ if artifact.kind != 'file':
273
+ raise CloudSubmitError(
274
+ f'Cannot get path for local artifact {artifact.name}. '
275
+ "Only artifacts of kind 'file' are supported and this one "
276
+ f'is of kind {repr(artifact.kind)}.'
277
+ )
278
+ if artifact.scope == 'project':
279
+ return os.path.join('artifacts', 'shared', artifact.name)
280
+ elif artifact.scope == 'user':
281
+ return os.path.join(
282
+ 'artifacts', 'users', self._user, 'shared', artifact.name)
283
+ elif artifact.scope == 'run':
284
+ if run_id is None:
285
+ raise ValueError(
286
+ 'You must specify `run_id` to get the path '
287
+ "for an artifact with scope 'run'"
288
+ )
289
+ return os.path.join(
290
+ 'artifacts', 'users', self._user, 'runs', run_id, artifact.name)
291
+ else:
292
+ raise CloudSubmitError(
293
+ f'Unknown scope {artifact.scope} for artifact {artifact.name}.')
294
+
295
+ def get_remote_artifact_path(self, artifact, run_id=None):
296
+ raise NotImplementedError
297
+
298
+ def list_local_artifacts(self, artifacts, run_ids=None):
299
+ if run_ids is not None:
300
+ run_ids = set(run_ids)
301
+
302
+ results = []
303
+ for artifact in artifacts:
304
+ if artifact.kind != 'file':
305
+ raise CloudSubmitError(
306
+ f'Cannot list run IDs for local artifact {artifact.name}. '
307
+ "Only artifacts of kind 'file' are supported and this one "
308
+ f'is of kind {repr(artifact.kind)}.'
309
+ )
310
+ if artifact.scope == 'run':
311
+ pattern = os.path.join(
312
+ 'artifacts', 'users', self._user,
313
+ 'runs', '*', artifact.name,
314
+ )
315
+ files = glob.glob(pattern)
316
+ ids = [os.path.basename(os.path.dirname(f)) for f in files]
317
+ if run_ids is not None:
318
+ ids = [i for i in ids if i in run_ids]
319
+ elif artifact.scope == 'user':
320
+ pattern = os.path.join(
321
+ 'artifacts', 'users', self._user, 'shared', artifact.name)
322
+ files = glob.glob(pattern)
323
+ if files:
324
+ ids = [None]
325
+ else:
326
+ ids = []
327
+ elif artifact.scope == 'project':
328
+ pattern = os.path.join(
329
+ 'artifacts', 'shared', artifact.name)
330
+ files = glob.glob(pattern)
331
+ if files:
332
+ ids = [None]
333
+ else:
334
+ ids = []
335
+ results.append(ids)
336
+ return results
337
+
338
+ def list_remote_artifacts(self, artifacts, run_ids=None):
339
+ raise NotImplementedError
340
+
341
+ def push_artifacts(self, artifacts, run_ids):
342
+ raise NotImplementedError
343
+
344
+ def pull_artifacts(self, artifacts, run_ids):
345
+ raise NotImplementedError
346
+
347
+ def remove_local_artifacts(self, artifacts, run_ids):
348
+ for artifact, runs in zip(artifacts, run_ids):
349
+ if artifact.kind != 'file':
350
+ continue
351
+ for run_id in runs:
352
+ path = self.get_local_artifact_path(artifact, run_id)
353
+ clear_path(path)
354
+
355
+ def remove_remote_artifacts(self, artifacts, run_ids):
356
+ raise NotImplementedError
357
+
358
+ def copy_local_artifacts(self, artifacts, from_run_id, to_run_id):
359
+ for artifact in artifacts:
360
+ src_path = self.get_local_artifact_path(artifact, from_run_id)
361
+ dst_path = self.get_local_artifact_path(artifact, to_run_id)
362
+ if os.path.exists(src_path):
363
+ ensure_path(os.path.dirname(dst_path))
364
+ try:
365
+ shutil.copytree(src_path, dst_path)
366
+ except OSError as e:
367
+ if e.errno in (errno.ENOTDIR, errno.EINVAL):
368
+ shutil.copy(src_path, dst_path)
369
+ else:
370
+ raise
371
+
372
+ def copy_remote_artifacts(self, artifacts, from_run_id, to_run_id):
373
+ raise NotImplementedError
374
+
375
+ def move_local_artifacts(self, artifacts, from_run_id, to_run_id):
376
+ for artifact in artifacts:
377
+ src_path = self.get_local_artifact_path(artifact, from_run_id)
378
+ dst_path = self.get_local_artifact_path(artifact, to_run_id)
379
+ if os.path.exists(src_path):
380
+ ensure_path(os.path.dirname(dst_path))
381
+ shutil.move(src_path, dst_path)
382
+
383
+ def move_remote_artifacts(self, artifacts, from_run_id, to_run_id):
384
+ raise NotImplementedError
385
+
386
+ def run_pipeline(
387
+ self,
388
+ pipeline,
389
+ image_refs,
390
+ overwrite_artifacts,
391
+ timestamp,
392
+ run_id,
393
+ temp_path
394
+ ):
395
+ raise NotImplementedError
396
+
397
+ def print_logs(self, run_id, start_timestamp, stream=False):
398
+ raise NotImplementedError
@@ -0,0 +1,2 @@
1
+ from .local_environment_handler import LocalAWSEnv
2
+ from .remote_environment_handler import RemoteAWSEnv