snakemake-executor-plugin-aws-batch-flex 0.1.0__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.
Files changed (30) hide show
  1. snakemake_executor_plugin_aws_batch_flex/__init__.py +8 -0
  2. snakemake_executor_plugin_aws_batch_flex/bootstrap.py +376 -0
  3. snakemake_executor_plugin_aws_batch_flex/bundle.py +142 -0
  4. snakemake_executor_plugin_aws_batch_flex/check.py +129 -0
  5. snakemake_executor_plugin_aws_batch_flex/condacache.py +184 -0
  6. snakemake_executor_plugin_aws_batch_flex/executor.py +725 -0
  7. snakemake_executor_plugin_aws_batch_flex/headjob.py +244 -0
  8. snakemake_executor_plugin_aws_batch_flex/hints.py +75 -0
  9. snakemake_executor_plugin_aws_batch_flex/hostenv.py +221 -0
  10. snakemake_executor_plugin_aws_batch_flex/imageinfo.py +207 -0
  11. snakemake_executor_plugin_aws_batch_flex/jobdefs.py +221 -0
  12. snakemake_executor_plugin_aws_batch_flex/jobfixes.py +65 -0
  13. snakemake_executor_plugin_aws_batch_flex/jobmonitor.py +146 -0
  14. snakemake_executor_plugin_aws_batch_flex/naming.py +16 -0
  15. snakemake_executor_plugin_aws_batch_flex/orphans.py +51 -0
  16. snakemake_executor_plugin_aws_batch_flex/patches.py +196 -0
  17. snakemake_executor_plugin_aws_batch_flex/preflight.py +165 -0
  18. snakemake_executor_plugin_aws_batch_flex/profiles.py +98 -0
  19. snakemake_executor_plugin_aws_batch_flex/remote.py +409 -0
  20. snakemake_executor_plugin_aws_batch_flex/settings.py +482 -0
  21. snakemake_executor_plugin_aws_batch_flex/sizing.py +75 -0
  22. snakemake_executor_plugin_aws_batch_flex/sources.py +83 -0
  23. snakemake_executor_plugin_aws_batch_flex/tags.py +76 -0
  24. snakemake_executor_plugin_aws_batch_flex/transfer.py +193 -0
  25. snakemake_executor_plugin_aws_batch_flex/usage.py +208 -0
  26. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/METADATA +343 -0
  27. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/RECORD +30 -0
  28. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/WHEEL +4 -0
  29. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/entry_points.txt +3 -0
  30. snakemake_executor_plugin_aws_batch_flex-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,409 @@
1
+ """smkflex: run a whole workflow on AWS Batch, so nothing has to stay running on your machine.
2
+
3
+ `smkflex run` packs the workflow folder and the profile, uploads them next to the workflow's
4
+ files in storage, and submits one "head job" that runs `snakemake` with this plugin. Snakemake's
5
+ .snakemake state (what was made with which code, unfinished jobs) is saved to storage at the end
6
+ of each run and restored at the start of the next, so reruns behave as they do locally.
7
+
8
+ Optional: `snakemake --executor aws-batch-flex` from your own machine works as before."""
9
+
10
+ import argparse
11
+ import hashlib
12
+ import os
13
+ import re
14
+ import shutil
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ from datetime import datetime, timezone
19
+
20
+ import yaml
21
+ from botocore.exceptions import BotoCoreError, ClientError
22
+ from snakemake_interface_common.exceptions import WorkflowError
23
+
24
+ from . import headjob
25
+ from .bundle import _tag, added_files, clone, head_command, local_commit, make_bundle
26
+ from .check import cmd_check
27
+ from .imageinfo import Entrypoints
28
+ from .jobdefs import JobDefinitions, log_group_of
29
+ from .naming import batch_job_name
30
+ from .orphans import active_jobs, describe_jobs, list_jobs
31
+ from .preflight import check_queue, queue_uses_spot
32
+ from .profiles import Fail, _clients, _load_profile, _Log, workflow_profile_file
33
+ from .settings import from_profile, host_mounts, registry_secrets
34
+ from .sizing import fargate_size
35
+ from .tags import MANAGED_BY
36
+ from .transfer import cmd_pull, cmd_push
37
+
38
+ HEAD_IMAGE = "buildpack-deps:bookworm-scm"
39
+
40
+
41
+ def _active_heads(batch, queue, state_key):
42
+ return [d["jobId"] for d in active_jobs(batch, queue) if (d.get("tags") or {}).get("smkflex-state") == state_key]
43
+
44
+ def _check_snakemake_args(args):
45
+ """Fail on options Snakemake doesn't know (a typo in an smkflex option such as --wiat
46
+ ends up here too), before anything is uploaded or submitted."""
47
+ try:
48
+ from snakemake.cli import get_argument_parser
49
+ except ImportError:
50
+ return
51
+ try:
52
+ _ns, unknown = get_argument_parser().parse_known_args(args)
53
+ except SystemExit:
54
+ raise Fail("snakemake can't use these arguments (see above)") from None
55
+ unknown = [u for u in unknown if u.startswith("-")]
56
+ if unknown:
57
+ raise Fail(f"neither smkflex run nor snakemake has the option(s) {' '.join(unknown)} "
58
+ "(see smkflex run --help and snakemake --help)")
59
+
60
+ def cmd_run(a, snakemake_args):
61
+ _check_snakemake_args(snakemake_args)
62
+ tmp = None
63
+ try:
64
+ if a.git:
65
+ tmp = tempfile.mkdtemp(prefix="smkflex-git-")
66
+ commit = clone(a.git, a.rev, os.path.join(tmp, "repo"))
67
+ root = os.path.join(tmp, "repo", a.path or "")
68
+ if not os.path.isdir(root):
69
+ raise Fail(f"{a.path} isn't a folder in {a.git}")
70
+ print(f"Cloned {a.git} at {commit[:12]}" + (f" ({a.rev})" if a.rev else ""), file=sys.stderr)
71
+ extra = added_files(a.add or [])
72
+ source = {"smkflex-source": _tag(a.git + (f" {a.rev}" if a.rev else "")),
73
+ "smkflex-commit": commit[:12] + ("+added" if extra else "")}
74
+ default_name = re.sub(r"\.git$", "", a.git.rstrip("/").rsplit("/", 1)[-1])
75
+ else:
76
+ if a.rev or a.path:
77
+ raise Fail("--rev and --path go with --git")
78
+ root = "."
79
+ extra = added_files(a.add or [])
80
+ commit = local_commit(root)
81
+ source = {"smkflex-commit": commit + ("+added" if extra else "")} if commit else {}
82
+ default_name = os.path.basename(os.path.abspath("."))
83
+ if extra:
84
+ print(f"Adding {len(extra)} local file(s): " + ", ".join(sorted(extra)[:5])
85
+ + (" ..." if len(extra) > 5 else ""), file=sys.stderr)
86
+ return _run(a, snakemake_args, root, source, default_name, extra)
87
+ finally:
88
+ if tmp:
89
+ shutil.rmtree(tmp, ignore_errors=True)
90
+
91
+ def _run(a, snakemake_args, root, source, default_name, extra=None):
92
+ profile_dir, profile = _load_profile(a.profile)
93
+ s = from_profile(profile)
94
+ queue = a.queue or s.queue
95
+ prefix = str(profile.get("default-storage-prefix") or "").rstrip("/")
96
+ role = a.role or s.head_role_arn
97
+ if not queue:
98
+ raise Fail("no queue: set aws-batch-flex-queue in the profile or pass --queue")
99
+ if not prefix or profile.get("default-storage-provider") in (None, "local"):
100
+ raise Fail("smkflex needs remote storage (default-storage-provider and default-storage-prefix "
101
+ "in the profile): the head job's disk is gone when it ends")
102
+ if not role:
103
+ raise Fail("no role for the head job: set aws-batch-flex-head-role-arn in the profile (Terraform "
104
+ "prints it as head_role_arn) or pass --role")
105
+ name = re.sub(r"[^A-Za-z0-9_-]", "-", a.name or default_name)
106
+ # One project (storage prefix) has one state and one run at a time, as Snakemake has one
107
+ # working directory with one lock: two runs would make and overwrite the same outputs.
108
+ state_url = f"{prefix}/.smkflex/state.tar.gz"
109
+ state_key = hashlib.sha256(state_url.encode()).hexdigest()[:16]
110
+ batch, _logs, region = _clients(profile)
111
+
112
+ errors, warnings, detected = check_queue(batch, queue, s.platform)
113
+ for w in warnings:
114
+ print(f"warning: {w}", file=sys.stderr)
115
+ if errors:
116
+ raise Fail("; ".join(errors))
117
+ platform = s.platform or detected or "ec2"
118
+ if queue_uses_spot(batch, queue):
119
+ print(f"warning: queue {queue} uses Spot instances; if AWS takes the head job's instance back, "
120
+ "the whole run stops. Use an on-demand queue for it (--queue).", file=sys.stderr)
121
+ running = _active_heads(batch, queue, state_key)
122
+ if running:
123
+ raise Fail(f"a run of this project ({prefix}) is still going: head job {running[0]}. Wait for "
124
+ f"it or `smkflex cancel {running[0]}` (finished outputs are kept), then run again. "
125
+ "Another set of samples is another project: `smkflex init` in its own folder.")
126
+
127
+ bundle = make_bundle(root, profile_dir, a.max_bundle_mb, extra)
128
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
129
+ bundle_url = f"{prefix}/.smkflex/runs/{stamp}/workflow.tar.gz"
130
+ headjob.put(bundle_url, bundle)
131
+ print(f"Uploaded the workflow ({len(bundle) // 1024} KiB) to {bundle_url}", file=sys.stderr)
132
+
133
+ jobdefs = JobDefinitions(
134
+ batch, _Log(),
135
+ prefix=s.job_definition_prefix,
136
+ platform=platform,
137
+ job_role_arn=role,
138
+ execution_role_arn=s.execution_role_arn,
139
+ log_group=s.log_group,
140
+ assign_public_ip=s.assign_public_ip,
141
+ ephemeral_storage_gib=None,
142
+ tags={},
143
+ mounts=host_mounts(s) if platform == "ec2" else [],
144
+ )
145
+ # Debian 12 with git: Snakemake needs git for pipelines loaded from GitHub (snakedeploy's)
146
+ image = a.image or HEAD_IMAGE
147
+ jobdef = jobdefs.ensure(image, platform)
148
+ vcpu, mem = str(a.vcpus), a.mem_mb
149
+ if platform == "fargate":
150
+ vcpu, mem = fargate_size(a.vcpus, a.mem_mb)
151
+ env = [{"name": "AWS_REGION", "value": region}, {"name": "AWS_DEFAULT_REGION", "value": region}] if region else []
152
+ env.append({"name": "SMKFLEX_BUNDLE_URL", "value": bundle_url})
153
+ resp = batch.submit_job(
154
+ jobName=batch_job_name(f"smkflex-{name}"),
155
+ jobQueue=queue,
156
+ jobDefinition=jobdef,
157
+ containerOverrides={
158
+ "command": Entrypoints(registry_secrets(s), _Log()).command(
159
+ image, head_command(profile, platform, bundle_url, state_url, snakemake_args)),
160
+ "resourceRequirements": [{"type": "VCPU", "value": vcpu}, {"type": "MEMORY", "value": str(mem)}],
161
+ "environment": env,
162
+ },
163
+ tags={"smkflex-workflow": name[:256], "smkflex-state": state_key, **source, **MANAGED_BY},
164
+ propagateTags=True,
165
+ )
166
+ jid = resp["jobId"]
167
+ at = f" at {source['smkflex-commit']}" if source.get("smkflex-commit") else ""
168
+ print(f"Submitted workflow '{name}'{at} as head job {jid} (queue {queue}, {platform}).")
169
+ if a.wait:
170
+ return follow(batch, _logs, jid, follow_logs=True)
171
+ print(f" follow it: smkflex logs {jid} --follow\n cancel it: smkflex cancel {jid}")
172
+ return 0
173
+
174
+ def follow(batch, logs, jid, follow_logs):
175
+ """Print the head job's log (until it ends, with follow_logs). Returns 0 if it succeeded."""
176
+ token, last_status, group, stream = None, None, None, None
177
+ ended = False
178
+ while True:
179
+ jobs = batch.describe_jobs(jobs=[jid])["jobs"]
180
+ if not jobs:
181
+ raise Fail(f"no job {jid}")
182
+ d = jobs[0]
183
+ status = d["status"]
184
+ done = status in ("SUCCEEDED", "FAILED")
185
+ if status != last_status and follow_logs and not done:
186
+ print(f"[smkflex] head job {jid}: {status}", file=sys.stderr)
187
+ last_status = status
188
+ c = d.get("container", {})
189
+ stream = c.get("logStreamName") or stream
190
+ group = log_group_of(c)
191
+ got = 0
192
+ if stream:
193
+ while True:
194
+ kw = dict(logGroupName=group, logStreamName=stream, startFromHead=True)
195
+ if token:
196
+ kw["nextToken"] = token
197
+ try:
198
+ resp = logs.get_log_events(**kw)
199
+ except ClientError as e:
200
+ if e.response.get("Error", {}).get("Code") == "ResourceNotFoundException":
201
+ break # no log stream yet
202
+ raise
203
+ for e in resp.get("events", []):
204
+ print(e["message"].rstrip())
205
+ got += len(resp.get("events", []))
206
+ # CloudWatch can return empty pages before the end; the end is when the
207
+ # token stops changing
208
+ new = resp.get("nextForwardToken")
209
+ if not new or new == token:
210
+ break
211
+ token = new
212
+ if done and not ended and stream:
213
+ # the last lines can reach CloudWatch a few seconds after the job ends
214
+ ended = True
215
+ time.sleep(3)
216
+ continue
217
+ if done:
218
+ print(f"[smkflex] head job {jid}: {status}", file=sys.stderr)
219
+ if status == "FAILED" and d.get("statusReason"):
220
+ print(f"[smkflex] {d['statusReason']}", file=sys.stderr)
221
+ return 0 if status == "SUCCEEDED" else 1
222
+ if not follow_logs:
223
+ if not stream:
224
+ print(f"[smkflex] head job {jid} is {status}; no log yet", file=sys.stderr)
225
+ return 0
226
+ if not got:
227
+ time.sleep(5)
228
+
229
+ def _job_clients(a):
230
+ """Clients for commands that only need a job ID: the profile (as the other commands find
231
+ it) for the region, else the AWS config's; --region overrides both."""
232
+ try:
233
+ _dir, profile = _load_profile(a.profile)
234
+ except Fail:
235
+ if a.profile:
236
+ raise
237
+ profile = {}
238
+ if a.region:
239
+ profile["aws-batch-flex-region"] = a.region
240
+ return _clients(profile)
241
+
242
+ def cmd_logs(a):
243
+ batch, logs, _r = _job_clients(a)
244
+ return follow(batch, logs, a.job_id, a.follow)
245
+
246
+ def cmd_cancel(a):
247
+ batch, _logs, _r = _job_clients(a)
248
+ jobs = batch.describe_jobs(jobs=[a.job_id])["jobs"]
249
+ if not jobs:
250
+ raise Fail(f"no job {a.job_id}")
251
+ status = jobs[0]["status"]
252
+ if status in ("SUCCEEDED", "FAILED"):
253
+ print(f"Head job {a.job_id} has already ended ({status}).")
254
+ return 0
255
+ batch.terminate_job(jobId=a.job_id, reason="smkflex cancel")
256
+ if status == "RUNNING":
257
+ print(f"Asked head job {a.job_id} to stop; Snakemake cancels the workflow's jobs and saves "
258
+ "its state (about a minute).")
259
+ else:
260
+ print(f"Cancelled head job {a.job_id} before it started ({status}); no jobs were submitted.")
261
+ return 0
262
+
263
+ def cmd_status(a):
264
+ _dir, profile = _load_profile(a.profile)
265
+ batch, _logs, _r = _clients(profile)
266
+ queue = a.queue or profile.get("aws-batch-flex-queue")
267
+ if not queue:
268
+ raise Fail("no queue: set aws-batch-flex-queue in the profile or pass --queue")
269
+ rows = list_jobs(batch, jobQueue=queue, filters=[{"name": "JOB_NAME", "values": ["smkflex-*"]}])
270
+ rows = sorted(rows, key=lambda j: j.get("createdAt", 0), reverse=True)[:a.limit]
271
+ if not rows:
272
+ print(f"no smkflex runs in queue {queue}")
273
+ return 0
274
+ # the summaries have no tags; the commit is on the job itself
275
+ tags = {d["jobId"]: d.get("tags") or {} for d in describe_jobs(batch, [j["jobId"] for j in rows])}
276
+ for j in rows:
277
+ when = datetime.fromtimestamp(j.get("createdAt", 0) / 1000).strftime("%Y-%m-%d %H:%M")
278
+ t = tags.get(j["jobId"], {})
279
+ what = j.get("jobName", "")[len("smkflex-"):]
280
+ if t.get("smkflex-commit"):
281
+ what += f" {t['smkflex-commit']}" + (f" ({t['smkflex-source']})" if t.get("smkflex-source") else "")
282
+ print(f"{j['jobId']} {j.get('status', ''):<9} {when} {what}")
283
+ return 0
284
+
285
+ IGNORE_LINES = ("data/", "results/", "resources/", "logs/", ".snakemake/")
286
+
287
+ def cmd_init(a):
288
+ """Set up this folder as a project: its own folder in the bucket (in the workflow profile
289
+ Snakemake loads by itself, the existing one if the pipeline has one) and a .gitignore that
290
+ keeps data out of smkflex run."""
291
+ _dir, profile = _load_profile(a.profile)
292
+ wp = workflow_profile_file(create=True)
293
+ wp_dir = os.path.dirname(wp)
294
+ current = {}
295
+ if os.path.isfile(wp):
296
+ with open(wp) as f:
297
+ current = yaml.safe_load(f) or {}
298
+ base = str(profile.get("default-storage-prefix") or "").rstrip("/")
299
+ name = re.sub(r"[^A-Za-z0-9_.-]", "-", a.name or os.path.basename(os.path.abspath(".")))
300
+ if current.get("default-storage-prefix"):
301
+ prefix = current["default-storage-prefix"]
302
+ print(f"This project already has its folder in storage: {prefix} (in {wp}); left as it is.")
303
+ else:
304
+ if not base:
305
+ raise Fail("the profile has no default-storage-prefix to put the project's folder under")
306
+ if not os.path.isdir("workflow") and not os.path.isfile("Snakefile"):
307
+ print("(no workflow/ or Snakefile here; is this the project's top folder?)", file=sys.stderr)
308
+ prefix = f"{base}/{name}"
309
+ os.makedirs(wp_dir, exist_ok=True)
310
+ with open(wp, "a") as f:
311
+ f.write(f"default-storage-prefix: {prefix}\n")
312
+ print(f"This project's files go to {prefix} (written to {wp}).")
313
+ lines = []
314
+ if os.path.isfile(".gitignore"):
315
+ with open(".gitignore") as f:
316
+ lines = [l.rstrip("\n") for l in f]
317
+ new = [l for l in IGNORE_LINES if l not in lines and l.rstrip("/") not in lines]
318
+ if new:
319
+ with open(".gitignore", "a") as f:
320
+ if lines and lines[-1].strip():
321
+ f.write("\n")
322
+ f.write("# kept out of smkflex run (data reaches the jobs through storage)\n")
323
+ f.write("".join(l + "\n" for l in new))
324
+ print(f"Added {', '.join(new)} to .gitignore.")
325
+ print("\nNext:\n"
326
+ " edit config/ (the pipeline's docs say what goes there)\n"
327
+ " smkflex push data upload input files (data/ here, as the sample sheets name them)\n"
328
+ " snakemake --profile " + os.path.basename(_dir.rstrip("/")) + " or: smkflex run")
329
+ return 0
330
+
331
+ def main(argv=None):
332
+ p = argparse.ArgumentParser(prog="smkflex", description=__doc__.split("\n\n")[0])
333
+ sub = p.add_subparsers(dest="cmd", required=True)
334
+ r = sub.add_parser("run", help="run the workflow in this folder on AWS Batch; other arguments "
335
+ "(e.g. targets, --forceall) go to snakemake")
336
+ r.add_argument("--profile", help="profile folder (default: ./profile, or $SNAKEMAKE_PROFILE)")
337
+ r.add_argument("--git", metavar="URL", help="run the workflow from this git repository instead of this folder "
338
+ "(cloned here with your git login, so private repositories work)")
339
+ r.add_argument("--rev", help="with --git: branch, tag or commit (default: the default branch)")
340
+ r.add_argument("--path", help="with --git: folder in the repository that has the workflow")
341
+ r.add_argument("--add", action="append", metavar="PATH",
342
+ help="send this local file or folder too, at the same path (with --git: over the "
343
+ "repository's, e.g. --add config/ for your own config); repeatable")
344
+ r.add_argument("--name", help="the run's name in smkflex status and AWS Batch "
345
+ "(default: the folder's or repository's name)")
346
+ r.add_argument("--queue", help="queue for the head job (default: the profile's)")
347
+ r.add_argument("--role", help="IAM role for the head job (default: aws-batch-flex-head-role-arn)")
348
+ r.add_argument("--image", help=f"image for the head job (default: {HEAD_IMAGE}, Debian 12 with git)")
349
+ r.add_argument("--vcpus", type=int, default=1)
350
+ r.add_argument("--mem-mb", type=int, default=2048)
351
+ r.add_argument("--max-bundle-mb", type=int, default=100)
352
+ r.add_argument("--wait", action="store_true", help="stay attached and print the log until the run ends")
353
+ for name, text in (("logs", "print a run's Snakemake log"), ("cancel", "stop a run and its jobs")):
354
+ s = sub.add_parser(name, help=text)
355
+ s.add_argument("job_id")
356
+ s.add_argument("--profile")
357
+ s.add_argument("--region")
358
+ if name == "logs":
359
+ s.add_argument("--follow", "-f", action="store_true")
360
+ s = sub.add_parser("pull", help="download files or folders from the workflow's storage, e.g. results")
361
+ s.add_argument("paths", nargs="*", help="paths as the workflow names them, e.g. results or results/summary.tsv")
362
+ s.add_argument("--profile")
363
+ s.add_argument("--to", default=".", help="folder to download into (default: here)")
364
+ s.add_argument("--prefix", help="storage prefix to download from (default: the profile's default-storage-prefix)")
365
+ s.add_argument("--dry-run", action="store_true", help="only list what would be downloaded")
366
+ s.add_argument("--force", action="store_true", help="download files that are already here too")
367
+ s = sub.add_parser("init", help="set up this folder as a project: its own folder in the bucket, and a "
368
+ ".gitignore for data")
369
+ s.add_argument("--profile")
370
+ s.add_argument("--name", help="the project's folder name in the bucket (default: this folder's name)")
371
+ s = sub.add_parser("push", help="upload input files or folders to the workflow's storage, at the same paths")
372
+ s.add_argument("paths", nargs="+", help="local paths as the workflow names them: relative (data, "
373
+ "data/reads.fq) or absolute (/shared/refs), as in the config")
374
+ s.add_argument("--profile")
375
+ s.add_argument("--prefix", help="storage prefix to upload to (default: the profile's default-storage-prefix)")
376
+ s.add_argument("--dry-run", action="store_true", help="only list what would be uploaded")
377
+ s.add_argument("--force", action="store_true", help="upload files that are already there too")
378
+ s = sub.add_parser("check", help="check the setup (AWS login, queue, roles, storage, versions)")
379
+ s.add_argument("--profile")
380
+ s = sub.add_parser("status", help="list recent runs")
381
+ s.add_argument("--profile")
382
+ s.add_argument("--queue")
383
+ s.add_argument("--limit", type=int, default=20)
384
+
385
+ a, extra = p.parse_known_args(argv)
386
+ if extra and a.cmd != "run":
387
+ p.error(f"unrecognized arguments: {' '.join(extra)}")
388
+ if extra[:1] == ["--"]:
389
+ extra = extra[1:]
390
+ try:
391
+ if a.cmd == "run":
392
+ return cmd_run(a, extra)
393
+ return {"logs": cmd_logs, "cancel": cmd_cancel, "status": cmd_status, "check": cmd_check,
394
+ "pull": cmd_pull, "push": cmd_push, "init": cmd_init}[a.cmd](a)
395
+ except Fail as e:
396
+ print(f"smkflex: {e}", file=sys.stderr)
397
+ return 2
398
+ except (ClientError, BotoCoreError) as e:
399
+ print(f"smkflex: AWS error: {e}", file=sys.stderr)
400
+ return 2
401
+ except WorkflowError as e: # a setting in the profile
402
+ print(f"smkflex: {e}", file=sys.stderr)
403
+ return 2
404
+ except KeyboardInterrupt:
405
+ return 130
406
+
407
+
408
+ if __name__ == "__main__":
409
+ sys.exit(main())