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,482 @@
1
+ """The plugin's settings: `aws-batch-flex-...` in a profile, `--aws-batch-flex-...` on the
2
+ command line. `from_profile` reads them from a profile the way Snakemake does, for smkflex."""
3
+
4
+ import dataclasses
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Optional
7
+
8
+ from snakemake_interface_common.exceptions import WorkflowError
9
+ from snakemake_interface_executor_plugins.settings import (
10
+ CommonSettings,
11
+ ExecutorSettingsBase,
12
+ )
13
+
14
+ from .bootstrap import MODES
15
+ from .hostenv import DEFAULT_PATH as DEFAULT_HOST_ENV
16
+ from .jobdefs import DEFAULT_LOG_GROUP, is_ecr
17
+ from .sizing import DEFAULT_MEM_OVERHEAD_MIB
18
+
19
+ PLUGIN_NAME = "aws-batch-flex"
20
+ PROFILE_PREFIX = PLUGIN_NAME + "-"
21
+ MAX_SOURCES_MB = 100
22
+
23
+
24
+ @dataclass
25
+ class ExecutorSettings(ExecutorSettingsBase):
26
+ queue: Optional[str] = field(
27
+ default=None,
28
+ metadata={
29
+ "help": "AWS Batch job queue to submit to. A rule can override this with "
30
+ "the resource `batch_queue`.",
31
+ "env_var": False,
32
+ "required": True,
33
+ },
34
+ )
35
+ region: Optional[str] = field(
36
+ default=None,
37
+ metadata={
38
+ "help": "AWS region. Defaults to the standard boto3 resolution "
39
+ "(AWS_REGION, AWS_PROFILE, ~/.aws/config).",
40
+ "env_var": False,
41
+ "required": False,
42
+ },
43
+ )
44
+ job_role_arn: Optional[str] = field(
45
+ default=None,
46
+ metadata={
47
+ "help": "IAM role assumed by the job container (needs access to the "
48
+ "storage bucket). If omitted, the compute environment's instance role "
49
+ "is used (EC2 only, and only if the instances let containers reach it: "
50
+ "jobs_use_instance_role in the Terraform setup).",
51
+ "env_var": False,
52
+ "required": False,
53
+ },
54
+ )
55
+ execution_role_arn: Optional[str] = field(
56
+ default=None,
57
+ metadata={
58
+ "help": "ECS task execution role. Required for Fargate and for pulling "
59
+ "from private registries.",
60
+ "env_var": False,
61
+ "required": False,
62
+ },
63
+ )
64
+ head_role_arn: Optional[str] = field(
65
+ default=None,
66
+ metadata={
67
+ "help": "Only for `smkflex run`: IAM role of the head job that runs Snakemake on "
68
+ "AWS (Terraform prints it as head_role_arn). Snakemake itself ignores it.",
69
+ "env_var": False,
70
+ "required": False,
71
+ },
72
+ )
73
+ platform: Optional[str] = field(
74
+ default=None,
75
+ metadata={
76
+ "help": "Compute platform of the main queue: 'ec2' (also covers EC2 Spot) or "
77
+ "'fargate'. Default: detected per queue from its compute environment (EC2 and "
78
+ "Fargate queues can be mixed via the batch_queue resource); falls back to ec2 "
79
+ "when the queue cannot be inspected. A rule can force it with the "
80
+ "batch_platform resource.",
81
+ "env_var": False,
82
+ "required": False,
83
+ },
84
+ )
85
+ default_image: Optional[str] = field(
86
+ default=None,
87
+ metadata={
88
+ "help": "Image for rules without a `container:` directive. Defaults to "
89
+ "Snakemake's --container-image.",
90
+ "env_var": False,
91
+ "required": False,
92
+ },
93
+ )
94
+ bootstrap: Optional[str] = field(
95
+ default="auto",
96
+ metadata={
97
+ "help": "How to get Snakemake into job containers. 'host' (EC2, "
98
+ "recommended; Nextflow-style): mount a Snakemake env pre-installed on the "
99
+ "host (see snakemake-batch-flex-host-env), so images need only /bin/sh. "
100
+ "'auto': install at job start only if the image lacks the matching version; "
101
+ "'always': always install at job start; 'never': the image must already "
102
+ "contain Snakemake and the storage plugins.",
103
+ "env_var": False,
104
+ "required": False,
105
+ },
106
+ )
107
+ host_env_path: Optional[str] = field(
108
+ default=DEFAULT_HOST_ENV,
109
+ metadata={
110
+ "help": "Path of the host-installed Snakemake env, mounted read-only at "
111
+ "the same path in every container (bootstrap=host).",
112
+ "env_var": False,
113
+ "required": False,
114
+ },
115
+ )
116
+ host_env_wait_seconds: Optional[int] = field(
117
+ default=120,
118
+ metadata={
119
+ "help": "bootstrap=host: how long a job waits for the host env to become "
120
+ "ready (instances installing it via user-data). Use 0 on a baked AMI.",
121
+ "env_var": False,
122
+ "required": False,
123
+ },
124
+ )
125
+ install_prereqs: bool = field(
126
+ default=False,
127
+ metadata={
128
+ "help": "Install-at-start modes (auto/always): if the image has no curl, wget or "
129
+ "python3+pip, install curl/ca-certificates/bash with the image's package manager "
130
+ "(apt/apk/dnf/yum). Needs root in the image and access to the distro mirrors.",
131
+ "env_var": False,
132
+ "required": False,
133
+ },
134
+ )
135
+ script_python: Optional[str] = field(
136
+ default="image",
137
+ metadata={
138
+ "help": "Which python runs Python script: rules. 'image': the image's python if it "
139
+ "is 3.10 or newer (so the script sees the image's packages), else Snakemake's. "
140
+ "'host': always Snakemake's own python. Per rule: the batch_script_python resource.",
141
+ "env_var": False,
142
+ "required": False,
143
+ },
144
+ )
145
+ mem_overhead_mib: Optional[int] = field(
146
+ default=DEFAULT_MEM_OVERHEAD_MIB,
147
+ metadata={
148
+ "help": "MiB added to each rule's mem_mb/mem_mib for the Snakemake process that "
149
+ "runs inside the job and shares its memory limit. 0 = request exactly mem_mb.",
150
+ "env_var": False,
151
+ "required": False,
152
+ },
153
+ )
154
+ skip_preflight: bool = field(
155
+ default=False,
156
+ metadata={
157
+ "help": "Skip the startup checks of the queue, its compute environment and the job role.",
158
+ "env_var": False,
159
+ "required": False,
160
+ },
161
+ )
162
+ conda_path: Optional[str] = field(
163
+ default=None,
164
+ metadata={
165
+ "help": "Host-installed conda (see snakemake-batch-flex-host-env conda / bake-ami.sh "
166
+ "--conda), mounted read-only at the same path. Lets any image run conda: rules, "
167
+ "including existing named environments in <path>/envs. EC2 only.",
168
+ "env_var": False,
169
+ "required": False,
170
+ },
171
+ )
172
+ volumes: List[str] = field(
173
+ default_factory=list,
174
+ metadata={
175
+ "help": "Extra host bind mounts, HOST:CONTAINER[:ro] (EC2 only), like "
176
+ "Nextflow's aws.batch.volumes. Example: /scratch:/scratch",
177
+ "env_var": False,
178
+ "required": False,
179
+ "nargs": "+",
180
+ },
181
+ )
182
+ extra_packages: List[str] = field(
183
+ default_factory=list,
184
+ metadata={
185
+ "help": "Extra pip requirements installed alongside Snakemake when "
186
+ "bootstrapping (e.g. additional Snakemake plugins).",
187
+ "env_var": False,
188
+ "required": False,
189
+ "nargs": "+",
190
+ },
191
+ )
192
+ python_version: Optional[str] = field(
193
+ default=None,
194
+ metadata={
195
+ "help": "Python version for the bootstrap venv. Defaults to the version "
196
+ "running this Snakemake.",
197
+ "env_var": False,
198
+ "required": False,
199
+ },
200
+ )
201
+ spot_retries: Optional[int] = field(
202
+ default=2,
203
+ metadata={
204
+ "help": "How many times AWS Batch itself retries a job whose host was "
205
+ "reclaimed (Spot interruption), 0-9; 0 = none. Independent of Snakemake's --retries. "
206
+ "Per rule: the batch_spot_retries resource.",
207
+ "env_var": False,
208
+ "required": False,
209
+ },
210
+ )
211
+ scheduling_priority: Optional[int] = field(
212
+ default=None,
213
+ metadata={
214
+ "help": "Scheduling priority (0-9999, higher runs first) for every job. Only works on "
215
+ "queues with a fair-share scheduling policy; skipped with a warning otherwise. Per "
216
+ "rule: the batch_priority resource.",
217
+ "env_var": False,
218
+ "required": False,
219
+ },
220
+ )
221
+ log_group: Optional[str] = field(
222
+ default=DEFAULT_LOG_GROUP,
223
+ metadata={
224
+ "help": "CloudWatch log group for job logs. The default needs no setup.",
225
+ "env_var": False,
226
+ "required": False,
227
+ },
228
+ )
229
+ job_definition: Optional[str] = field(
230
+ default=None,
231
+ metadata={
232
+ "help": "Use this existing job definition (name, name:revision or ARN) instead of "
233
+ "registering one per image; per rule: the batch_job_definition resource. It fixes "
234
+ "the image, job role, mounts and /dev/shm; vCPUs, memory, timeout and tags still "
235
+ "come from the rules. Without the host-env mount, jobs install Snakemake at start.",
236
+ "env_var": False,
237
+ "required": False,
238
+ },
239
+ )
240
+ job_definition_prefix: Optional[str] = field(
241
+ default="snakemake",
242
+ metadata={
243
+ "help": "Prefix for auto-registered job definitions.",
244
+ "env_var": False,
245
+ "required": False,
246
+ },
247
+ )
248
+ assign_public_ip: Optional[str] = field(
249
+ default="ENABLED",
250
+ metadata={
251
+ "help": "Fargate only: 'ENABLED' or 'DISABLED'. DISABLED needs a NAT "
252
+ "gateway or VPC endpoints for image pulls and bootstrapping.",
253
+ "env_var": False,
254
+ "required": False,
255
+ },
256
+ )
257
+ ephemeral_storage_gib: Optional[int] = field(
258
+ default=None,
259
+ metadata={
260
+ "help": "Fargate only: minimum scratch disk in GiB (21-200); a rule's disk_mb can "
261
+ "raise it for that rule.",
262
+ "env_var": False,
263
+ "required": False,
264
+ },
265
+ )
266
+ registry_credentials: List[str] = field(
267
+ default_factory=list,
268
+ metadata={
269
+ "help": "REGISTRY=SECRET_ARN pairs for private registries outside AWS, e.g. "
270
+ "docker.io=arn:aws:secretsmanager:...:secret:dockerhub (the secret holds "
271
+ "{\"username\": ..., \"password\": ...}). Only images from that registry use it. "
272
+ "Needs --aws-batch-flex-execution-role-arn with secretsmanager:GetSecretValue on "
273
+ "the secret. ECR images need no credentials (the instance/execution role pulls them).",
274
+ "env_var": False,
275
+ "required": False,
276
+ "nargs": "+",
277
+ },
278
+ )
279
+ orphan_jobs: Optional[str] = field(
280
+ default=None,
281
+ metadata={
282
+ "help": "What to do with jobs still running from an earlier, interrupted run of "
283
+ "the same workflow (same directory, Snakefile and storage prefix), which "
284
+ "Snakemake would otherwise run a second time: 'cancel' (default), 'warn' "
285
+ "(default with --nolock) or 'ignore'. Needs batch:ListJobs.",
286
+ "env_var": False,
287
+ "required": False,
288
+ },
289
+ )
290
+ tags: List[str] = field(
291
+ default_factory=list,
292
+ metadata={
293
+ "help": "KEY=VALUE tags applied to every job and to job definitions "
294
+ "(cost allocation). Rules can add or override tags for their own jobs with "
295
+ "the resource batch_tags=\"k1=v1,k2=v2\".",
296
+ "env_var": False,
297
+ "required": False,
298
+ "nargs": "+",
299
+ },
300
+ )
301
+ conda_cache: Optional[str] = field(
302
+ default="instance",
303
+ metadata={
304
+ "help": "Reuse conda environments instead of building them in every job: "
305
+ "'instance' (jobs on the same EC2 machine share them), an S3 URL such as "
306
+ "s3://BUCKET/snakemake/.smkflex/conda-cache (also kept there for later jobs and "
307
+ "runs), or 'none'.",
308
+ "env_var": False,
309
+ "required": False,
310
+ },
311
+ )
312
+ oom_memory_factor: Optional[float] = field(
313
+ default=2.0,
314
+ metadata={
315
+ "help": "When a job ran out of memory and Snakemake retries it (--retries, or a "
316
+ "rule's retries:), ask for this many times more memory on each retry. 1 = never.",
317
+ "env_var": False,
318
+ "required": False,
319
+ },
320
+ )
321
+ no_usage_summary: bool = field(
322
+ default=False,
323
+ metadata={
324
+ "help": "Don't print the per-rule summary of what the run used (vCPU-hours, memory, "
325
+ "CPUs, disk, and the resources to paste into the profile) at the end of the run.",
326
+ "env_var": False,
327
+ "required": False,
328
+ },
329
+ )
330
+ no_auto_tags: bool = field(
331
+ default=False,
332
+ metadata={
333
+ "help": "Disable the automatic job tags snakemake-run-id, snakemake-workflow, "
334
+ "snakemake-rule and snakemake-job-id (on by default, for per-rule cost reporting). "
335
+ "Also turns off the check for jobs left over from an interrupted run.",
336
+ "env_var": False,
337
+ "required": False,
338
+ },
339
+ )
340
+ max_sources_mb: Optional[int] = field(
341
+ default=MAX_SOURCES_MB,
342
+ metadata={
343
+ "help": "Every job gets the workflow folder (what git would track, or everything "
344
+ ".gitignore doesn't exclude, and config/; single files over 10 MB are left out). "
345
+ "Stop with a message when that is more than this many MB: data belongs in storage.",
346
+ "env_var": False,
347
+ "required": False,
348
+ },
349
+ )
350
+ runnable_warn_minutes: Optional[int] = field(
351
+ default=15,
352
+ metadata={
353
+ "help": "Warn when a job has been RUNNABLE (waiting for capacity) this "
354
+ "long; usually means the request exceeds what the compute environment "
355
+ "can provide.",
356
+ "env_var": False,
357
+ "required": False,
358
+ },
359
+ )
360
+
361
+
362
+ common_settings = CommonSettings(
363
+ non_local_exec=True,
364
+ implies_no_shared_fs=True,
365
+ job_deploy_sources=True,
366
+ pass_default_storage_provider_args=True,
367
+ pass_default_resources_args=True,
368
+ # Env vars go into the Batch job's `environment`, not into the command line.
369
+ pass_envvar_declarations_to_cmd=False,
370
+ # We install storage plugins ourselves, version-pinned, inside the bootstrap venv.
371
+ auto_deploy_default_storage_provider=False,
372
+ # an early first look at the jobs only costs one status call; then every 10 s, as Snakemake's default
373
+ init_seconds_before_status_checks=1,
374
+ )
375
+
376
+
377
+ def host_mounts(s: ExecutorSettings) -> list:
378
+ """[(host path, container path, read-only)] for the machine image's Snakemake and conda
379
+ (EC2 only), mounted at the same path in the jobs."""
380
+ mounts = []
381
+ if s.bootstrap == "host":
382
+ mounts.append((s.host_env_path, s.host_env_path, True))
383
+ if s.conda_path:
384
+ mounts.append((s.conda_path, s.conda_path, True))
385
+ return mounts
386
+
387
+
388
+ def volume_mounts(s: ExecutorSettings) -> list:
389
+ """[(host path, container path, read-only)] from the volumes setting (HOST:CONTAINER[:ro])."""
390
+ mounts = []
391
+ for item in s.volumes or []:
392
+ parts = item.split(":")
393
+ if len(parts) not in (2, 3) or (len(parts) == 3 and parts[2] != "ro"):
394
+ raise WorkflowError(f"Invalid volume '{item}', expected HOST:CONTAINER[:ro].")
395
+ mounts.append((parts[0], parts[1], len(parts) == 3))
396
+ return mounts
397
+
398
+
399
+ def registry_secrets(s: ExecutorSettings) -> dict:
400
+ """{registry: secret ARN} from the registry-credentials setting (REGISTRY=SECRET_ARN)."""
401
+ out = {}
402
+ for item in s.registry_credentials or []:
403
+ reg, sep, arn = item.partition("=")
404
+ reg, arn = reg.strip().lower(), arn.strip()
405
+ if not sep or not reg or not arn.startswith("arn:"):
406
+ raise WorkflowError(
407
+ f"Invalid --aws-batch-flex-registry-credentials entry '{item}', "
408
+ "expected REGISTRY=SECRET_ARN (e.g. docker.io=arn:aws:secretsmanager:...)."
409
+ )
410
+ if is_ecr(reg):
411
+ raise WorkflowError(f"{reg} is ECR: it needs no credentials, the instance/execution role pulls from it.")
412
+ out["docker.io" if reg in ("index.docker.io", "registry-1.docker.io") else reg] = arn
413
+ if out and not s.execution_role_arn:
414
+ raise WorkflowError(
415
+ "--aws-batch-flex-registry-credentials needs --aws-batch-flex-execution-role-arn: "
416
+ "ECS reads the registry secret with the execution role."
417
+ )
418
+ return out
419
+
420
+
421
+ def _one_of(name, value, allowed):
422
+ if value is not None and value not in allowed:
423
+ raise WorkflowError(f"--aws-batch-flex-{name} must be {' or '.join(repr(a) for a in allowed)}, got {value!r}.")
424
+
425
+
426
+ def _number(name, value, low, high=None, kind=int):
427
+ if value is None:
428
+ return
429
+ try:
430
+ ok = kind(value) >= low and (high is None or kind(value) <= high)
431
+ except (TypeError, ValueError):
432
+ ok = False
433
+ if not ok:
434
+ to = f" to {high}" if high is not None else " or more"
435
+ raise WorkflowError(f"--aws-batch-flex-{name} must be a number from {low}{to}, got {value!r}.")
436
+
437
+
438
+ def validate(s: ExecutorSettings) -> ExecutorSettings:
439
+ """The settings, checked: what the executor and smkflex both rely on. Raises WorkflowError
440
+ with what's wrong, before anything reaches AWS."""
441
+ _one_of("platform", s.platform, ("ec2", "fargate"))
442
+ _one_of("bootstrap", s.bootstrap, MODES)
443
+ _one_of("script-python", s.script_python, ("image", "host"))
444
+ _one_of("orphan-jobs", s.orphan_jobs, ("cancel", "warn", "ignore"))
445
+ _one_of("assign-public-ip", s.assign_public_ip, ("ENABLED", "DISABLED"))
446
+ cache = str(s.conda_cache).strip()
447
+ if cache not in ("none", "instance") and not cache.startswith("s3://"):
448
+ raise WorkflowError(f"--aws-batch-flex-conda-cache must be none, instance or an s3:// URL, got '{cache}'.")
449
+ _number("oom-memory-factor", s.oom_memory_factor, 1, kind=float)
450
+ _number("spot-retries", s.spot_retries, 0, 9)
451
+ _number("scheduling-priority", s.scheduling_priority, 0, 9999)
452
+ _number("ephemeral-storage-gib", s.ephemeral_storage_gib, 21, 200) # Fargate's limits
453
+ _number("mem-overhead-mib", s.mem_overhead_mib, 0)
454
+ _number("max-sources-mb", s.max_sources_mb, 1)
455
+ volume_mounts(s)
456
+ registry_secrets(s)
457
+ return s
458
+
459
+
460
+ def normalized(s: ExecutorSettings) -> ExecutorSettings:
461
+ """The settings with every None that has a default replaced by the default, so the rest of
462
+ the code needn't check (Snakemake can hand over None for a setting that wasn't given)."""
463
+ changes = {}
464
+ for f in dataclasses.fields(s):
465
+ default = f.default if f.default is not dataclasses.MISSING else None
466
+ if getattr(s, f.name) is None and default is not None:
467
+ changes[f.name] = default
468
+ return dataclasses.replace(s, **changes) if changes else s
469
+
470
+
471
+ def from_profile(profile: dict) -> ExecutorSettings:
472
+ """The settings in a profile's config.yaml (keys aws-batch-flex-...), with the defaults for
473
+ the rest, as Snakemake would read them."""
474
+ kw = {}
475
+ for f in dataclasses.fields(ExecutorSettings):
476
+ value = profile.get(PROFILE_PREFIX + f.name.replace("_", "-"))
477
+ if value is None:
478
+ continue
479
+ if f.type is bool and isinstance(value, str):
480
+ value = value.lower() in ("true", "1", "yes")
481
+ kw[f.name] = value
482
+ return validate(normalized(ExecutorSettings(**kw)))
@@ -0,0 +1,75 @@
1
+ """Map Snakemake job resources onto AWS Batch resource requirements."""
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from typing import Mapping, Optional
6
+
7
+ from snakemake_interface_common.exceptions import WorkflowError
8
+
9
+ DEFAULT_MEM_MIB = 2048
10
+ # The Snakemake process inside each job shares the container's memory limit (~100-250 MiB).
11
+ DEFAULT_MEM_OVERHEAD_MIB = 256
12
+
13
+ # Fargate: vCPU -> (min MiB, max MiB, step MiB)
14
+ _FARGATE = {
15
+ 0.25: (512, 2048, 512),
16
+ 0.5: (1024, 4096, 1024),
17
+ 1: (2048, 8192, 1024),
18
+ 2: (4096, 16384, 1024),
19
+ 4: (8192, 30720, 1024),
20
+ 8: (16384, 61440, 4096),
21
+ 16: (32768, 122880, 8192),
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class Requirements:
27
+ vcpu: str
28
+ memory_mib: int
29
+ gpu: Optional[int] = None
30
+ timeout_seconds: Optional[int] = None
31
+
32
+
33
+ def fargate_size(vcpu: float, mem_mib: int) -> tuple[str, int]:
34
+ """Smallest valid Fargate (vCPU, MiB) combination that satisfies the request."""
35
+ for cpu, (lo, hi, step) in sorted(_FARGATE.items()):
36
+ if cpu >= vcpu and hi >= mem_mib:
37
+ mem = max(lo, mem_mib)
38
+ mem = lo + math.ceil((mem - lo) / step) * step
39
+ return (str(cpu), mem)
40
+ raise WorkflowError(
41
+ f"No Fargate size fits {vcpu} vCPU / {mem_mib} MiB (max 16 vCPU, 120 GiB). "
42
+ "Use --aws-batch-flex-platform ec2 for larger jobs."
43
+ )
44
+
45
+
46
+ def job_requirements(
47
+ resources: Mapping,
48
+ threads: int,
49
+ platform: str,
50
+ mem_overhead_mib: int = DEFAULT_MEM_OVERHEAD_MIB,
51
+ ) -> Requirements:
52
+ mem_mib = None
53
+ if resources.get("mem_mib"):
54
+ mem_mib = int(resources["mem_mib"])
55
+ elif resources.get("mem_mb"):
56
+ # Snakemake normally derives mem_mib itself; this is only a fallback (4.6% generous).
57
+ mem_mib = int(resources["mem_mb"])
58
+ # The overhead is only added to explicit requests; the default already leaves room.
59
+ mem_mib = mem_mib + max(0, mem_overhead_mib) if mem_mib else DEFAULT_MEM_MIB
60
+ mem_mib = max(mem_mib, 128)
61
+ vcpu = max(1, int(threads or 1))
62
+
63
+ gpu = int(resources["gpu"]) if resources.get("gpu") else None
64
+ if gpu and platform == "fargate":
65
+ raise WorkflowError("GPU resources are not supported on Fargate.")
66
+
67
+ timeout = None
68
+ if resources.get("runtime"): # Snakemake's `runtime` is in minutes
69
+ timeout = max(60, int(resources["runtime"]) * 60)
70
+
71
+ if platform == "fargate":
72
+ cpu_s, mem_mib = fargate_size(vcpu, mem_mib)
73
+ else:
74
+ cpu_s = str(vcpu)
75
+ return Requirements(cpu_s, mem_mib, gpu, timeout)
@@ -0,0 +1,83 @@
1
+ """Which files of the workflow folder go along: to each job (patches.py, for plain `snakemake`)
2
+ and into `smkflex run`'s bundle. Only the standard library: patches.py imports it in every run."""
3
+
4
+ import fnmatch
5
+ import os
6
+ import subprocess
7
+
8
+ SKIP_DIRS = {".snakemake", ".smkflex", ".git", "__pycache__"}
9
+ ALWAYS = "config"
10
+ # Top-level folders that never go along, whatever .gitignore says: input data and results, which
11
+ # jobs read from and write to storage (a local copy, e.g. from `smkflex pull`, isn't used there)
12
+ NEVER = {"data", "results", "logs", "benchmarks"}
13
+ # Snakemake leaves out bigger files itself (with a warning), so they don't count
14
+ SNAKEMAKE_MAX_FILE = 10_000_000
15
+
16
+
17
+ def _ignore_patterns(root):
18
+ """Patterns from a .gitignore at the top of a folder that isn't a git checkout (simple
19
+ globs: `*.bam`, `data/`, `results`; no negation)."""
20
+ try:
21
+ with open(os.path.join(root, ".gitignore")) as f:
22
+ lines = [l.strip() for l in f]
23
+ except OSError:
24
+ return []
25
+ return [l for l in lines if l and not l.startswith(("#", "!"))]
26
+
27
+
28
+ def _ignored(rel, patterns):
29
+ parts = rel.split("/")
30
+ for p in patterns:
31
+ anchored, p = p.startswith("/"), p.strip("/")
32
+ if "/" in p or anchored:
33
+ if fnmatch.fnmatch(rel, p) or any(fnmatch.fnmatch("/".join(parts[:i]), p) for i in range(1, len(parts))):
34
+ return True
35
+ elif any(fnmatch.fnmatch(part, p) for part in parts):
36
+ return True
37
+ return False
38
+
39
+
40
+ def workflow_files(root):
41
+ """Files to send: what git would track (tracked plus untracked, not ignored), or
42
+ everything outside .snakemake/.git when the folder isn't a git checkout, minus what a
43
+ .gitignore there lists. Never data/, results/, logs/ or benchmarks/ (NEVER). Plus config/
44
+ even if ignored: every job reads its sample sheets, which people keep out of git when they
45
+ name patients or private samples."""
46
+ try:
47
+ out = subprocess.run(["git", "ls-files", "-co", "--exclude-standard", "-z"], cwd=root,
48
+ capture_output=True, check=True).stdout.decode()
49
+ files = [f for f in out.split("\0") if f]
50
+ except (OSError, subprocess.CalledProcessError):
51
+ patterns = _ignore_patterns(root)
52
+ files = []
53
+ for d, dirs, names in os.walk(root):
54
+ dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
55
+ files += [os.path.relpath(os.path.join(d, n), root) for n in names]
56
+ files = [f for f in files if not _ignored(f, patterns)]
57
+ for d, dirs, names in os.walk(os.path.join(root, ALWAYS)):
58
+ dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
59
+ files += [os.path.relpath(os.path.join(d, n), root) for n in names]
60
+ return sorted(set(f for f in files
61
+ if not SKIP_DIRS.intersection(f.split("/")) and f.split("/")[0] not in NEVER
62
+ and os.path.isfile(os.path.join(root, f))))
63
+
64
+
65
+ def too_big(files, limit_mb, root="."):
66
+ """None, or what to say when `files` add up to more than limit_mb (MB)."""
67
+ total, by_top = 0, {}
68
+ for f in files:
69
+ try:
70
+ n = os.path.getsize(os.path.join(root, f))
71
+ except OSError:
72
+ continue
73
+ if n > SNAKEMAKE_MAX_FILE:
74
+ continue
75
+ total += n
76
+ top = f.split("/")[0] + "/" if "/" in f else "files at the top"
77
+ by_top[top] = by_top.get(top, 0) + n
78
+ if total <= limit_mb * 1e6:
79
+ return None
80
+ biggest = ", ".join(f"{d} {n / 1e6:.0f} MB" for d, n in sorted(by_top.items(), key=lambda kv: -kv[1])[:5])
81
+ return (f"Every job gets the workflow folder, and it has {total / 1e6:.0f} MB to send ({biggest}); the limit "
82
+ f"is {limit_mb} MB. Data belongs in storage: add folders like these to .gitignore (`smkflex init` "
83
+ "adds resources/ too), or raise aws-batch-flex-max-sources-mb.")