"""Dagster asset definitions for granular FERC-to-SQLite extraction.
This module defines the prerequisite assets that build the FERC DBF and XBRL derived
SQLite databases used elsewhere in the PUDL pipeline. It should contain asset factories,
resource requirements, and materialization metadata specific to those prerequisite
databases, rather than the downstream transforms that consume them.
"""
from collections.abc import Callable
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
from typing import Literal
from zipfile import ZipFile
import dagster as dg
from botocore.exceptions import (
ConnectTimeoutError,
EndpointConnectionError,
ReadTimeoutError,
)
from upath import UPath
import pudl.logging_helpers
from pudl import PUDL_EEL_HOLE_BASE_PATH
from pudl.dagster.provenance import (
FERC_TO_SQLITE_METADATA_KEY,
FercSqliteProvenance,
FercSqliteProvenanceRecord,
ferc_sqlite_provenance_is_compatible,
get_xbrl_extractor_version,
)
from pudl.extract.ferc import (
Ferc1DbfExtractor,
Ferc2DbfExtractor,
Ferc6DbfExtractor,
Ferc60DbfExtractor,
)
from pudl.extract.xbrl import FercXbrlDatastore, convert_form
from pudl.helpers import env_var_is_true
from pudl.settings import FercForm, FercToSqliteDataConfig
from pudl.workspace.setup import PudlPaths
[docs]
NETWORK_ERRORS = (
TimeoutError,
ConnectionError,
ConnectTimeoutError,
EndpointConnectionError,
ReadTimeoutError,
)
[docs]
logger = pudl.logging_helpers.get_logger(__name__)
@dataclass
[docs]
class FercPaths:
"""Helper class to get paths to various FERC paths both local and remote."""
# Store data_format so we can use it in ``delete_local_outputs``.
# DBF and XBRL outputs
[docs]
local_datapackage_path: Path
[docs]
nightly_datapackage_path: UPath
[docs]
local_sqlite_path: Path
[docs]
nightly_sqlite_path: UPath
# XBRL specific outputs
[docs]
local_duckdb_path: Path | None = None
[docs]
nightly_duckdb_path: UPath | None = None
[docs]
local_taxonomy_json_path: Path | None = None
[docs]
nightly_taxonomy_json_path: UPath | None = None
# Points to a directory of parquet files
[docs]
local_parquet_path: Path | None = None
# Points to zipfile containing parquet files
[docs]
nightly_parquet_path: UPath | None = None
[docs]
def delete_local_outputs(self):
"""Helper function to delete local outputs before starting extraction."""
self.local_sqlite_path.unlink(missing_ok=True)
self.local_datapackage_path.unlink(missing_ok=True)
if self.data_format == "xbrl":
self.local_duckdb_path.unlink(missing_ok=True)
self.local_taxonomy_json_path.unlink(missing_ok=True)
# Delete files in parquet dir
if self.local_parquet_path.exists():
[path.unlink() for path in self.local_parquet_path.iterdir()]
@classmethod
[docs]
def _download_zipped_outputs(
paths: FercPaths, output_format: Literal["sqlite", "parquet"]
):
"""Download nightly zipfile containing sqlite or parquet outputs and extract to local cache."""
if output_format == "sqlite":
nightly_path = paths.nightly_sqlite_path
local_path = paths.local_sqlite_path.parent
else:
nightly_path = paths.nightly_parquet_path
local_path = paths.local_parquet_path
local_path.mkdir(exist_ok=True)
with ZipFile(BytesIO(nightly_path.read_bytes())) as archive:
for member in archive.namelist():
if not member.endswith(f".{output_format}"):
continue
filename = Path(member).name
with archive.open(member) as f:
(local_path / filename).write_bytes(f.read())
[docs]
def _download_nightly_outputs(
data_format: Literal["dbf", "xbrl"],
paths: FercPaths,
) -> None:
"""Download ``ferc_to_sqlite`` outputs from s3.
This will download all outputs produced by the ``ferc_to_sqlite`` process for the
provided ``dataset`` and ``data_format``. For the 'DBF' format, this includes the
SQLite db and a datapackage JSON file, while 'XBRL' will include both of these
plus a DuckDB file, parquet files, and the taxonomy JSON file.
"""
# Download sqlite DB
_download_zipped_outputs(paths, output_format="sqlite")
# Download datapckage JSON
paths.local_datapackage_path.write_text(paths.nightly_datapackage_path.read_text())
# DBF only produces sqlite and datapackage, so return
if data_format == "dbf":
return
# Download taxonomy JSON
paths.local_taxonomy_json_path.write_bytes(
paths.nightly_taxonomy_json_path.read_bytes()
)
# Download duckdb DB / parquet
paths.local_duckdb_path.write_bytes(paths.nightly_duckdb_path.read_bytes())
_download_zipped_outputs(paths, output_format="parquet")
[docs]
def _check_for_cached_db_w_compatible_provenance(
dataset: FercForm,
data_format: Literal["dbf", "xbrl"],
zenodo_doi: str,
paths: FercPaths,
ferc_to_sqlite: FercToSqliteDataConfig,
) -> FercSqliteProvenanceRecord | None:
"""Check to see if there is a compatible outputs either locally, or in nightly builds.
This function will first check the local datapackage for the specified ``dataset``
and ``data_format`` to see if it contains a ``FercSqliteProvenanceRecord`` that
is compatible with the requirements of the current run. If the local datapckage doesn't
exist or contains an incompatible record, it will then download the datapackage produced
by the most recent nightly build and perform the same check. If the nightly
outputs are found to be compatible with the current run, then it will
download all associated outputs from that run. For DBF outputs, this includes
the SQLite file and the datapackage JSON file, while XBRL outputs also
include a duckdb file, parquet files, and a taxonomy JSON file.
If the environment variable, ``PUDL_FORCE_FERC_TO_SQLITE``, is set to ``true``, then
this function will immediately return ``None``, triggering the normal extraction.
Returns:
Compatible ``FercSqliteProvenanceRecord`` if one is found, otherwise ``None``.
"""
# Check if configured to force extraction
if env_var_is_true("PUDL_FORCE_FERC_TO_SQLITE"):
return None
# Assemble required provenance for current run
provenance = FercSqliteProvenance(
dataset=str(dataset),
data_format=data_format,
zenodo_doi=zenodo_doi,
years=ferc_to_sqlite.get_dataset_years(dataset, data_format),
ferc_xbrl_extractor_version=get_xbrl_extractor_version(),
)
compatible_metadata = None
# Check local datapackage first
local_provenance = FercSqliteProvenanceRecord.from_datapackage(
paths.local_datapackage_path, source="local_cache"
)
# Check if local or nightly datapackage contain compatible provenance metadata
if ferc_sqlite_provenance_is_compatible(
required_provenance=provenance, observed_provenance=local_provenance
):
logger.info(
f"Local outputs for {dataset}_{data_format} are compatible with current run."
)
return local_provenance
# Don't try to use nightly outputs in integration tests
# This is to avoid incompatibilities between fast / full outputs
if env_var_is_true("PUDL_INTEGRATION_TESTS"):
return None
# Check nightly provenance
try:
nightly_provenance = FercSqliteProvenanceRecord.from_datapackage(
paths.nightly_datapackage_path, source="nightly"
)
except NETWORK_ERRORS:
logger.warning(
f"Failed to download {dataset}_{data_format} datapackage to check provenance."
)
# This will cause ferc_sqlite_provenance_is_compatible to return False
nightly_provenance = None
if ferc_sqlite_provenance_is_compatible(
required_provenance=provenance,
observed_provenance=nightly_provenance,
):
try:
_download_nightly_outputs(
data_format=data_format,
paths=paths,
)
# At this point the local datapackage is overwritten by the nightly one
# This means we can grab the nightly provenance metadata from the local file
compatible_metadata = FercSqliteProvenanceRecord.from_datapackage(
paths.local_datapackage_path, source="nightly"
)
logger.info(
f"Nightly outputs for {dataset}_{data_format} are compatible with current run."
)
except NETWORK_ERRORS:
logger.warning(
f"Failed to download {dataset}_{data_format} outputs from"
" nightly builds. See: \n{e}"
)
if compatible_metadata is None:
logger.info(
f"Can't find a cached version of {dataset}_{data_format} with compatible provenance metadata."
" Extracting from scratch."
)
return compatible_metadata
[docs]
def ferc_to_sqlite_asset_factory(
*,
dataset: FercForm,
data_format: Literal["dbf", "xbrl"],
extract_function: Callable[[dg.AssetExecutionContext], None],
op_tags: dict | None = None,
) -> dg.AssetsDefinition:
"""Create a FERC-to-SQLite prerequisite asset for a specific FERC dataset."""
@dg.asset(
key=f"raw_{dataset}_{data_format}__sqlite",
group_name="raw_ferc_to_sqlite",
required_resource_keys={
"global_data_config",
"datastore",
"pudl_paths",
"runtime_settings",
"zenodo_dois",
},
tags={"dataset": str(dataset), "data_format": data_format},
op_tags=op_tags,
)
def _asset(context) -> dg.MaterializeResult[str]:
ferc_to_sqlite = context.resources.global_data_config.ferc_to_sqlite
data_config = ferc_to_sqlite.get_data_config(
dataset=dataset, data_format=data_format
)
zenodo_doi = context.resources.zenodo_dois.get_doi(str(dataset))
pudl_paths = context.resources.pudl_paths
ferc_paths = FercPaths.from_dataset_format(
dataset=dataset, data_format=data_format, paths=pudl_paths
)
if data_config is None or not data_config.years:
logger.info(
f"No years configured for {dataset}_{data_format}: skipping extraction."
)
return dg.MaterializeResult(
value="not_configured",
metadata={
FERC_TO_SQLITE_METADATA_KEY: dg.MetadataValue.json(
FercSqliteProvenanceRecord(
dataset=str(dataset),
data_format=data_format,
status="not_configured",
source="local_new",
).model_dump(mode="json")
)
},
)
# Check if there's a cached SQLite DB that is compatible
if (
provenance := _check_for_cached_db_w_compatible_provenance(
dataset=dataset,
data_format=data_format,
zenodo_doi=zenodo_doi,
paths=ferc_paths,
ferc_to_sqlite=ferc_to_sqlite,
)
) is None:
# Delete local outputs before starting extraction
ferc_paths.delete_local_outputs()
# Run extraction
extract_function(context)
provenance = FercSqliteProvenanceRecord(
dataset=str(dataset),
data_format=data_format,
status="complete",
source="local_new",
zenodo_doi=zenodo_doi,
years=ferc_to_sqlite.get_dataset_years(
dataset=dataset, data_format=data_format
),
data_config=ferc_to_sqlite,
ferc_xbrl_extractor_version=get_xbrl_extractor_version(),
)
provenance.to_datapackage(ferc_paths.local_datapackage_path)
else:
logger.info(
f"Found compatible cached SQLite DB for {dataset}_{data_format}. Skipping extraction."
)
# Return provenance metadata
return dg.MaterializeResult(
value="complete",
metadata={
FERC_TO_SQLITE_METADATA_KEY: dg.MetadataValue.json(
provenance.model_dump(mode="json")
)
},
)
return _asset
[docs]
raw_ferc1_dbf__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM1,
data_format="dbf",
extract_function=lambda context: Ferc1DbfExtractor(
datastore=context.resources.datastore,
data_config=context.resources.global_data_config.ferc_to_sqlite,
output_path=context.resources.pudl_paths.pudl_output,
).execute(),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc2_dbf__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM2,
data_format="dbf",
extract_function=lambda context: Ferc2DbfExtractor(
datastore=context.resources.datastore,
data_config=context.resources.global_data_config.ferc_to_sqlite,
output_path=context.resources.pudl_paths.pudl_output,
).execute(),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc6_dbf__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM6,
data_format="dbf",
extract_function=lambda context: Ferc6DbfExtractor(
datastore=context.resources.datastore,
data_config=context.resources.global_data_config.ferc_to_sqlite,
output_path=context.resources.pudl_paths.pudl_output,
).execute(),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc60_dbf__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM60,
data_format="dbf",
extract_function=lambda context: Ferc60DbfExtractor(
datastore=context.resources.datastore,
data_config=context.resources.global_data_config.ferc_to_sqlite,
output_path=context.resources.pudl_paths.pudl_output,
).execute(),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc1_xbrl__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM1,
data_format="xbrl",
extract_function=lambda context: convert_form(
ferc_to_sqlite=context.resources.global_data_config.ferc_to_sqlite,
form=FercForm.FORM1,
datastore=FercXbrlDatastore(context.resources.datastore),
pudl_paths=context.resources.pudl_paths,
batch_size=context.resources.runtime_settings.xbrl_batch_size,
workers=context.resources.runtime_settings.xbrl_num_workers,
loglevel=context.resources.runtime_settings.xbrl_loglevel,
),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc2_xbrl__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM2,
data_format="xbrl",
extract_function=lambda context: convert_form(
ferc_to_sqlite=context.resources.global_data_config.ferc_to_sqlite,
form=FercForm.FORM2,
datastore=FercXbrlDatastore(context.resources.datastore),
pudl_paths=context.resources.pudl_paths,
batch_size=context.resources.runtime_settings.xbrl_batch_size,
workers=context.resources.runtime_settings.xbrl_num_workers,
loglevel=context.resources.runtime_settings.xbrl_loglevel,
),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc6_xbrl__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM6,
data_format="xbrl",
extract_function=lambda context: convert_form(
ferc_to_sqlite=context.resources.global_data_config.ferc_to_sqlite,
form=FercForm.FORM6,
datastore=FercXbrlDatastore(context.resources.datastore),
pudl_paths=context.resources.pudl_paths,
batch_size=context.resources.runtime_settings.xbrl_batch_size,
workers=context.resources.runtime_settings.xbrl_num_workers,
loglevel=context.resources.runtime_settings.xbrl_loglevel,
),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc60_xbrl__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM60,
data_format="xbrl",
extract_function=lambda context: convert_form(
ferc_to_sqlite=context.resources.global_data_config.ferc_to_sqlite,
form=FercForm.FORM60,
datastore=FercXbrlDatastore(context.resources.datastore),
pudl_paths=context.resources.pudl_paths,
batch_size=context.resources.runtime_settings.xbrl_batch_size,
workers=context.resources.runtime_settings.xbrl_num_workers,
loglevel=context.resources.runtime_settings.xbrl_loglevel,
),
op_tags={"dagster/priority": 10},
)
[docs]
raw_ferc714_xbrl__sqlite = ferc_to_sqlite_asset_factory(
dataset=FercForm.FORM714,
data_format="xbrl",
extract_function=lambda context: convert_form(
ferc_to_sqlite=context.resources.global_data_config.ferc_to_sqlite,
form=FercForm.FORM714,
datastore=FercXbrlDatastore(context.resources.datastore),
pudl_paths=context.resources.pudl_paths,
batch_size=context.resources.runtime_settings.xbrl_batch_size,
workers=context.resources.runtime_settings.xbrl_num_workers,
loglevel=context.resources.runtime_settings.xbrl_loglevel,
),
op_tags={"dagster/priority": 10},
)