cpg-utils 5.7.2__tar.gz → 5.8.0__tar.gz

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 (33) hide show
  1. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/PKG-INFO +4 -1
  2. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cloudpath_hail_az.py +1 -1
  3. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/config.py +5 -2
  4. cpg_utils-5.8.0/cpg_utils/dataproc_runner.py +927 -0
  5. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/hail_batch.py +0 -1
  6. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/PKG-INFO +4 -1
  7. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/SOURCES.txt +2 -1
  8. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/requires.txt +3 -0
  9. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/pyproject.toml +4 -1
  10. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/setup.py +4 -1
  11. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_config.py +17 -0
  12. cpg_utils-5.8.0/test/test_dataproc_runner.py +160 -0
  13. cpg_utils-5.7.2/test/test_doctests.py +0 -22
  14. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/LICENSE +0 -0
  15. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/README.md +0 -0
  16. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/__init__.py +0 -0
  17. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cloud.py +0 -0
  18. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/constants.py +0 -0
  19. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cromwell.py +0 -0
  20. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/cromwell_model.py +0 -0
  21. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/dataproc.py +0 -0
  22. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/existence_checks.py +0 -0
  23. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/git.py +0 -0
  24. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/membership.py +0 -0
  25. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/metamist_registration.py +0 -0
  26. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/py.typed +0 -0
  27. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils/slack.py +0 -0
  28. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/dependency_links.txt +0 -0
  29. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/cpg_utils.egg-info/top_level.txt +0 -0
  30. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/setup.cfg +0 -0
  31. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/__init__.py +0 -0
  32. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_cromwell.py +0 -0
  33. {cpg_utils-5.7.2 → cpg_utils-5.8.0}/test/test_get_google_identity_token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cpg-utils
3
- Version: 5.7.2
3
+ Version: 5.8.0
4
4
  Summary: Library of convenience functions specific to the CPG
5
5
  Home-page: https://github.com/populationgenomics/cpg-utils
6
6
  License: MIT
@@ -24,8 +24,11 @@ Requires-Dist: frozendict
24
24
  Requires-Dist: google-auth>=1.27.0
25
25
  Requires-Dist: google-auth-oauthlib
26
26
  Requires-Dist: google-cloud-artifact-registry
27
+ Requires-Dist: google-cloud-dataproc
27
28
  Requires-Dist: google-cloud-secret-manager
29
+ Requires-Dist: google-cloud-storage
28
30
  Requires-Dist: metamist
31
+ Requires-Dist: python-slugify
29
32
  Requires-Dist: requests
30
33
  Requires-Dist: tabulate
31
34
  Requires-Dist: toml
@@ -26,7 +26,7 @@ class HailAzureBlobPath(AzureBlobPath):
26
26
  >>> CloudPath('hail-az://myaccount/mycontainer/tmp')
27
27
  HailAzureBlobPath('hail-az://myaccount/mycontainer/tmp')
28
28
  >>> CloudPath('https://myaccount.blob.core.windows.net/mycontainer/tmp')
29
- HailAzureBlobPath('hail-az://myaccount/mycontainer/tmp')
29
+ HttpsPath('https://myaccount.blob.core.windows.net/mycontainer/tmp')
30
30
  """
31
31
 
32
32
  cloud_prefix: str = 'hail-az://'
@@ -576,9 +576,12 @@ def dataset_for_access_level(dataset_name: str) -> str:
576
576
  """
577
577
  Checks access_level in config, modifying dataset with "-test" suffix if appropriate.
578
578
 
579
- >>> dataset_for_access_level('dataset') # with access_level != 'test'
579
+ # with access_level != 'test'
580
+ > dataset_for_access_level('dataset')
580
581
  'dataset'
581
- >>> dataset_for_access_level('dataset') # with access_level == 'test'
582
+
583
+ # with access_level == 'test'
584
+ > dataset_for_access_level('dataset')
582
585
  'dataset-test'
583
586
  """
584
587
  access_level = config_retrieve(['workflow', 'access_level'], 'standard')
@@ -0,0 +1,927 @@
1
+ """Dataproc client wrapper for running Hail jobs on ephemeral clusters.
2
+
3
+ A non-Hail alternative to cpg_utils.dataproc. Instead of wrapping the cluster
4
+ lifecycle inside a Hail Batch driver job and shelling out to hailctl, this
5
+ module drives the Dataproc REST API directly through the google-cloud-dataproc
6
+ library. It is intended for orchestrators such as Nextflow that need to submit
7
+ one or more Hail PySpark scripts to an ephemeral cluster and tear it down when
8
+ done.
9
+
10
+ Example:
11
+ from cpg_utils.dataproc_runner import HailDataprocCluster
12
+
13
+ with HailDataprocCluster(
14
+ project='my-project',
15
+ region='australia-southeast1',
16
+ cluster_name_prefix='my-pipeline',
17
+ staging_bucket='gs://my-staging',
18
+ temp_bucket='gs://my-temp',
19
+ labels={'ar-guid': 'abc123', 'stage': 'align'},
20
+ ) as cluster:
21
+ script_uri = cluster.upload('local_script.py')
22
+ input_uri = cluster.upload('samplesheet.tsv')
23
+ cluster.run_job(
24
+ script_uri,
25
+ args=['--samplesheet', input_uri, '--out', 'gs://out/vds'],
26
+ )
27
+ # Multiple jobs can be submitted to the same cluster before shutdown.
28
+ cluster.run_job(script_uri, args=['--samplesheet', input_uri, '--stage', 'qc'])
29
+ """
30
+
31
+ import os
32
+ import signal
33
+ import sys
34
+ import threading
35
+ import time
36
+ import uuid
37
+ from contextlib import suppress
38
+ from types import FrameType, TracebackType
39
+ from typing import Any
40
+
41
+ from cloudpathlib import GSClient, GSPath
42
+ from google.api_core import exceptions as gax_exceptions
43
+ from google.cloud import dataproc_v1, storage
44
+ from slugify import slugify
45
+
46
+ DEFAULT_WHEEL = (
47
+ 'gs://hail-common/hailctl/dataproc/{version}/hail-{version}-py3-none-any.whl'
48
+ )
49
+ DEFAULT_HAIL_VERSION = '0.2.138'
50
+ DEFAULT_HAIL_IMAGE = '2.2-debian12'
51
+ DEFAULT_INIT = 'gs://hail-common/hailctl/dataproc/{version}/init_notebook.py'
52
+ DEFAULT_MASTER_TYPE = 'n1-highmem-4'
53
+ DEFAULT_WORKER_TYPE = 'n1-highmem-4'
54
+ DEFAULT_NUM_WORKERS = 2
55
+ DEFAULT_NUM_SECONDARY_WORKERS = 2
56
+ DEFAULT_MAX_IDLE_SECONDS = 900
57
+ DEFAULT_MAX_AGE_SECONDS = 86400
58
+ DEFAULT_BOOT_DISK_SIZE_GB = 100
59
+ DEFAULT_BOOT_DISK_TYPE = 'pd-standard'
60
+ DEFAULT_NUM_LOCAL_SSDS = 0
61
+ DEFAULT_INIT_TIMEOUT_SECONDS = 600
62
+ DEFAULT_LOG_POLL_SECONDS = 5.0
63
+ DEFAULT_JOB_POLL_INITIAL_SECONDS = 10.0
64
+ DEFAULT_JOB_POLL_MAX_SECONDS = 150.0
65
+ DEFAULT_JOB_POLL_BACKOFF = 1.5
66
+
67
+ # GCP label keys/values: lowercase letters, digits, underscore, hyphen; <=63 chars.
68
+ # Keys must start with a lowercase letter. values may be empty.
69
+ _KEY_VALUE_INVALID = r'[^a-z0-9_-]'
70
+ # Cluster names are stricter: no underscores allowed, max length 52 chars.
71
+ _CLUSTER_INVALID = r'[^a-z0-9-]'
72
+
73
+ _TERMINAL_ERROR_STATES = frozenset({'ERROR', 'CANCELLED'})
74
+
75
+
76
+ class DataprocJobError(RuntimeError):
77
+ """Raised when a Dataproc job reaches a non-DONE terminal state.
78
+
79
+ Attributes:
80
+ job: The failing Dataproc Job returned by the API.
81
+ state: The terminal state name, either ERROR or CANCELLED.
82
+ """
83
+
84
+ def __init__(self, job: dataproc_v1.Job, state: str) -> None:
85
+ details = job.status.details or 'No details available'
86
+ super().__init__(
87
+ f'Dataproc job {job.reference.job_id} failed with state '
88
+ f'{state}: {details}',
89
+ )
90
+ self.job = job
91
+ self.state = state
92
+
93
+
94
+ def unique_cluster_name(name: str) -> str:
95
+ """Build a unique Dataproc cluster name from a prefix.
96
+
97
+ Dataproc cluster names allow only lowercase letters, digits, and hyphens,
98
+ and must be at most 51 characters. The sanitised prefix is truncated to
99
+ 42 characters and a hyphen plus eight hex characters of uuid4 are
100
+ appended so the total stays within the 51 character limit.
101
+
102
+ Args:
103
+ name: The desired cluster name prefix.
104
+
105
+ Returns:
106
+ A sanitised name with a unique eight-character hex suffix.
107
+
108
+ (full result cannot be doctested due to the unique component)
109
+ >>> unique_cluster_name('My_Cluster_1')[:12]
110
+ 'my-cluster-1'
111
+ """
112
+ # sanitised name, truncated to 42 chars - the hyphen and uuid add 9 chars, for a 51-char dataproc max length name
113
+ sanitised_name = slugify(name, regex_pattern=_CLUSTER_INVALID, max_length=42)
114
+ if not sanitised_name[0].isalpha():
115
+ raise ValueError(
116
+ f'Invalid cluster name: {sanitised_name} - must start with a lowercase letter.',
117
+ )
118
+
119
+ return f'{sanitised_name}-{uuid.uuid4().hex[:8]}'
120
+
121
+
122
+ def sanitise_labels(labels: dict[str, str]) -> dict[str, str]:
123
+ """Return a copy of labels with keys and values sanitised for GCP.
124
+
125
+ Entries whose sanitised key does not start with a lowercase letter causes an error.
126
+
127
+ Args:
128
+ labels: Raw label mapping from caller input.
129
+
130
+ Returns:
131
+ A new dictionary containing only the valid sanitised entries.
132
+
133
+ >>> sanitise_labels({'AR-GUID': 'Abc123', 'Bad Key!': 'x'})
134
+ {'ar-guid': 'abc123', 'bad-key': 'x'}
135
+ """
136
+ failing_labels: dict[str, str] = {}
137
+ result: dict[str, str] = {}
138
+ for raw_key, raw_value in labels.items():
139
+ key = slugify(raw_key, regex_pattern=_KEY_VALUE_INVALID, max_length=63)
140
+ value = slugify(str(raw_value), regex_pattern=_KEY_VALUE_INVALID)
141
+ if not key or not key[0].isalpha():
142
+ failing_labels[key] = value
143
+ continue
144
+ result[key] = value
145
+ if failing_labels:
146
+ fail_string = f"""\
147
+ During key-value sanitisation on the provided labels, the following failed to conform to requirements:
148
+ Input: {labels}
149
+ Failures: {failing_labels}\n
150
+ Note: keys must start with a lowercase letter and contain only letters, digits, underscores, and hyphens.
151
+ See https://docs.cloud.google.com/resource-manager/docs/labels-overview
152
+ """
153
+ raise ValueError(fail_string)
154
+ return result
155
+
156
+
157
+ def parse_label_kvs(items: list[str]) -> dict[str, str]:
158
+ """Parse key=value strings into a sanitised label dictionary.
159
+
160
+ Intended to format CLI-passed arguments. Malformed entries missing an equals sign cause an error.
161
+
162
+ Args:
163
+ items: Raw key=value strings from the command line.
164
+
165
+ Returns:
166
+ A sanitised label dictionary.
167
+
168
+ >>> parse_label_kvs(['ar-guid=abc', 'Stage=Align'])
169
+ {'ar-guid': 'abc', 'stage': 'align'}
170
+ """
171
+ parsed: dict[str, str] = {}
172
+ failing_kv: list[str] = []
173
+ for item in items:
174
+ if '=' not in item:
175
+ failing_kv.append(item)
176
+ continue
177
+ key, value = item.split('=', 1)
178
+ parsed[key] = value
179
+
180
+ if failing_kv:
181
+ fail_string = f"""\
182
+ One or more key=value parameters did not contain an equals character, so they could not be parsed:
183
+ {failing_kv}
184
+ """
185
+ raise ValueError(fail_string)
186
+ return sanitise_labels(parsed)
187
+
188
+
189
+ def resolve_autoscaling_policy_uri(
190
+ policy_ref: str,
191
+ project: str | None = None,
192
+ region: str | None = None,
193
+ ) -> str:
194
+ """Return the full resource URI for an autoscaling policy.
195
+
196
+ Accepts either a bare policy ID or an already-qualified resource URI. If
197
+ policy_ref already starts with projects/ it is returned unchanged.
198
+
199
+ Args:
200
+ project: GCP project ID owning the policy. Can be None if policy_ref is a full path.
201
+ region: Dataproc region hosting the policy. Can be None if policy_ref is a full path.
202
+ policy_ref: Bare policy ID or full resource URI.
203
+
204
+ Returns:
205
+ The fully qualified autoscaling policy resource URI.
206
+
207
+ >>> resolve_autoscaling_policy_uri('my-policy', 'proj', 'australia-southeast1')
208
+ 'projects/proj/regions/australia-southeast1/autoscalingPolicies/my-policy'
209
+ >>> resolve_autoscaling_policy_uri(
210
+ ... 'projects/other/regions/eu/autoscalingPolicies/foo',
211
+ ... 'proj', 'australia-southeast1',
212
+ ... )
213
+ 'projects/other/regions/eu/autoscalingPolicies/foo'
214
+ """
215
+ if policy_ref.startswith('projects/'):
216
+ return policy_ref
217
+ if not all((project, region, policy_ref)):
218
+ raise ValueError(
219
+ f'Invalid autoscaling policy components: {project}, {region}, {policy_ref}',
220
+ )
221
+ # projects/seqera-trial/regions/australia-southeast1/autoscalingPolicies/trial-combiner-1
222
+ # projects/australia-southeast1/regions/trial-combiner-1
223
+ return f'projects/{project}/regions/{region}/autoscalingPolicies/{policy_ref}'
224
+
225
+
226
+ def upload_to_gcs(
227
+ local_path: str,
228
+ bucket: str,
229
+ prefix: str = '',
230
+ client: GSClient | None = None,
231
+ ) -> str:
232
+ """
233
+ Upload a local file to GCS and return the gs:// URI.
234
+
235
+ The uploaded blob path is bucket/prefix/basename. Passing an empty prefix
236
+ uploads to the bucket root.
237
+
238
+ Args:
239
+ local_path: Path to the file on the local filesystem.
240
+ bucket: Target bucket, either as gs://bucket-name or bucket-name.
241
+ prefix: Blob path prefix within the bucket.
242
+ client: Optional pre-built storage client, mainly for tests.
243
+
244
+ Returns:
245
+ The gs:// URI of the uploaded blob.
246
+ """
247
+ bucket_name = bucket.removeprefix('gs://').rstrip('/')
248
+ dest = (
249
+ GSPath(f'gs://{bucket_name}', client=client)
250
+ / prefix.strip('/')
251
+ / os.path.basename(local_path)
252
+ )
253
+ dest.upload_from(local_path)
254
+ return str(dest)
255
+
256
+
257
+ def populate_packages(
258
+ packages: list[str] | None = None,
259
+ hail_version: str = DEFAULT_HAIL_VERSION,
260
+ ) -> list[str]:
261
+ """
262
+ Blend any requested packages with default packages
263
+
264
+ Args:
265
+ packages:
266
+ hail_version: str, the version of hail to use
267
+
268
+ Returns:
269
+ a list of packages. At minimum this will contain cpg-utils and a version of hail
270
+ if hail was not user specified, it will be added at the default version pin.
271
+ """
272
+ new_packages: list[str] = []
273
+
274
+ # two key packages
275
+ cpg_utils_specified: bool = False
276
+ hail_specified: bool = False
277
+
278
+ # iterate over the provided package list
279
+ for each_package in packages or []:
280
+ if each_package.startswith('hail'):
281
+ hail_specified = True
282
+ elif each_package.startswith('cpg-utils'):
283
+ cpg_utils_specified = True
284
+ new_packages.append(each_package)
285
+
286
+ if not cpg_utils_specified:
287
+ new_packages.append('cpg-utils')
288
+ if not hail_specified:
289
+ new_packages.append(f'hail=={hail_version}')
290
+
291
+ return new_packages
292
+
293
+
294
+ class HailDataprocCluster:
295
+ """Ephemeral Dataproc cluster preconfigured for Hail PySpark jobs.
296
+
297
+ Construction does not create the cluster. Call start, enter the object as
298
+ a context manager, or invoke submit_job to lazily create it. submit_job
299
+ returns the Dataproc job id without waiting. wait_for_job polls a
300
+ submitted job until it reaches a terminal state and by default streams
301
+ driver stdout to this process. run_job is a convenience wrapper that
302
+ submits and then waits. Multiple jobs may be submitted to the same
303
+ cluster before shutdown, and each submit_job call reuses the running
304
+ cluster. shutdown deletes the cluster and is idempotent. The context
305
+ manager runs shutdown on exit, and install_sigterm_cleanup wires it to
306
+ SIGTERM.
307
+
308
+ service_account sets the identity the cluster VMs run as, i.e. the identity Hail code uses to
309
+ read from and write to GCS.
310
+ If unset, Dataproc falls back to the project's default compute service account. At CPG that should
311
+ normally be dataproc-<access_level>@<project>.iam.gserviceaccount.com, as in the hailctl implementation.
312
+ """
313
+
314
+ def __init__(
315
+ self,
316
+ project: str,
317
+ region: str,
318
+ cluster_name_prefix: str,
319
+ staging_bucket: str,
320
+ temp_bucket: str,
321
+ *,
322
+ master_type: str = DEFAULT_MASTER_TYPE,
323
+ worker_type: str = DEFAULT_WORKER_TYPE,
324
+ num_workers: int = DEFAULT_NUM_WORKERS,
325
+ num_secondary_workers: int = DEFAULT_NUM_SECONDARY_WORKERS,
326
+ max_idle_seconds: int = DEFAULT_MAX_IDLE_SECONDS,
327
+ max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS,
328
+ autoscaling_policy: str | None = None,
329
+ service_account: str | None = None,
330
+ labels: dict[str, str] | None = None,
331
+ hail_version: str = DEFAULT_HAIL_VERSION,
332
+ hail_image: str = DEFAULT_HAIL_IMAGE,
333
+ wheel: str | None = None,
334
+ init_script: str | None = None,
335
+ packages: list[str] | None = None,
336
+ boot_disk_size_gb: int = DEFAULT_BOOT_DISK_SIZE_GB,
337
+ boot_disk_type: str = DEFAULT_BOOT_DISK_TYPE,
338
+ num_local_ssds: int = DEFAULT_NUM_LOCAL_SSDS,
339
+ init_timeout_seconds: int = DEFAULT_INIT_TIMEOUT_SECONDS,
340
+ cluster_client: dataproc_v1.ClusterControllerClient | None = None,
341
+ job_client: dataproc_v1.JobControllerClient | None = None,
342
+ storage_client: storage.Client | None = None,
343
+ preemptible_workers: bool = True,
344
+ ) -> None:
345
+ self._project = project
346
+ self._region = region
347
+ self._staging_bucket = staging_bucket.rstrip('/')
348
+ self._temp_bucket = temp_bucket.rstrip('/')
349
+ self._master_type = master_type
350
+ self._worker_type = worker_type
351
+ self._num_workers = num_workers
352
+ self._num_secondary_workers = num_secondary_workers
353
+ self._max_idle_seconds = max_idle_seconds
354
+ self._max_age_seconds = max_age_seconds
355
+ self._hail_version = hail_version
356
+ self._hail_image = hail_image
357
+ self._wheel = wheel or DEFAULT_WHEEL.format(version=hail_version)
358
+ self._init_script = init_script or DEFAULT_INIT.format(version=hail_version)
359
+ self._packages = populate_packages(packages, hail_version=hail_version)
360
+ self._boot_disk_size_gb = boot_disk_size_gb
361
+ self._boot_disk_type = boot_disk_type
362
+ self._num_local_ssds = num_local_ssds
363
+ self._init_timeout_seconds = init_timeout_seconds
364
+ self._service_account = service_account
365
+ self._labels = sanitise_labels(labels or {})
366
+ self._policy_uri: str | None = (
367
+ resolve_autoscaling_policy_uri(
368
+ project=project,
369
+ region=region,
370
+ policy_ref=autoscaling_policy,
371
+ )
372
+ if autoscaling_policy
373
+ else None
374
+ )
375
+
376
+ self._name = unique_cluster_name(cluster_name_prefix)
377
+ # 8 hex chars of uniqueness; keeps the total name well under the 51-char limit.
378
+
379
+ endpoint = f'{region}-dataproc.googleapis.com:443'
380
+ self._cluster_client = cluster_client or dataproc_v1.ClusterControllerClient(
381
+ client_options={'api_endpoint': endpoint},
382
+ )
383
+ self._job_client = job_client or dataproc_v1.JobControllerClient(
384
+ client_options={'api_endpoint': endpoint},
385
+ )
386
+ self._storage_client = storage_client or storage.Client()
387
+ self._gs_client = GSClient(storage_client=self._storage_client)
388
+
389
+ self._started = False
390
+ self._shutdown_called = False
391
+ self._cluster: dataproc_v1.Cluster | None = None
392
+ self._job_ids: list[str] = []
393
+ self._preemptible_workers = preemptible_workers
394
+
395
+ @property
396
+ def name(self) -> str:
397
+ """The generated cluster name, prefix plus eight random hex characters."""
398
+ return self._name
399
+
400
+ @property
401
+ def project(self) -> str:
402
+ return self._project
403
+
404
+ @property
405
+ def region(self) -> str:
406
+ return self._region
407
+
408
+ @property
409
+ def labels(self) -> dict[str, str]:
410
+ """A copy of the sanitised labels applied to the cluster."""
411
+ return dict(self._labels)
412
+
413
+ @property
414
+ def autoscaling_policy_uri(self) -> str | None:
415
+ return self._policy_uri
416
+
417
+ @property
418
+ def service_account(self) -> str | None:
419
+ """The service account the cluster VMs run as, or None for the GCE default."""
420
+ return self._service_account
421
+
422
+ @property
423
+ def job_ids(self) -> list[str]:
424
+ """Job ids of every job submitted to this cluster, in submission order."""
425
+ return list(self._job_ids)
426
+
427
+ @property
428
+ def is_started(self) -> bool:
429
+ return self._started
430
+
431
+ @property
432
+ def hail_version(self) -> str:
433
+ return self._hail_version
434
+
435
+ @property
436
+ def wheel(self) -> str:
437
+ return self._wheel
438
+
439
+ @property
440
+ def init_script(self) -> str:
441
+ return self._init_script
442
+
443
+ @property
444
+ def preemptible_workers(self) -> bool:
445
+ return self._preemptible_workers
446
+
447
+ def start(self) -> dataproc_v1.Cluster:
448
+ """
449
+ Create the cluster if not already started and return it.
450
+
451
+ Idempotent. If start has already been called, the existing cluster is returned without another API call.
452
+
453
+ Returns:
454
+ The created or already-running Dataproc Cluster.
455
+ """
456
+ if self.is_started and self._cluster is not None:
457
+ return self._cluster
458
+
459
+ cluster_config = self._build_cluster_config()
460
+ print(f"Creating cluster '{self._name}' in {self._region}...")
461
+ operation = self._cluster_client.create_cluster(
462
+ request={
463
+ 'project_id': self._project,
464
+ 'region': self._region,
465
+ 'cluster': cluster_config,
466
+ },
467
+ )
468
+ # set started = True before launching. If the cluster times out during the creation it can enter an error state
469
+ # by setting started=True early, any failures will be caught and managed by the context handler
470
+ # ERROR state dataproc clusters need to be deleted manually, the idle TTL does not apply
471
+ self._started = True
472
+ self._cluster = operation.result()
473
+ print(f"Cluster '{self._name}' created successfully.")
474
+ return self._cluster
475
+
476
+ def _build_cluster_config(self) -> dict[str, Any]:
477
+ disk_config: dict[str, Any] = {
478
+ 'boot_disk_type': self._boot_disk_type,
479
+ 'boot_disk_size_gb': self._boot_disk_size_gb,
480
+ }
481
+ # Local SSDs are a fixed 375 GB each on GCE; only the count is
482
+ # configurable, so there is no size field to set here.
483
+ if self._num_local_ssds:
484
+ disk_config['num_local_ssds'] = self._num_local_ssds
485
+
486
+ config: dict[str, Any] = {
487
+ 'project_id': self._project,
488
+ 'cluster_name': self._name,
489
+ 'labels': dict(self._labels),
490
+ 'config': {
491
+ 'gce_cluster_config': {
492
+ 'zone_uri': '', # Let Dataproc auto-zone within region.
493
+ 'metadata': {
494
+ 'WHEEL': self.wheel,
495
+ },
496
+ },
497
+ 'master_config': {
498
+ 'num_instances': 1,
499
+ 'machine_type_uri': self._master_type,
500
+ 'disk_config': dict(disk_config),
501
+ },
502
+ 'worker_config': {
503
+ 'num_instances': self._num_workers,
504
+ 'machine_type_uri': self._worker_type,
505
+ 'disk_config': dict(disk_config),
506
+ },
507
+ 'software_config': {
508
+ 'image_version': self._hail_image,
509
+ 'properties': {
510
+ 'spark:spark.serializer': (
511
+ 'org.apache.spark.serializer.KryoSerializer'
512
+ ),
513
+ 'spark:spark.kryo.registrator': (
514
+ 'is.hail.kryo.HailKryoRegistrator'
515
+ ),
516
+ 'dataproc:dataproc.logging.stackdriver.job.driver.enable': (
517
+ 'true'
518
+ ),
519
+ },
520
+ },
521
+ 'initialization_actions': [
522
+ {
523
+ 'executable_file': self.init_script,
524
+ 'execution_timeout': {'seconds': self._init_timeout_seconds},
525
+ },
526
+ ],
527
+ 'lifecycle_config': {
528
+ 'auto_delete_ttl': {'seconds': self._max_age_seconds},
529
+ 'idle_delete_ttl': {'seconds': self._max_idle_seconds},
530
+ },
531
+ 'temp_bucket': self._temp_bucket.removeprefix('gs://'),
532
+ },
533
+ }
534
+
535
+ # Without this the VMs run as the project's default compute service account, typically
536
+ # this has no access to dataset buckets.
537
+ if self._service_account:
538
+ config['config']['gce_cluster_config'][
539
+ 'service_account'
540
+ ] = self._service_account
541
+ if self._packages:
542
+ pkgs = '|'.join(self._packages)
543
+ config['config']['gce_cluster_config']['metadata']['PKGS'] = pkgs
544
+ if self._policy_uri:
545
+ config['config']['autoscaling_config'] = {'policy_uri': self._policy_uri}
546
+
547
+ # Add secondary (preemptible) workers if requested, or if autoscaling may scale them.
548
+ if self._num_secondary_workers or self._policy_uri:
549
+ config['config']['secondary_worker_config'] = {
550
+ 'num_instances': self._num_secondary_workers,
551
+ 'machine_type_uri': self._worker_type,
552
+ 'is_preemptible': self.preemptible_workers,
553
+ 'disk_config': dict(disk_config),
554
+ }
555
+
556
+ return config
557
+
558
+ def upload(self, local_path: str, prefix: str | None = None) -> str:
559
+ """Upload a local file to the staging bucket and return the gs:// URI.
560
+
561
+ The default prefix is scripts/<cluster_name>, which keeps artifacts
562
+ from different clusters segregated. Pass an explicit prefix, including
563
+ the empty string for the bucket root, to override.
564
+
565
+ Args:
566
+ local_path: Path to the file on the local filesystem.
567
+ prefix: Optional override for the blob path prefix.
568
+
569
+ Returns:
570
+ The gs:// URI of the uploaded blob.
571
+ """
572
+ effective_prefix = prefix if prefix is not None else f'scripts/{self._name}'
573
+ return upload_to_gcs(
574
+ local_path=local_path,
575
+ bucket=self._staging_bucket,
576
+ prefix=effective_prefix,
577
+ client=self._gs_client,
578
+ )
579
+
580
+ def submit_job(
581
+ self,
582
+ script_uri: str,
583
+ *,
584
+ args: list[str] | None = None,
585
+ file_uris: list[str] | None = None,
586
+ properties: dict[str, str] | None = None,
587
+ ) -> str:
588
+ """
589
+ Submit a PySpark job to the cluster and return the Dataproc job id.
590
+
591
+ Starts the cluster first if it has not been started. The cluster is not shut down after the job completes.
592
+ Call shutdown or use the context manager when all jobs are done.
593
+
594
+ Args:
595
+ script_uri: gs:// URI of the main Python script to run.
596
+ args: Positional arguments passed to the script.
597
+ file_uris: Extra files localised into the job working directory
598
+ on the cluster. Matches the Dataproc pyspark_job.file_uris field.
599
+ properties: Extra Spark or Dataproc job properties.
600
+
601
+ Returns:
602
+ The Dataproc job id assigned by the API.
603
+ """
604
+ if not self._started:
605
+ self.start()
606
+
607
+ pyspark_job: dict[str, Any] = {
608
+ 'main_python_file_uri': script_uri,
609
+ # Jar paths are set by init_notebook.py in spark-defaults.conf.
610
+ 'properties': dict(properties) if properties else {},
611
+ }
612
+ if args:
613
+ pyspark_job['args'] = list(args)
614
+ if file_uris:
615
+ pyspark_job['file_uris'] = list(file_uris)
616
+
617
+ job = {
618
+ 'placement': {'cluster_name': self._name},
619
+ 'pyspark_job': pyspark_job,
620
+ }
621
+
622
+ print(f"Submitting Hail job to cluster '{self._name}'...")
623
+ result = self._job_client.submit_job(
624
+ request={
625
+ 'project_id': self._project,
626
+ 'region': self._region,
627
+ 'job': job,
628
+ },
629
+ )
630
+ job_id = result.reference.job_id
631
+ self._job_ids.append(job_id)
632
+ print(f'Job submitted: {job_id}')
633
+ return job_id
634
+
635
+ def wait_for_job(
636
+ self,
637
+ job_id: str,
638
+ *,
639
+ stream_logs: bool = True,
640
+ ) -> dataproc_v1.Job:
641
+ """
642
+ Poll a submitted job until it reaches a terminal state.
643
+
644
+ When stream_logs is true, the job driver stdout is streamed to this process on a background thread
645
+ so logs appear in orchestrators like Seqera in near-real time.
646
+ The streaming thread is stopped whether the poll returns normally or raises.
647
+
648
+ Args:
649
+ job_id: The Dataproc job id returned by submit_job.
650
+ stream_logs: Whether to stream driver stdout in the background.
651
+
652
+ Returns:
653
+ The final Dataproc Job on success.
654
+
655
+ Raises:
656
+ DataprocJobError: If the job reaches ERROR or CANCELLED instead
657
+ of DONE.
658
+ """
659
+ stop_event = threading.Event()
660
+ log_thread: threading.Thread | None = None
661
+ if stream_logs:
662
+ log_thread = threading.Thread(
663
+ target=_stream_driver_logs,
664
+ args=(
665
+ self._job_client,
666
+ self._storage_client,
667
+ self._project,
668
+ self._region,
669
+ job_id,
670
+ stop_event,
671
+ ),
672
+ daemon=True,
673
+ )
674
+ log_thread.start()
675
+ try:
676
+ return _poll_job(self._job_client, self._project, self._region, job_id)
677
+ finally:
678
+ stop_event.set()
679
+ if log_thread is not None:
680
+ log_thread.join(timeout=30)
681
+
682
+ def run_job(
683
+ self,
684
+ script_uri: str,
685
+ *,
686
+ args: list[str] | None = None,
687
+ file_uris: list[str] | None = None,
688
+ properties: dict[str, str] | None = None,
689
+ stream_logs: bool = True,
690
+ ) -> dataproc_v1.Job:
691
+ """Submit a job and wait for it to reach a terminal state.
692
+
693
+ Convenience wrapper over submit_job followed by wait_for_job.
694
+
695
+ Args:
696
+ script_uri: gs:// URI of the main Python script to run.
697
+ args: Positional arguments passed to the script.
698
+ file_uris: Extra files localised into the job working directory.
699
+ properties: Extra Spark or Dataproc job properties.
700
+ stream_logs: Whether to stream driver stdout in the background.
701
+
702
+ Returns:
703
+ The final Dataproc Job on success.
704
+
705
+ Raises:
706
+ DataprocJobError: If the job fails, so orchestrator scripts
707
+ propagate a non-zero exit on failure.
708
+ """
709
+ job_id = self.submit_job(
710
+ script_uri,
711
+ args=args,
712
+ file_uris=file_uris,
713
+ properties=properties,
714
+ )
715
+ return self.wait_for_job(job_id, stream_logs=stream_logs)
716
+
717
+ def shutdown(self) -> None:
718
+ """Delete the cluster.
719
+
720
+ Idempotent and never raises. Warnings are printed to stderr on failure
721
+ so this remains safe to call from a finally block or a signal handler.
722
+ """
723
+ if self._shutdown_called:
724
+ return
725
+ self._shutdown_called = True
726
+ if not self._started:
727
+ return
728
+ print(f"Deleting cluster '{self._name}'...")
729
+ try:
730
+ operation = self._cluster_client.delete_cluster(
731
+ request={
732
+ 'project_id': self._project,
733
+ 'region': self._region,
734
+ 'cluster_name': self._name,
735
+ },
736
+ )
737
+ operation.result()
738
+ print(f"Cluster '{self._name}' deleted.")
739
+ except Exception as e: # noqa: BLE001 - cleanup path must not raise
740
+ print(
741
+ f"Warning: failed to delete cluster '{self._name}': {e}",
742
+ file=sys.stderr,
743
+ )
744
+
745
+ def __enter__(self) -> 'HailDataprocCluster':
746
+ self.start()
747
+ return self
748
+
749
+ def __exit__(
750
+ self,
751
+ exc_type: type[BaseException] | None,
752
+ exc_val: BaseException | None,
753
+ exc_tb: TracebackType | None,
754
+ ) -> None:
755
+ self.shutdown()
756
+
757
+
758
+ def install_sigterm_cleanup(cluster: HailDataprocCluster) -> None:
759
+ """Install a SIGTERM handler that shuts down a cluster and exits with code 1.
760
+
761
+ Useful for pipeline orchestrators such as Nextflow that send SIGTERM on
762
+ cancellation. Must be called from the main thread because Python signal
763
+ registration only works there.
764
+
765
+ Args:
766
+ cluster: The cluster to shut down when SIGTERM is received.
767
+ """
768
+
769
+ def handler(signum: int, _frame: FrameType | None) -> None:
770
+ print(
771
+ f'Received signal {signum}, shutting down cluster {cluster.name}...',
772
+ file=sys.stderr,
773
+ )
774
+ try:
775
+ cluster.shutdown()
776
+ finally:
777
+ sys.exit(1)
778
+
779
+ signal.signal(signal.SIGTERM, handler)
780
+
781
+
782
+ def _tail_blob_to_stdout(blob: storage.Blob, offset: int) -> int:
783
+ """Write any bytes beyond offset in blob to stdout and return the new offset.
784
+
785
+ Uses a metadata reload followed by a range download so only the new bytes
786
+ are transferred each tick.
787
+
788
+ Args:
789
+ blob: The GCS blob to read from.
790
+ offset: Number of bytes already streamed from this blob.
791
+
792
+ Returns:
793
+ The updated byte offset after any new bytes were written.
794
+
795
+ Raises:
796
+ google.api_core.exceptions.NotFound: If the blob does not yet exist.
797
+ The caller decides whether to keep polling.
798
+ """
799
+ blob.reload()
800
+ size = blob.size or 0
801
+ if size <= offset:
802
+ return offset
803
+ chunk = blob.download_as_bytes(start=offset, end=size - 1)
804
+ sys.stdout.write(chunk.decode('utf-8', errors='replace'))
805
+ sys.stdout.flush()
806
+ return size
807
+
808
+
809
+ def _stream_driver_logs(
810
+ job_client: dataproc_v1.JobControllerClient,
811
+ storage_client: storage.Client,
812
+ project: str,
813
+ region: str,
814
+ job_id: str,
815
+ stop_event: threading.Event,
816
+ poll_interval: float = DEFAULT_LOG_POLL_SECONDS,
817
+ ) -> None:
818
+ """Stream Dataproc job driver stdout from GCS to this process.
819
+
820
+ Dataproc writes driver stdout to rolling numbered files under the job
821
+ driver_output_resource_uri, ending in .000000000, .000000001, and so on.
822
+ Mirrors the behaviour of gcloud dataproc jobs wait.
823
+
824
+ Args:
825
+ job_client: Dataproc job controller client used to look up the driver
826
+ output URI.
827
+ storage_client: GCS storage client used to read the driver output.
828
+ project: GCP project id.
829
+ region: Dataproc region.
830
+ job_id: Job id whose driver output should be streamed.
831
+ stop_event: Event set by the caller to signal the streamer to exit.
832
+ poll_interval: Seconds to sleep between polls.
833
+ """
834
+ file_index = 0
835
+ offset = 0
836
+ uri_prefix: str | None = None
837
+ print(f'[log-stream] started for job {job_id}', flush=True)
838
+ while not stop_event.is_set():
839
+ if uri_prefix is None:
840
+ try:
841
+ job = job_client.get_job(
842
+ request={
843
+ 'project_id': project,
844
+ 'region': region,
845
+ 'job_id': job_id,
846
+ },
847
+ )
848
+ uri_prefix = job.driver_output_resource_uri or None
849
+ except gax_exceptions.GoogleAPIError:
850
+ print(f'[log-stream] failed to get job {job_id}', flush=True)
851
+ if not uri_prefix:
852
+ time.sleep(poll_interval)
853
+ continue
854
+ print(f'[log-stream] found driver output at {uri_prefix}', flush=True)
855
+
856
+ try:
857
+ path = uri_prefix.removeprefix('gs://')
858
+ bucket_name, blob_prefix = path.split('/', 1)
859
+ bucket = storage_client.bucket(bucket_name)
860
+ blob = bucket.blob(f'{blob_prefix}.{file_index:09d}')
861
+ try:
862
+ offset = _tail_blob_to_stdout(blob, offset)
863
+ except gax_exceptions.NotFound:
864
+ # Current-index blob hasn't been created yet; try again next tick.
865
+ pass
866
+ else:
867
+ next_blob = bucket.blob(f'{blob_prefix}.{file_index + 1:09d}')
868
+ if next_blob.exists():
869
+ # One final range-read of the current file: Dataproc may have
870
+ # appended bytes between our last download and the roll.
871
+ with suppress(gax_exceptions.NotFound):
872
+ offset = _tail_blob_to_stdout(blob, offset)
873
+ print(
874
+ f'[log-stream] rolling to output file {file_index + 1}',
875
+ flush=True,
876
+ )
877
+ file_index += 1
878
+ offset = 0
879
+ except gax_exceptions.GoogleAPIError as e:
880
+ print(f'[log-stream] warning: {e}', file=sys.stderr)
881
+
882
+ time.sleep(poll_interval)
883
+ print(f'[log-stream] stopped for job {job_id}', flush=True)
884
+
885
+
886
+ def _poll_job(
887
+ job_client: dataproc_v1.JobControllerClient,
888
+ project: str,
889
+ region: str,
890
+ job_id: str,
891
+ initial_interval: float = DEFAULT_JOB_POLL_INITIAL_SECONDS,
892
+ max_interval: float = DEFAULT_JOB_POLL_MAX_SECONDS,
893
+ backoff: float = DEFAULT_JOB_POLL_BACKOFF,
894
+ ) -> dataproc_v1.Job:
895
+ """Poll a job id until it reaches a terminal state.
896
+
897
+ Args:
898
+ job_client: Dataproc job controller client used to fetch the job.
899
+ project: GCP project id.
900
+ region: Dataproc region.
901
+ job_id: Dataproc job id.
902
+ initial_interval: Seconds to sleep between the first two polls.
903
+ max_interval: Ceiling on the polling interval.
904
+ backoff: multiple applied to the interval after each poll, up to max_interval.
905
+
906
+ Returns:
907
+ The final Dataproc Job on success.
908
+
909
+ Raises:
910
+ DataprocJobError: If the job reaches a non-DONE terminal state, so
911
+ orchestrators propagate a non-zero exit rather than silently
912
+ continuing.
913
+ """
914
+ poll_interval = initial_interval
915
+ while True:
916
+ job = job_client.get_job(
917
+ request={'project_id': project, 'region': region, 'job_id': job_id},
918
+ )
919
+ state = dataproc_v1.JobStatus.State(job.status.state).name
920
+ if state in _TERMINAL_ERROR_STATES:
921
+ raise DataprocJobError(job, state)
922
+ if state == 'DONE':
923
+ print(f'Job {job_id} completed successfully.')
924
+ return job
925
+ print(f'Job {job_id} state: {state} (polling in {poll_interval}s)')
926
+ time.sleep(poll_interval)
927
+ poll_interval = min(poll_interval * backoff, max_interval)
@@ -62,7 +62,6 @@ def get_batch(
62
62
  ) -> 'Batch':
63
63
  """
64
64
  Wrapper around Hail's `Batch` class, which allows to register created jobs
65
- This has been migrated (currently duplicated) out of cpg_workflows
66
65
 
67
66
  Parameters
68
67
  ----------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cpg-utils
3
- Version: 5.7.2
3
+ Version: 5.8.0
4
4
  Summary: Library of convenience functions specific to the CPG
5
5
  Home-page: https://github.com/populationgenomics/cpg-utils
6
6
  License: MIT
@@ -24,8 +24,11 @@ Requires-Dist: frozendict
24
24
  Requires-Dist: google-auth>=1.27.0
25
25
  Requires-Dist: google-auth-oauthlib
26
26
  Requires-Dist: google-cloud-artifact-registry
27
+ Requires-Dist: google-cloud-dataproc
27
28
  Requires-Dist: google-cloud-secret-manager
29
+ Requires-Dist: google-cloud-storage
28
30
  Requires-Dist: metamist
31
+ Requires-Dist: python-slugify
29
32
  Requires-Dist: requests
30
33
  Requires-Dist: tabulate
31
34
  Requires-Dist: toml
@@ -10,6 +10,7 @@ cpg_utils/constants.py
10
10
  cpg_utils/cromwell.py
11
11
  cpg_utils/cromwell_model.py
12
12
  cpg_utils/dataproc.py
13
+ cpg_utils/dataproc_runner.py
13
14
  cpg_utils/existence_checks.py
14
15
  cpg_utils/git.py
15
16
  cpg_utils/hail_batch.py
@@ -25,5 +26,5 @@ cpg_utils.egg-info/top_level.txt
25
26
  test/__init__.py
26
27
  test/test_config.py
27
28
  test/test_cromwell.py
28
- test/test_doctests.py
29
+ test/test_dataproc_runner.py
29
30
  test/test_get_google_identity_token.py
@@ -5,8 +5,11 @@ frozendict
5
5
  google-auth>=1.27.0
6
6
  google-auth-oauthlib
7
7
  google-cloud-artifact-registry
8
+ google-cloud-dataproc
8
9
  google-cloud-secret-manager
10
+ google-cloud-storage
9
11
  metamist
12
+ python-slugify
10
13
  requests
11
14
  tabulate
12
15
  toml
@@ -48,8 +48,11 @@ cpg = [
48
48
  "analysis_runner",
49
49
  "cpg_infra",
50
50
  "cpg_utils",
51
- "cpg_workflows",
52
51
  "gnomad",
53
52
  "hail_scripts",
54
53
  "metamist",
55
54
  ]
55
+
56
+ [tool.pytest.ini_options]
57
+ addopts = "--doctest-modules"
58
+ testpaths = ["test", "cpg_utils"]
@@ -8,7 +8,7 @@ with open('README.md') as f:
8
8
  setup(
9
9
  name='cpg-utils',
10
10
  # This tag is automatically updated by bumpversion
11
- version='5.7.2',
11
+ version='5.8.0',
12
12
  description='Library of convenience functions specific to the CPG',
13
13
  long_description=long_description,
14
14
  long_description_content_type='text/markdown',
@@ -26,8 +26,11 @@ setup(
26
26
  'google-auth>=1.27.0',
27
27
  'google-auth-oauthlib',
28
28
  'google-cloud-artifact-registry',
29
+ 'google-cloud-dataproc',
29
30
  'google-cloud-secret-manager',
31
+ 'google-cloud-storage',
30
32
  'metamist',
33
+ 'python-slugify',
31
34
  'requests',
32
35
  'tabulate',
33
36
  'toml',
@@ -12,6 +12,7 @@ import toml
12
12
  from cpg_utils.config import (
13
13
  ConfigError,
14
14
  config_retrieve,
15
+ dataset_for_access_level,
15
16
  dataset_path,
16
17
  get_config,
17
18
  image_path,
@@ -204,3 +205,19 @@ class TestConfig(TestCase):
204
205
  """
205
206
  mock_get_config.return_value = {'images': {'image-name': 'image/path:version'}}
206
207
  self.assertEqual('image/path:version', image_path('image-name'))
208
+
209
+ @patch('cpg_utils.config.get_config')
210
+ def test_dataset_access_levels_test(self, mock_get_config: MagicMock):
211
+ """
212
+ Test image_path
213
+ """
214
+ mock_get_config.return_value = {'workflow': {'access_level': 'test'}}
215
+ self.assertEqual(dataset_for_access_level('dataset'), 'dataset-test')
216
+
217
+ @patch('cpg_utils.config.get_config')
218
+ def test_dataset_access_levels_standard(self, mock_get_config: MagicMock):
219
+ """
220
+ Test image_path
221
+ """
222
+ mock_get_config.return_value = {'workflow': {'access_level': 'standard'}}
223
+ self.assertEqual(dataset_for_access_level('dataset'), 'dataset')
@@ -0,0 +1,160 @@
1
+ import unittest
2
+ from unittest.mock import MagicMock
3
+
4
+ import pytest
5
+
6
+ from cpg_utils.dataproc_runner import (
7
+ DEFAULT_HAIL_VERSION,
8
+ HailDataprocCluster,
9
+ parse_label_kvs,
10
+ resolve_autoscaling_policy_uri,
11
+ sanitise_labels,
12
+ )
13
+
14
+
15
+ def _make_cluster(**kwargs) -> HailDataprocCluster: # noqa: ANN003
16
+ return HailDataprocCluster(
17
+ project='test-project',
18
+ region='australia-southeast1',
19
+ cluster_name_prefix='test-cluster',
20
+ staging_bucket='gs://staging',
21
+ temp_bucket='gs://temp',
22
+ cluster_client=MagicMock(),
23
+ job_client=MagicMock(),
24
+ storage_client=MagicMock(),
25
+ **kwargs,
26
+ )
27
+
28
+
29
+ class TestDataprocRunnerPackages(unittest.TestCase):
30
+ def test_default_packages_populate_pkgs_metadata(self):
31
+ cluster = _make_cluster()
32
+ config = cluster._build_cluster_config()
33
+ pkgs = config['config']['gce_cluster_config']['metadata']['PKGS']
34
+ self.assertEqual(pkgs, f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}')
35
+
36
+ def test_custom_packages_populate_pkgs_metadata(self):
37
+ cluster = _make_cluster(packages=['cpg-utils', 'hail==0.2.999', 'gnomad'])
38
+ config = cluster._build_cluster_config()
39
+ pkgs = config['config']['gce_cluster_config']['metadata']['PKGS']
40
+ self.assertEqual(pkgs, 'cpg-utils|hail==0.2.999|gnomad')
41
+
42
+ def test_empty_pkgs(self):
43
+ cluster = _make_cluster(packages=[])
44
+ config = cluster._build_cluster_config()
45
+ self.assertEqual(
46
+ config['config']['gce_cluster_config']['metadata'].get('PKGS'),
47
+ f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}',
48
+ )
49
+
50
+ def test_null_pkgs(self):
51
+ cluster = _make_cluster(packages=None)
52
+ config = cluster._build_cluster_config()
53
+ self.assertEqual(
54
+ config['config']['gce_cluster_config']['metadata']['PKGS'],
55
+ f'cpg-utils|hail=={DEFAULT_HAIL_VERSION}',
56
+ )
57
+
58
+
59
+ class TestDataprocRunnerServiceAccount(unittest.TestCase):
60
+ def test_service_account_omitted_by_default(self):
61
+ cluster = _make_cluster()
62
+ config = cluster._build_cluster_config()
63
+ self.assertIsNone(cluster.service_account)
64
+ self.assertNotIn(
65
+ 'service_account',
66
+ config['config']['gce_cluster_config'],
67
+ )
68
+
69
+ def test_explicit_service_account(self):
70
+ sa = 'dataproc-test@test-project.iam.gserviceaccount.com'
71
+ cluster = _make_cluster(service_account=sa)
72
+ config = cluster._build_cluster_config()
73
+ self.assertEqual(cluster.service_account, sa)
74
+ self.assertEqual(
75
+ config['config']['gce_cluster_config']['service_account'],
76
+ sa,
77
+ )
78
+
79
+
80
+ _DISK_ROLES = ('master_config', 'worker_config', 'secondary_worker_config')
81
+
82
+
83
+ class TestDataprocRunnerDiskConfig(unittest.TestCase):
84
+ def test_default_disk_config(self):
85
+ config = _make_cluster()._build_cluster_config()['config']
86
+ for role in _DISK_ROLES:
87
+ disk_config = config[role]['disk_config']
88
+ self.assertEqual(disk_config['boot_disk_type'], 'pd-standard')
89
+ # num_local_ssds is omitted by default rather than set to 0.
90
+ self.assertNotIn('num_local_ssds', disk_config)
91
+
92
+ def test_custom_boot_disk_type(self):
93
+ config = _make_cluster(boot_disk_type='pd-ssd')._build_cluster_config()[
94
+ 'config'
95
+ ]
96
+ for role in _DISK_ROLES:
97
+ self.assertEqual(config[role]['disk_config']['boot_disk_type'], 'pd-ssd')
98
+
99
+ def test_local_ssds_added_to_every_role(self):
100
+ config = _make_cluster(num_local_ssds=2)._build_cluster_config()['config']
101
+ for role in _DISK_ROLES:
102
+ self.assertEqual(config[role]['disk_config']['num_local_ssds'], 2)
103
+
104
+
105
+ def test_good_keys():
106
+ kv_pairs = ['good=key', 'also=good']
107
+ assert parse_label_kvs(kv_pairs) == {'good': 'key', 'also': 'good'}
108
+
109
+
110
+ def test_missing_equals_keys():
111
+ kv_pairs = ['good=key', 'bad']
112
+ with pytest.raises(ValueError) as ve:
113
+ parse_label_kvs(kv_pairs)
114
+ assert (
115
+ 'One or more key=value parameters did not contain an equals character, so they could'
116
+ in str(ve.value)
117
+ )
118
+
119
+
120
+ def test_key_sanitation():
121
+ kv_pairs = ['good=key', 'also=good', r'what\'s_this!=#ARGH#']
122
+ assert parse_label_kvs(kv_pairs) == {
123
+ 'good': 'key',
124
+ 'also': 'good',
125
+ 'what-s_this': 'argh',
126
+ }
127
+
128
+
129
+ def test_sanitise_labels_good():
130
+ assert sanitise_labels({'AR-GUID': 'Abc123', 'Bad Key!': 'x'}) == {
131
+ 'ar-guid': 'abc123',
132
+ 'bad-key': 'x',
133
+ }
134
+
135
+
136
+ def test_sanitise_labels_bad():
137
+ with pytest.raises(ValueError) as ve:
138
+ sanitise_labels({'1AR-GUID': 'Abc123'})
139
+ assert "Failures: {'1ar-guid': 'abc123'}" in str(ve.value)
140
+
141
+
142
+ def test_autoscaling_policy_resolver():
143
+ assert resolve_autoscaling_policy_uri(policy_ref='projects/stub') == 'projects/stub'
144
+
145
+
146
+ def test_autoscaling_policy_resolver_from_parts():
147
+ assert (
148
+ resolve_autoscaling_policy_uri(
149
+ policy_ref='1234',
150
+ project='project',
151
+ region='aus',
152
+ )
153
+ == 'projects/project/regions/aus/autoscalingPolicies/1234'
154
+ )
155
+
156
+
157
+ def test_autoscaling_policy_resolver_fails():
158
+ with pytest.raises(ValueError) as ve:
159
+ _ = resolve_autoscaling_policy_uri(policy_ref='not_projects/stub')
160
+ assert 'Invalid autoscaling policy' in str(ve.value)
@@ -1,22 +0,0 @@
1
- import doctest
2
- import pkgutil
3
- import unittest
4
-
5
- import cpg_utils
6
-
7
-
8
- def load_tests(
9
- loader: unittest.TestLoader, # noqa: ARG001
10
- tests: unittest.TestSuite,
11
- ignore: str | None, # noqa: ARG001
12
- ) -> unittest.TestSuite:
13
-
14
- # Load all doctests from the cpg_utils package
15
- for module in pkgutil.iter_modules(cpg_utils.__path__):
16
- tests.addTests(doctest.DocTestSuite('cpg_utils.' + module.name))
17
-
18
- return tests
19
-
20
-
21
- if __name__ == "__main__":
22
- unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes