Source code for pudl.dagster.provenance

"""Helpers for recording asset provenance and checking compatibility.

This module builds and interprets Dagster materialization metadata for assets so
downstream consumers can verify that the data they are using was created with compatible
inputs. Put provenance fingerprints, metadata builders, and compatibility checks here
when they describe the identity of a materialized asset, rather than the extraction
logic that produces the asset itself.

For the closest Dagster concept, see
https://docs.dagster.io/guides/build/assets/metadata-and-tags
"""

import json
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
from typing import Literal

import dagster as dg
from pydantic import BaseModel
from upath import UPath

import pudl.logging_helpers
from pudl.settings import FercToSqliteDataConfig

[docs] logger = pudl.logging_helpers.get_logger(__name__)
[docs] FERC_TO_SQLITE_METADATA_KEY = "ferc_to_sqlite"
[docs] def _get_ferc_to_sqlite_asset_key(dataset: str, data_format: str) -> dg.AssetKey: """Return the asset key corresponding to a ferc_to_sqlite asset from dataset/format.""" return dg.AssetKey(f"raw_{dataset}_{data_format}__sqlite")
@dataclass(frozen=True)
[docs] class FercSqliteProvenance: """The provenance requirements derived from the current run's data config. Computed from ``data_config`` and ``zenodo_dois`` to describe what a compatible FERC SQLite prerequisite must contain. Used by :func:`assert_ferc_sqlite_compatible` to compare against the stored :class:`FercSqliteProvenanceRecord` that was written when the DB was built. """
[docs] dataset: str
[docs] data_format: str
[docs] zenodo_doi: str
[docs] years: list[int]
[docs] ferc_xbrl_extractor_version: str
@property
[docs] def asset_key(self) -> dg.AssetKey: """The AssetKey corresponding to the extracted SQLite database.""" return _get_ferc_to_sqlite_asset_key(self.dataset, self.data_format)
[docs] class FercSqliteProvenanceRecord(BaseModel): """Stored provenance + extra debugging fields from materialization time."""
[docs] dataset: str
[docs] data_format: Literal["dbf", "xbrl"]
[docs] status: Literal["complete", "not_configured"]
[docs] source: Literal["nightly", "local_cache", "local_new"]
[docs] zenodo_doi: str | None = None
[docs] years: list[int] | None = None
[docs] data_config: FercToSqliteDataConfig | None = None
[docs] ferc_xbrl_extractor_version: str | None = None
@classmethod
[docs] def from_dagster_instance( cls, instance: dg.DagsterInstance, dataset: str, data_format: str, ) -> "FercSqliteProvenanceRecord": """Return FercSqliteProvenanceRecord from dagster metadata if available. Raises: RuntimeError: if no Dagster provenance metadata is available. """ asset_key = _get_ferc_to_sqlite_asset_key(dataset, data_format) event = instance.get_latest_materialization_event(asset_key) materialization = None if event is None else event.asset_materialization raw_payload = ( None if materialization is None else materialization.metadata.get(FERC_TO_SQLITE_METADATA_KEY) ) payload = raw_payload.value if hasattr(raw_payload, "value") else raw_payload if not isinstance(payload, dict): raise RuntimeError( "No Dagster provenance metadata is available for " f"{asset_key.to_user_string()}. Refresh the FERC SQLite assets." ) return cls(**payload)
[docs] def to_datapackage(self, datapackage_path: Path): """Write Provenance data to datapackage JSON file.""" json_dict = json.loads(datapackage_path.read_text()) json_dict["provenance_metadata"] = self.model_dump(mode="json") datapackage_path.write_text(json.dumps(json_dict, indent=2))
@classmethod
[docs] def from_datapackage( cls, datapackage_path: UPath, source: Literal["nightly", "local_cache", "local_new"], ) -> "FercSqliteProvenanceRecord": """Read SQLite provenance metadata from datapackage JSON file. Note that this method accepts ``datapackage_path`` as a ``UPath`` as we read provenance metadata directly from nightly builds, but ``to_datapackage`` only accepts a regular ``Path``, as we should never try to write directly to s3. """ if datapackage_path.exists(): json_dict = json.loads(datapackage_path.read_text()) if (provenance := json_dict.get("provenance_metadata", None)) is not None: provenance["source"] = source return cls.model_validate(provenance) # Handle legacy datapackages that didn't contain logger.warning(f"{datapackage_path} does not contain provenance metadata.") else: # Handle missing datapackage, which typically means you haven't run ferc_sqlite yet logger.warning(f"{datapackage_path} does not exist.") # If we get here that means we couldn't find provenance metadata return None
[docs] def get_xbrl_extractor_version() -> str: """Return the installed version of ``catalystcoop.ferc_xbrl_extractor``.""" return version("catalystcoop.ferc_xbrl_extractor")
[docs] def ferc_sqlite_provenance_is_compatible( *, observed_provenance: FercSqliteProvenanceRecord | None, required_provenance: FercSqliteProvenance, ) -> bool: """Ensure a persisted FERC SQLite prerequisite is compatible with this run. Compatibility requires three conditions to hold: 1. The Zenodo DOI recorded when the FERC SQLite DB was built must match the current :class:`~pudl.workspace.datastore.ZenodoDoiSettings`. A mismatch means the raw archive has changed version and the DB must be rebuilt. 2. The years stored in the FERC SQLite DB must be a *superset* of the years needed by the current downstream data config. This allows a "full" FERC SQLite DB to serve a "fast" downstream run without an expensive rebuild. 3. The version of ``ferc_xbrl_extractor`` is the same for XBRL derived data. """ if observed_provenance is None: logger.warning( "No observed provenance provided. This usually indicates that a datapackage was " "created before the provenance metadata feature was added, or that the datapackage " "does not exist locally." ) return False if observed_provenance.status == "not_configured": logger.warning( f"Stored provenance metadata for {required_provenance.asset_key.to_user_string()} has " f"status={observed_provenance.status!r}: the DB was built from a run that had no years " "configured for this form. Refresh the FERC SQLite assets with years configured." ) return False if observed_provenance.status != "complete": logger.warning( f"Stored provenance metadata for {required_provenance.asset_key.to_user_string()} has " f"status={observed_provenance.status!r}. Refresh the FERC SQLite assets." ) return False if observed_provenance.zenodo_doi is None or observed_provenance.years is None: return False mismatches: list[str] = [] if observed_provenance.zenodo_doi != required_provenance.zenodo_doi: mismatches.append( "Zenodo DOI mismatch: " f"stored={observed_provenance.zenodo_doi!r}, " f"expected={required_provenance.zenodo_doi!r}" ) stored_years: set[int] = set(observed_provenance.years) required_years: set[int] = set(required_provenance.years) missing_years: set[int] = required_years - stored_years if missing_years: mismatches.append( "FERC SQLite DB is missing required years: " f"missing={sorted(missing_years)}, " f"stored={sorted(stored_years)}, " f"required={sorted(required_years)}" ) if ( observed_provenance.ferc_xbrl_extractor_version != required_provenance.ferc_xbrl_extractor_version ) and (required_provenance.data_format == "xbrl"): mismatches.append( "FERC SQLite DB created with incompatible version of the XBRL extractor: " f"stored={observed_provenance.ferc_xbrl_extractor_version}, " f"required={required_provenance.ferc_xbrl_extractor_version}" ) if mismatches: mismatch_summary: str = "; ".join(mismatches) logger.warning( f"Stored prerequisite asset {required_provenance.asset_key.to_user_string()} is not " f"compatible with the current run configuration. {mismatch_summary}. " ) return False return True