Source code for bhf_smarthealth.utils.workflow_utils

"""This module contains utility functions for common Snakemake workflow actions"""

import re
import logging
import boto3

from pathlib import Path
from dataclasses import dataclass
from enum import StrEnum, auto
from datetime import date, timedelta, datetime
from snakemake.io import expand
from typing import Any
from uuid import uuid4

from bhf_smarthealth.models import BucketNames, PartitionFreqs
from bhf_smarthealth.config import CLEAN_TABLE_REGISTRY

logger = logging.getLogger(__name__)


[docs] @dataclass class ValidTargets: """Class to hold valid date and type target combinations""" date: str type: str
[docs] class SENTINEL_FLAGS(StrEnum): """Configure naming of Snakemake sentinel flags""" PROCESSING_COMPLETE = auto() UPLOAD_RAW_COMPLETE = auto() UPLOAD_INTERMEDIATE_COMPLETE = auto() DATABASE_LOAD_COMPLETE = auto() DATASTREAM_SUCCESS = auto() PIPELINE_ERROR = auto() PIPELINE_SUCCESS = auto()
[docs] def read_or_generate_run_id(filepath: Path | str) -> str: """Check whether a run_id file has been generated by a snakemake pipeline run, else generate a new run_id file. This is required to ensure persistent run_id state throughout a pipeline run. Parameters ---------- filepath : Path | str Path, or string of path, to the lockfile that contains, or should contain, the run_id Returns ------- str String of the generated run_id Warning ------- The output file must be deleted by the pipeline onsuccess or onerror to ensure a new run_id is generated. """ if isinstance(filepath, str): filepath = Path(filepath) # read existing run_id if filepath.exists(): with open(filepath, "r") as f: run_id = f.read().strip() # generate new run_id else: filepath.parent.mkdir(parents=True, exist_ok=True) # ensure dir exists timestamp = datetime.now().strftime("%Y%m%d%H%M") short_id = uuid4().hex[ :6 ] # 0.03% collision chance for 100 runs within 1 minute run_id = f"{timestamp}_{short_id}" with open(filepath, "w") as f: f.write(run_id) return run_id
[docs] def get_bucket_credentials(profile: str) -> dict[str, str]: """Retrieve credentials from the s3 object storage associated with the specified 'profile' as configured in the .aws/config file Parameters ---------- profile : str String of profile name as configured through AWS CLI Returns ------- dict[str, str] Dict containing bucket credentials Raises ------ RuntimeError Raised if error retrieveing credentials for the specified profile """ try: creds = ( boto3.Session(profile_name=profile) .get_credentials() .get_frozen_credentials() ) return {"access_key": creds.access_key, "secret_key": creds.secret_key} except Exception as e: raise RuntimeError(f"Could not load credentials for profile {profile}: {e}")
[docs] def validate_date_to_process(date_to_process: str | date) -> date: """Validates that the date specified in the Snakemake config.yaml file is a valid ISO date or the string "today". Parameters ---------- date_to_process : str | date The DATE_TO_PROCESS config specified in the Snakemake config file Returns ------ validated_date : date A date object of the validated date to process Raises ------ ValueError Raised if the specified date is not a valid ISO 8601 date or 'today' """ if date_to_process == "today": validated_date = date.today() elif isinstance(date_to_process, date): validated_date = date_to_process else: raise ValueError( f"Specified DATE_TO_PROCESS {date_to_process} in config is not valid. Either enter 'today' or a date in the form 'YYYY-MM-DD'" ) return validated_date
[docs] def generate_dates(start_date: date, deltas: list[int]) -> list[str]: """Generates dates for days before the given start date, as specified by the values in 'deltas' 'deltas' must contain values that are greater than 0, values less than or equal to 0 will be ignored Duplicate values in deltas will also be ignored. Parameters ---------- start_date : date Date to generate other dates relative to deltas : list[int] List of deltas representing the interval (in days) for dates prior to the start date Returns ------- list[str] List of ISO8601 formatted date strings """ if len(deltas) > 1 and not all(delta > 0 for delta in deltas): logger.warning( "values less than 1 in deltas are invalid and will be ignored when generating dates" ) if len(deltas) == 1 and deltas[0] == 0: return [start_date.strftime("%Y%m%d")] dates = (start_date - timedelta(delta) for delta in deltas if delta > 0) # Use set comprehension to remove duplicate values dates_str = {dt.strftime("%Y%m%d") for dt in dates} logger.debug(f"Generated {len(list(dates_str))} dates from {len(deltas)} deltas") return list(dates_str)
[docs] def get_valid_day_deltas( data_type, all_dates, targets, wildcards, checkpoints, storage_provider ): """ 1. Runs the checkpoint to list archive contents. 2. Parses the text file to find available dates. 3. Returns targets only for valid dates. """ logger.info("Inspecting archive contents to determine valid deltas to process") # Force run checkpoint checkpoint_output = checkpoints.inspect_archive_contents.get(**wildcards).output[0] logger.debug(f"Checking for {all_dates} in {checkpoint_output}") # Read the created 7zip listing file with open(checkpoint_output, "r") as f: archive_content = f.read() # Check which dates exist in the archive listing available_dates = { d for d in all_dates if re.search(rf"{d}.*/{data_type}", archive_content) } # Compare with requested dates requested_set = set(all_dates) valid_dates = requested_set.intersection(available_dates) missing_dates = requested_set.difference(available_dates) if missing_dates: logger.warning( f"The following requested dates were not found in the archive and will be skipped: {sorted(missing_dates)}" ) logger.info( f"The following requested dates will be processed: {sorted(valid_dates)}" ) # Return targets target_files = expand(targets, date=valid_dates) return storage_provider(target_files)
[docs] def get_valid_clean_targets( config: dict[str, Any], ) -> list[ValidTargets]: """Return a list of ValidTargets objects that comprise the valid table,date combinations determined from the clean table registry and configured day deltas for each stream. Parameters ---------- config : dict[str, Any] Snakemake config dict (should contain the streams and their associated day deltas) Returns ------- list[ValidTargets] List of ValidTargets """ active_streams = [ stream for stream in config["STREAMS"] if config["STREAMS"][stream]["ACTIVE"] and stream != "CLEAN" ] logger.info(f"The following streams are active: {sorted(active_streams)}") valid_targets = [] for stream in active_streams: target_tables = [ table for table, properties in CLEAN_TABLE_REGISTRY.items() if stream.lower() == properties.source_stream and properties.bucket == BucketNames.CLEAN ] # currently assumes all day deltas are valid # TODO: enable state-aware dates i.e. know if day deltas were missing from Daiser archive dates = generate_dates( config["DATE_TO_PROCESS"], config["STREAMS"][stream]["DAY_DELTAS"] ) valid_targets.extend( [ ValidTargets(date=date, type=table) for date in dates for table in target_tables ] ) return valid_targets
[docs] def generate_s3_key(wildcards): """ Parses snakemake wildcards to generate file key depending on table partitioning """ table_name = wildcards.type if CLEAN_TABLE_REGISTRY[table_name].partition_freq == PartitionFreqs.DAILY: date_str = wildcards.date date_obj = datetime.strptime(date_str, "%Y%m%d") year = date_obj.year month = date_obj.month day = date_obj.day return f"clean/{table_name}/{year}/{month}/{day}/clean_{table_name}_{date_str}.parquet" if not CLEAN_TABLE_REGISTRY[table_name].partition_freq: return f"clean/{table_name}/clean_{table_name}.parquet"