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,51 @@
1
+ """Jobs left running by an earlier run of the same workflow (Snakemake killed, laptop lost its
2
+ connection, ...). Snakemake would submit them again, so they'd run twice."""
3
+
4
+ import hashlib
5
+ import os
6
+
7
+ ACTIVE = ("SUBMITTED", "PENDING", "RUNNABLE", "STARTING", "RUNNING")
8
+
9
+
10
+ def workflow_id(workdir: str, snakefile: str, storage_prefix: str) -> str:
11
+ key = "|".join([os.path.abspath(workdir), os.path.abspath(snakefile), storage_prefix or ""])
12
+ return hashlib.sha256(key.encode()).hexdigest()[:16]
13
+
14
+
15
+ def list_jobs(batch, **kw) -> list:
16
+ """The job summaries list_jobs(**kw) gives, from every page."""
17
+ out, token = [], None
18
+ while True:
19
+ resp = batch.list_jobs(**kw, **({"nextToken": token} if token else {}))
20
+ out += resp.get("jobSummaryList", [])
21
+ token = resp.get("nextToken")
22
+ if not token:
23
+ return out
24
+
25
+
26
+ def describe_jobs(batch, ids) -> list:
27
+ """The jobs described (with their tags and job definition), 100 at a time as Batch takes them."""
28
+ ids, jobs = list(ids), []
29
+ for i in range(0, len(ids), 100):
30
+ jobs += batch.describe_jobs(jobs=ids[i : i + 100])["jobs"]
31
+ return jobs
32
+
33
+
34
+ def active_jobs(batch, queue: str) -> list:
35
+ """Every active job in `queue`, described. The summaries have no job definition or tags
36
+ (despite the API model), so every one is described."""
37
+ return describe_jobs(batch, [j["jobId"] for status in ACTIVE
38
+ for j in list_jobs(batch, jobQueue=queue, jobStatus=status, maxResults=1000)])
39
+
40
+
41
+ def find_orphans(batch, queue: str, jobdef_prefix: str, wf_id: str, run_id: str) -> list:
42
+ """[(job id, job name, status)] of active jobs in `queue` from this workflow but another run."""
43
+ found = []
44
+ for d in active_jobs(batch, queue):
45
+ tags = d.get("tags") or {}
46
+ jobdef = d.get("jobDefinition", "").rsplit("/", 1)[-1].split(":")[0]
47
+ if (jobdef.startswith(jobdef_prefix + "-")
48
+ and tags.get("snakemake-workflow") == wf_id
49
+ and tags.get("snakemake-run-id") != run_id):
50
+ found.append((d["jobId"], d.get("jobName", ""), d.get("status", "")))
51
+ return found
@@ -0,0 +1,196 @@
1
+ """Workarounds for bugs in Snakemake and the S3 storage plugin, each limited to what this
2
+ plugin needs. They can go once the bugs are fixed upstream.
3
+
4
+ Each one first checks that the code it patches is still what it was written for (the
5
+ parameters it takes); if a new version changed it, the workaround isn't applied and the
6
+ executor says so at start (NOT_APPLIED), instead of failing quietly or calling it wrongly.
7
+ tests/test_units.py fails as soon as one doesn't apply, and CI runs the newest Snakemake weekly."""
8
+
9
+ import inspect
10
+ import os
11
+ import re
12
+
13
+ from snakemake_interface_common.exceptions import WorkflowError
14
+
15
+ from .settings import MAX_SOURCES_MB, PLUGIN_NAME
16
+ from .sources import too_big, workflow_files
17
+
18
+
19
+ SOURCES = ("sending the workflow folder to the jobs (Snakemake's DAG.get_sources): jobs get only "
20
+ "what git knows about")
21
+ MISSING = "the hint for inputs that are only on this machine (Snakemake's MissingInputException)"
22
+ S3_DIRS = ("directory() outputs on S3 (the S3 storage plugin's StorageObject.exists): they can be "
23
+ "reported missing after the job made them")
24
+ # Workarounds this Snakemake (or S3 plugin) doesn't let us apply; the executor warns about them
25
+ NOT_APPLIED: list = []
26
+
27
+
28
+ def fits(func, params, what) -> bool:
29
+ """Does func take these parameters first, as the code a workaround was written for did?
30
+ If not, `what` goes into NOT_APPLIED."""
31
+ try:
32
+ ok = list(inspect.signature(func).parameters)[: len(params)] == list(params)
33
+ except (TypeError, ValueError):
34
+ ok = False
35
+ if not ok and what not in NOT_APPLIED:
36
+ NOT_APPLIED.append(what)
37
+ return ok
38
+
39
+
40
+ def skip_remote_module_scripts():
41
+ """Two fixes to what Snakemake packs for the jobs. (1) Work around Snakemake (9.27): packing the workflow's sources for the jobs crashes with
42
+ "TypeError: ... not GithubFile" when a rule comes from a module fetched from GitHub (as
43
+ snakedeploy sets up), because it treats the rule's script as a local file. Those scripts
44
+ don't need packing: each job fetches them from GitHub itself. So they're hidden while the
45
+ sources are collected. (2) The whole workflow folder is packed, see below."""
46
+ try:
47
+ from snakemake.dag import DAG
48
+ from snakemake.sourcecache import LocalSourceFile, SourceFile
49
+ except ImportError:
50
+ NOT_APPLIED.append(SOURCES)
51
+ return
52
+ orig = getattr(DAG, "get_sources", None)
53
+ if getattr(orig, "_skips_remote", False) or not fits(orig, ["self"], SOURCES):
54
+ return
55
+
56
+ def get_sources(self):
57
+ # Snakemake imports every installed executor plugin, so this runs for all of them:
58
+ # leave other executors' runs as they are
59
+ plugin = getattr(getattr(self, "workflow", None), "executor_plugin", None)
60
+ if getattr(plugin, "name", None) != PLUGIN_NAME:
61
+ return get_sources.__wrapped__(self)
62
+ # Jobs only get files below the folder Snakemake works in; Snakemake leaves the rest out
63
+ # with a warning, and every job then fails for want of the Snakefile
64
+ main = str(getattr(self.workflow, "main_snakefile", "") or "")
65
+ if main and os.path.exists(main) and os.path.relpath(main).startswith(".."):
66
+ raise WorkflowError(
67
+ f"The Snakefile ({os.path.relpath(main)}) is outside the folder Snakemake works in "
68
+ f"({os.getcwd()}), set by a workdir: directive or --directory, so the jobs couldn't get it. "
69
+ "Remove workdir: and start Snakemake in the workflow's folder: outputs go under the "
70
+ "storage prefix anyway (a project of its own: smkflex init in its own folder)."
71
+ )
72
+ hidden = []
73
+ for rule in getattr(self.workflow, "rules", []):
74
+ base = getattr(rule, "basedir", None)
75
+ if isinstance(base, SourceFile) and not isinstance(base, LocalSourceFile):
76
+ for attr in ("script", "notebook"):
77
+ value = getattr(rule, attr, None)
78
+ if value:
79
+ hidden.append((rule, attr, value))
80
+ setattr(rule, attr, None)
81
+ try:
82
+ files = get_sources.__wrapped__(self)
83
+ finally:
84
+ for rule, attr, value in hidden:
85
+ setattr(rule, attr, value)
86
+ # Snakemake sends jobs only what git knows about, besides the Snakefile and the files
87
+ # named in script:/conda:. Scripts a shell: command runs, modules the Snakefile imports,
88
+ # sample sheets and the like are missing in a folder that isn't a git checkout (such as
89
+ # one made by snakedeploy), or when never `git add`ed. Send the folder as smkflex run
90
+ # does: what git would track, or everything .gitignore doesn't exclude, and config/.
91
+ if isinstance(files, set):
92
+ files.update(workflow_files("."))
93
+ settings = getattr(self.workflow, "executor_settings", None)
94
+ limit = getattr(settings, "max_sources_mb", None) or MAX_SOURCES_MB
95
+ problem = too_big(files, limit)
96
+ if problem:
97
+ raise WorkflowError(problem)
98
+ return files
99
+
100
+ get_sources._skips_remote = True
101
+ get_sources.__wrapped__ = orig
102
+ DAG.get_sources = get_sources
103
+
104
+
105
+ # Snakemake packs the sources before it creates the executor, so this can't wait for it.
106
+ skip_remote_module_scripts()
107
+
108
+
109
+ def explain_local_only_inputs():
110
+ """Snakemake only says "Missing input files ... (in storage)" for an input that is on this
111
+ machine but was never uploaded (data/, or an absolute path): jobs read their inputs from
112
+ storage. Say so, with the smkflex push command that uploads it."""
113
+ try:
114
+ from snakemake.exceptions import MissingInputException
115
+ except ImportError:
116
+ NOT_APPLIED.append(MISSING)
117
+ return
118
+ orig = MissingInputException.__init__
119
+ if getattr(orig, "_smkflex", False) or not fits(orig, ["self", "job", "files"], MISSING):
120
+ return
121
+
122
+ def __init__(self, job, files, *args, **kwargs):
123
+ orig(self, job, files, *args, **kwargs)
124
+ try:
125
+ hint = local_only_hint(job.rule.workflow, files)
126
+ except Exception: # only an extra line: never let it break Snakemake's own error
127
+ hint = None
128
+ if hint and self.args:
129
+ self.args = (self.args[0] + hint, *self.args[1:])
130
+
131
+ __init__._smkflex = True
132
+ MissingInputException.__init__ = __init__
133
+
134
+
135
+ def local_only_hint(workflow, files):
136
+ if getattr(getattr(workflow, "executor_plugin", None), "name", None) != PLUGIN_NAME:
137
+ return None
138
+ prefix = str(getattr(workflow.storage_settings, "default_storage_prefix", "") or "").rstrip("/")
139
+ here, stored = [], False
140
+ for f in files:
141
+ query = getattr(getattr(f, "storage_object", None), "query", None) if getattr(f, "is_storage", False) else None
142
+ if not prefix or not query or not str(query).startswith(prefix + "/"):
143
+ continue
144
+ stored = True
145
+ rel = str(query)[len(prefix) + 1:]
146
+ # a relative path goes to prefix/path, an absolute one to prefix/path-without-its-/
147
+ for local in (rel, "/" + rel):
148
+ if os.path.exists(local):
149
+ here.append(local)
150
+ break
151
+ if not here:
152
+ return (f"\n Jobs read their inputs from {prefix}/, not from this machine: upload them there, "
153
+ "at the paths your rules and sample sheets use, with: smkflex push <files or folders>"
154
+ if stored else None)
155
+ push = sorted({os.path.dirname(p) if os.path.isabs(p) else p.split("/")[0] for p in here})
156
+ files_text = ", ".join(here[:5]) + (f" and {len(here) - 5} more" if len(here) > 5 else "")
157
+ return (f"\n On this machine but not in storage, where the jobs read their inputs: {files_text}. "
158
+ f"Upload with: smkflex push {' '.join(push)}")
159
+
160
+
161
+ explain_local_only_inputs()
162
+
163
+
164
+ def recheck_s3_directories():
165
+ """Work around snakemake-storage-plugin-s3 (0.3.6): a storage object remembers is_dir() =
166
+ False from before the job ran, so a directory() output the job then uploads is reported
167
+ "missing in storage". exists() looks a remembered False up again (it only asks is_dir()
168
+ when there's no plain object of that name). Only there: mtime() and size() of every file
169
+ ask is_dir() too, and keep the remembered answer, as without the patch."""
170
+ try:
171
+ from snakemake_storage_plugin_s3 import StorageObject
172
+ except ImportError: # not installed: nothing to work around
173
+ return
174
+ orig = getattr(StorageObject, "exists", None)
175
+ if getattr(orig, "_rechecks", False) or not fits(orig, ["self"], S3_DIRS):
176
+ return
177
+ # what it works around: the remembered is_dir() answer, in _is_dir
178
+ if "_is_dir" not in inspect.getsource(StorageObject):
179
+ NOT_APPLIED.append(S3_DIRS)
180
+ return
181
+
182
+ def exists(self):
183
+ if getattr(self, "_is_dir", None) is False:
184
+ self._is_dir = None
185
+ return orig(self)
186
+
187
+ exists._rechecks = True
188
+ StorageObject.exists = exists
189
+
190
+
191
+ def keep_unneeded_temp(cmd: str) -> str:
192
+ """Drop --unneeded-temp-files from a job's command. Snakemake (9.x) tells the job to delete
193
+ temp() outputs that no other job needs, but then waits for every output in storage after
194
+ the job, and fails with "Missing files". Without the option the job uploads them, and
195
+ Snakemake removes them from storage itself as with any other temp file."""
196
+ return re.sub(r" --unneeded-temp-files(?: (?!-)(?:'[^']*'|\S+))+", "", cmd)
@@ -0,0 +1,165 @@
1
+ """Startup checks on the queue, its compute environments and the job role.
2
+
3
+ A missing permission to look only gives a warning. Skip with --aws-batch-flex-skip-preflight."""
4
+
5
+ import weakref
6
+ from typing import Optional
7
+
8
+ from botocore.exceptions import BotoCoreError, ClientError
9
+
10
+
11
+ def access_denied(e: ClientError) -> bool:
12
+ code = e.response.get("Error", {}).get("Code", "")
13
+ return "AccessDenied" in code or "UnauthorizedOperation" in code or code == "AccessDeniedException"
14
+
15
+
16
+ # What describe_queue found, per Batch client (one region and login), for as long as it lives
17
+ _QUEUES: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
18
+
19
+
20
+ def describe_queue(batch, queue: str):
21
+ """(queue, its compute environments) as Batch describes them, looked up once per client:
22
+ the startup checks, Spot and priority all need them. (None, None) if there's no such
23
+ queue; the environments are None if they couldn't be described. Batch's errors for the
24
+ queue itself are raised."""
25
+ try:
26
+ cache = _QUEUES.setdefault(batch, {})
27
+ except TypeError: # a stand-in client that can't be weakly referenced (tests): no cache
28
+ cache = {}
29
+ if queue in cache:
30
+ return cache[queue]
31
+ found = batch.describe_job_queues(jobQueues=[queue])["jobQueues"]
32
+ if not found:
33
+ return None, None
34
+ q = found[0]
35
+ ces = [o["computeEnvironment"] for o in q.get("computeEnvironmentOrder", [])]
36
+ envs = []
37
+ if ces:
38
+ try:
39
+ envs = batch.describe_compute_environments(computeEnvironments=ces)["computeEnvironments"]
40
+ except (ClientError, BotoCoreError):
41
+ envs = None
42
+ cache[queue] = (q, envs)
43
+ return q, envs
44
+
45
+
46
+ def check_queue(batch, queue: str, platform: Optional[str] = None):
47
+ """Returns (errors, warnings, detected platform or None) for the job queue and the compute
48
+ environments behind it. A mismatch with `platform` is only an error when `platform` is given."""
49
+ errors, warnings, detected = [], [], None
50
+ try:
51
+ q, envs = describe_queue(batch, queue)
52
+ except ClientError as e:
53
+ if access_denied(e):
54
+ return [], [f"cannot verify queue '{queue}' (no batch:DescribeJobQueues permission); continuing"], None
55
+ return [f"AWS Batch refused the queue lookup: {e}"], [], None
56
+ except BotoCoreError as e:
57
+ return [], [f"cannot verify queue '{queue}': {e}; continuing"], None
58
+ if q is None:
59
+ hint = ""
60
+ try:
61
+ names = sorted(q["jobQueueName"] for q in batch.describe_job_queues()["jobQueues"])
62
+ hint = f" Queues in this region: {', '.join(names[:10]) or 'none'}."
63
+ except (ClientError, BotoCoreError):
64
+ pass
65
+ return [f"Job queue '{queue}' not found in this region (check the queue name and the region)." + hint], [], None
66
+ if q.get("state") != "ENABLED":
67
+ errors.append(f"Job queue '{queue}' is {q.get('state')}, not ENABLED.")
68
+ if q.get("status") != "VALID":
69
+ errors.append(f"Job queue '{queue}' has status {q.get('status')}: {q.get('statusReason', 'no reason given')}")
70
+ if not q.get("computeEnvironmentOrder"):
71
+ return errors + [f"Job queue '{queue}' has no compute environment attached."], warnings, None
72
+ if envs is None:
73
+ return errors, warnings + [f"cannot inspect the compute environments of '{queue}'; continuing"], None
74
+ for ce in envs:
75
+ name, kind = ce.get("computeEnvironmentName"), ce.get("computeResources", {}).get("type", "")
76
+ if ce.get("status") not in (None, "VALID"):
77
+ errors.append(f"Compute environment '{name}' has status {ce.get('status')}: {ce.get('statusReason', '')}")
78
+ if kind:
79
+ actual = "fargate" if kind.startswith("FARGATE") else "ec2"
80
+ detected = detected or actual
81
+ if platform and actual != platform:
82
+ errors.append(
83
+ f"Queue '{queue}' runs on {actual.upper()} (compute environment '{name}') but "
84
+ f"--aws-batch-flex-platform is '{platform}'. Set --aws-batch-flex-platform {actual} "
85
+ "(or leave it unset: the platform is then detected per queue)."
86
+ )
87
+ return errors, warnings, detected
88
+
89
+
90
+ def queue_uses_spot(batch, queue: str) -> bool:
91
+ """Best effort: is any compute environment behind the queue Spot (EC2 or Fargate)?"""
92
+ try:
93
+ _q, envs = describe_queue(batch, queue)
94
+ except (ClientError, BotoCoreError):
95
+ return False
96
+ return any(ce.get("computeResources", {}).get("type", "").endswith("SPOT") for ce in envs or [])
97
+
98
+
99
+ def check_role(iam, role_arn: str, label: str):
100
+ """Best effort: does the role exist? Only a definite 'no such role' is an error."""
101
+ name = role_arn.rsplit("/", 1)[-1]
102
+ try:
103
+ iam.get_role(RoleName=name)
104
+ except ClientError as e:
105
+ if e.response.get("Error", {}).get("Code") == "NoSuchEntity":
106
+ return [f"{label} '{role_arn}' does not exist."], []
107
+ return [], [] # cannot look (no iam:GetRole): not our business
108
+ except BotoCoreError:
109
+ return [], []
110
+ return [], []
111
+
112
+
113
+ SUBMIT_HINT = (
114
+ "The identity running snakemake needs batch:SubmitJob (on the queue and the snakemake-* job definitions), "
115
+ "batch:RegisterJobDefinition, batch:DescribeJobDefinitions, batch:DescribeJobs, batch:ListJobs, batch:TerminateJob, "
116
+ "batch:TagResource (jobs are tagged), logs:GetLogEvents, and iam:PassRole on the job role."
117
+ )
118
+
119
+
120
+ def ami_snakemake_version(batch, ec2, queue: str) -> Optional[str]:
121
+ """The SnakemakeVersion tag (set by bake-ami.sh) of the AMI the queue's instances start from.
122
+ None when it can't be found out: no tag (older AMIs), no permission, or several AMIs."""
123
+ try:
124
+ _q, envs = describe_queue(batch, queue)
125
+ images = set()
126
+ for ce in envs or []:
127
+ cr = ce.get("computeResources") or {}
128
+ # Batch uses an image override first, then imageId, then the launch template's image
129
+ found = {c.get("imageIdOverride") for c in cr.get("ec2Configuration") or []} - {None}
130
+ if not found and cr.get("imageId"):
131
+ found = {cr["imageId"]}
132
+ lt = cr.get("launchTemplate") or {}
133
+ if not found and (lt.get("launchTemplateId") or lt.get("launchTemplateName")):
134
+ which = ({"LaunchTemplateId": lt["launchTemplateId"]} if lt.get("launchTemplateId")
135
+ else {"LaunchTemplateName": lt["launchTemplateName"]})
136
+ for v in ec2.describe_launch_template_versions(
137
+ Versions=[lt.get("version") or "$Default"], **which)["LaunchTemplateVersions"]:
138
+ found |= {v.get("LaunchTemplateData", {}).get("ImageId")} - {None}
139
+ images |= found
140
+ if not images:
141
+ return None
142
+ versions = {t["Value"] for img in ec2.describe_images(ImageIds=sorted(images))["Images"]
143
+ for t in img.get("Tags", []) if t["Key"] == "SnakemakeVersion"}
144
+ return versions.pop() if len(versions) == 1 else None
145
+ except (ClientError, BotoCoreError, KeyError, IndexError):
146
+ return None
147
+
148
+
149
+ def _version_tuple(v: str):
150
+ try:
151
+ return tuple(int(x) for x in v.split(".")[:3])
152
+ except ValueError:
153
+ return None
154
+
155
+
156
+ def version_note(ami: Optional[str], local: str):
157
+ """(level, message) comparing the AMI's Snakemake with this machine's, or (None, None)."""
158
+ if not ami or ami == "unknown" or ami == local:
159
+ return None, None
160
+ a, l = _version_tuple(ami), _version_tuple(local)
161
+ msg = f"the AMI has Snakemake {ami}, this machine has {local}."
162
+ if a and l and a[0] == l[0] and min(a, l) >= (9, 5):
163
+ return "info", "Note: " + msg + " Versions from 9.5 on are known to work together."
164
+ return "warning", ("Snakemake version gap: " + msg + " Jobs may fail with 'unrecognized "
165
+ "arguments'; install the AMI's version here or bake a new AMI.")
@@ -0,0 +1,98 @@
1
+ """smkflex: the profile (as Snakemake finds it) and the AWS clients its commands use."""
2
+
3
+ import os
4
+ import sys
5
+
6
+ import boto3
7
+ import yaml
8
+ from botocore.config import Config as BotoConfig
9
+
10
+
11
+ class Fail(Exception):
12
+ pass
13
+
14
+ class _Log:
15
+ def info(self, msg):
16
+ print(msg, file=sys.stderr)
17
+
18
+ warning = info
19
+
20
+ def workflow_profile_file(create=False):
21
+ """The project's workflow profile, as Snakemake finds it when started here: the first of
22
+ default/, profiles/default/ and (next to workflow/Snakefile) workflow/profiles/default/ that
23
+ exists. With create, profiles/default/config.yaml when there's none (Snakemake reads that
24
+ for a Snakefile at the top or in workflow/)."""
25
+ candidates = ["default", os.path.join("profiles", "default")]
26
+ if os.path.isfile(os.path.join("workflow", "Snakefile")):
27
+ candidates.append(os.path.join("workflow", "profiles", "default"))
28
+ for d in candidates:
29
+ if os.path.isdir(d):
30
+ for name in ("profile.yaml", "config.yaml"):
31
+ if os.path.isfile(os.path.join(d, name)):
32
+ return os.path.join(d, name)
33
+ return os.path.join(d, "config.yaml")
34
+ return os.path.join("profiles", "default", "config.yaml") if create else None
35
+
36
+ def _only_installed_profile():
37
+ """The name of the one profile in ~/.config/snakemake that uses this plugin, if there is
38
+ exactly one (setup.sh installs it there)."""
39
+ base = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "snakemake")
40
+ names = []
41
+ try:
42
+ entries = sorted(os.listdir(base))
43
+ except OSError:
44
+ return None
45
+ for n in entries:
46
+ for f in ("profile.yaml", "config.yaml"):
47
+ cfg = os.path.join(base, n, f)
48
+ if os.path.isfile(cfg):
49
+ try:
50
+ with open(cfg) as fh:
51
+ if (yaml.safe_load(fh) or {}).get("executor") == "aws-batch-flex":
52
+ names.append(n)
53
+ except (OSError, yaml.YAMLError):
54
+ pass
55
+ break
56
+ if len(names) == 1:
57
+ print(f"(using profile {names[0]} from {base})", file=sys.stderr)
58
+ return names[0]
59
+ if names:
60
+ raise Fail(f"there are {len(names)} profiles for this plugin in {base} ({', '.join(names)}): "
61
+ f"say which with --profile {names[0]} (or export SNAKEMAKE_PROFILE={names[0]})")
62
+ return None
63
+
64
+ def _load_profile(path):
65
+ """(profile folder, settings) for --profile: a folder, or a name that Snakemake looks up in
66
+ ~/.config/snakemake/ (as `snakemake --profile NAME` does). Default: ./profile, then
67
+ $SNAKEMAKE_PROFILE."""
68
+ if path is None:
69
+ path = "profile" if os.path.isdir("profile") else os.environ.get("SNAKEMAKE_PROFILE")
70
+ if not path:
71
+ path = _only_installed_profile()
72
+ if not path:
73
+ raise Fail("no profile: pass --profile (a folder with config.yaml, or a profile name)")
74
+ found = None
75
+ try:
76
+ from snakemake.cli import get_profile_dir
77
+
78
+ found = get_profile_dir(path)
79
+ except ImportError:
80
+ cfg = os.path.join(path, "config.yaml")
81
+ found = (path, cfg) if os.path.isfile(cfg) else None
82
+ if not found:
83
+ raise Fail(f"profile {path} not found (looked for a folder with config.yaml or profile.yaml "
84
+ "here and in ~/.config/snakemake/)")
85
+ folder, cfg = found
86
+ with open(cfg) as f:
87
+ settings = yaml.safe_load(f) or {}
88
+ # The project's own workflow profile, which Snakemake loads on top (e.g. its storage prefix)
89
+ wp = workflow_profile_file()
90
+ if wp and os.path.isfile(wp):
91
+ with open(wp) as f:
92
+ settings.update(yaml.safe_load(f) or {})
93
+ return str(folder), settings
94
+
95
+ def _clients(profile):
96
+ cfg = BotoConfig(region_name=profile.get("aws-batch-flex-region"),
97
+ retries={"max_attempts": 10, "mode": "adaptive"})
98
+ return boto3.client("batch", config=cfg), boto3.client("logs", config=cfg), cfg.region_name