Source code for bhf_smarthealth.validate.metadata_validation
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from bhf_smarthealth.utils import utils
from bhf_smarthealth.exceptions import MetadataValidationError, ISODateConversionError
from bhf_smarthealth.config import ENVIRONMENT
from bhf_smarthealth.models import Environments
logger = logging.getLogger(__name__)
ENVIRONMENT_ID_REGISTRY = {
Environments.CORE: [r"^UOS\d{5}$", r"^TEST_R_[a-zA-Z0-9]{15}$"],
Environments.ISOLATE_PROD: [r"^R_[a-zA-Z0-9]{15}$"],
Environments.ISOLATE_DEV: [
r"^UOS\d{5}$",
r"^TEST_R_[a-zA-Z0-9]{15}$",
r"^R_[a-zA-Z0-9]{15}$",
],
}
"""Registry of accetable regex patterns for IDs to be processed per RONIN environment"""
[docs]
@dataclass
class FileMetadata:
filename: str
sheffield_id: Optional[str]
file_date: Optional[str]
validated: bool = False # switch to show if this metadata is validated or not
[docs]
def extract_metadata_from_filename(filepath: Path) -> FileMetadata:
"""Extracts Sheffield ID (sheffield_id) and date metadata from a structured filename.
The function assumes the filename adheres to a specific format:
<userid>_<YYYYMMDD>_<activity>.<json>
Parameters
----------
filepath : Path
Path to file to extract metadata from
Returns
-------
FileMetadata
FileMetadata dataclass object. If metadata cannot be extracted, the sheffield_id and date will be returned as None.
"""
basename = filepath.name
match = re.match(r"^(\w+)_(\d{8})_(\w*)\.json$", basename)
if not match:
logger.debug(f"Filename does not match expected pattern: {basename}")
# return FileMetadata dataclass in all cases
return FileMetadata(filename=basename, sheffield_id=None, file_date=None)
extracted_id = match.group(1)
extracted_date = match.group(2)
return FileMetadata(
filename=basename,
sheffield_id=extracted_id,
file_date=extracted_date,
)
[docs]
def validate_metadata(
filepath: Path,
metadata: FileMetadata,
dates: str | list[str],
internal_id: Optional[str] = None,
) -> None:
"""Compare metadata extracted from a filename to its internal contents. Raised error will contain a list of errors found.
Parameters
----------
filepath : Path
Path to file being processed
metadata : FileMetadata
FileMetadata object containing the filename and extracted sheffield_id and date
dates : str | list[str]
A list of the dates, or datetimes, inside the file being processed to be validated against the FileMetadata.date extracted from the filename
Raises
------
MetadataValidationError
Raised if validation checks fail. Raised message contains a list of all errors found.
"""
error_messages = set()
if metadata.sheffield_id:
id_pattern = "|".join(ENVIRONMENT_ID_REGISTRY[ENVIRONMENT])
if not re.match(pattern=id_pattern, string=metadata.sheffield_id):
error_messages.add(
f"ID in filename ({metadata.sheffield_id}) is not suitable for processing in this RONIN environment ({ENVIRONMENT})"
)
# If an internal file ID is available, check it against the ID extracted from the filename
if internal_id:
if internal_id != metadata.sheffield_id:
error_messages.add(
f"ID in filename ({metadata.sheffield_id}) does not match the internal file ID ({internal_id})"
)
# Handle the case of only one internal file date
if isinstance(dates, str):
# wrap the single date in a list to normalise the input
dates = [dates]
# Ensure that the dates internal to the file can be converted to ISO8601 format
internal_date_set = set()
for date in dates:
try:
converted_date = utils.convert_str_to_isodate(date_str=date)
except ISODateConversionError:
error_messages.add(
f"File {metadata.filename} contains dates that cannot be converted to ISO8601"
)
else:
internal_date_set.add(converted_date)
# Check if the metadata was extracted successfully
if metadata.file_date is None:
error_messages.add(
f"Metadata could not be extracted from JSON file: {metadata.filename}"
)
else:
# Check that the extracted date is valid
try:
metadata.file_date = utils.convert_str_to_isodate(metadata.file_date)
except ISODateConversionError as e:
error_messages.add(
f"Extract metadata file date cannot be converted to a valid ISO8601 date.\nDate: {metadata.file_date}.\nError message: {e}"
)
# Check if there are any valid dates inside of the JSON file
if len(internal_date_set) == 0:
error_messages.add(f"No valid dates found in JSON: {metadata.filename}")
# Check if there are multiple different date values inside of the JSON file
elif len(internal_date_set) > 1:
error_messages.add(
f"Dates are inconsistent in JSON: {metadata.filename}. Number of unique dates found: {len(internal_date_set)}"
)
# Else check if the date extracted from the filename matches the unique internal date
# Compares even if no valid file_date is extracted from the metadata, as useful to know if internal file structure is ok
elif (
len(internal_date_set) == 1 and metadata.file_date != list(internal_date_set)[0]
):
error_messages.add(
f"File date {metadata.file_date} does not match consistent internal date {list(internal_date_set)[0]}"
)
# Combine and raise all errors logged
if error_messages:
final_message = (
f"Metadata validation failed for the following reasons:\n"
f"{('\n').join(error_messages)}"
)
raise MetadataValidationError(final_message)
else:
logger.debug("No metadata processing errors found")
[docs]
def process_metadata(
filepath: Path | str, dates: str | list[str], internal_id: Optional[str] = None
) -> FileMetadata:
"""Orchestrator function to ensure than extract_metadata and validate_metadata are run together.
Parameters
----------
filepath : Path | str
Path to file to be processed
dates : str | list[str]
A list of the dates, or datetimes, inside the file being processed to be validated against the FileMetadata.date extracted from the filename
Returns
-------
FileMetadata
FileMetadata object containing validated metadata
Raises
------
MetadataValidationError
Raised by validate_metadata if any validation checks fail
"""
if isinstance(filepath, str):
filepath = Path(filepath)
metadata = extract_metadata_from_filename(filepath)
validate_metadata(
filepath=filepath, dates=dates, metadata=metadata, internal_id=internal_id
)
# switch the validated bool to true now checks are complete. Only runs if validate_metadata is successful, else MetadataValidationError will have been raised
metadata.validated = True
return metadata