Source code for pudl.scripts.zenodo_data_release

#!/usr/bin/env python
"""Upload a prepared PUDL data release directory to Zenodo.

The PUDL data release process produces a directory of artifacts (zipped Parquet files,
SQLite databases, JSON metadata, logs, etc.) that are uploaded to CERN's Zenodo data
repository for long-term archival access. Each new versioned release of PUDL is
associated with the same original PUDL concept DOI.

This module provides a CLI that handles the process of uploading a new PUDL data release
to Zenodo, given a prepared directory of artifacts typically produced by the PUDL builds.

It uses state objects to ensure that Zenodo API calls happen in a valid order. The files
to upload are read using ``fsspec`` and remote files are staged locally one at a time
so uploads can be retried, but without using excessive local disk space.

Retries are implemented for all upload requests to recover from transient network issues
and Zenodo server flakiness. Zero-byte uploads are prevented.

NOTE: PUDL nightly build outputs are NOT suitable for producing a Zenodo data release
unless the Parquet outputs are filtered out with an appropriate ignore_regex. Double
check what files should actually be distributed before running the script.

Run ``zenodo_data_release --help`` for CLI usage instructions.
"""

import datetime
import logging
import os
import re
import sys
import tempfile
import time
from collections.abc import Callable
from dataclasses import dataclass
from importlib.metadata import version
from pathlib import Path
from typing import IO

import click
import coloredlogs
import fsspec
import requests
from pydantic import AnyHttpUrl, BaseModel, Field

from pudl.deploy.pudl import send_zulip_message
from pudl.logging_helpers import get_logger

[docs] SANDBOX = "sandbox"
[docs] PRODUCTION = "production"
[docs] RETRYABLE_STATUS_CODES = { 408, # Request Timeout 500, # Internal Server Error 502, # Bad Gateway 503, # Service Unavailable 504, # Gateway Timeout 520, # Web server returned an unknown error (proxy) 522, # Connection timed out (proxy) 524, # A timeout occurred (proxy) }
[docs] logger = get_logger(__name__)
coloredlogs.install( level=logging.INFO, logger=logger, fmt="%(asctime)s %(levelname)s %(name)s %(message)s", )
[docs] class _LegacyMetadata(BaseModel):
[docs] upload_type: str = "dataset"
[docs] title: str
[docs] access_right: str
[docs] creators: list[dict]
[docs] license: str = "cc-by-4.0" # noqa: A003
[docs] publication_date: str = ""
[docs] description: str = ""
[docs] class _LegacyDeposition(BaseModel):
[docs] id_: int = Field(alias="id")
[docs] conceptrecid: int
[docs] metadata: _LegacyMetadata
[docs] submitted: bool = False
[docs] class _NewFile(BaseModel):
[docs] id_: str = Field(alias="id")
[docs] class _NewRecord(BaseModel):
[docs] id_: int = Field(alias="id")
[docs] files: list[_NewFile]
[docs] class ZenodoClient: """Thin wrapper over Zenodo REST API. Mostly legacy calls (https://developers.zenodo.org/) (archive: https://web.archive.org/web/20231212025359/https://developers.zenodo.org/) but due to inconsistent behavior of legacy API on sandbox environment, we need some of the unreleased new API endpoints too: https://inveniordm.docs.cern.ch/reference/rest_api_drafts_records/ """ def __init__(self, env: str): """Constructor. Args: env: "sandbox" or "production". """ if env == SANDBOX: self.base_url = "https://sandbox.zenodo.org/api" token = os.environ["ZENODO_SANDBOX_TOKEN_PUBLISH"] elif env == PRODUCTION: self.base_url = "https://zenodo.org/api" token = os.environ["ZENODO_TOKEN_UPLOAD"] else: raise ValueError( f"Got unexpected {env=}, expected {SANDBOX} or {PRODUCTION}" ) # As of 01/2026, Zenodo rejects all Python requests with no custom user agent. # We add this to note where our project's requests are originating from.
[docs] self.auth_headers = { "Authorization": f"Bearer {token}", "User-Agent": f"pudl/{version('catalystcoop.pudl')} (https://github.com/catalyst-cooperative/pudl)", }
logger.info(f"Using Zenodo token: {token[:4]}...{token[-4:]}")
[docs] def retry_request( self, *, method, url, max_tries: int = 6, request_timeout: float | None = None, data_factory: Callable[[], IO[bytes]] | None = None, **kwargs, ) -> requests.Response: """Retry calls to ``requests.request`` with exponential backoff. Args: method: HTTP method to use for the request (e.g. ``GET``). url: Fully-qualified URL to which the request is sent. max_tries: Maximum number of attempts before surfacing an error. request_timeout: Optional per-request timeout in seconds. When ``None`` the timeout grows exponentially (``2**attempt``). data_factory: Optional callable that yields a fresh binary stream for each attempt. Useful for uploads that require reopening a file-like object. **kwargs: Additional keyword arguments passed through directly to ``requests.request``. Returns:
[docs]
[docs]
[docs]
[docs]
[docs] The ``requests.Response`` produced by the successful attempt.
Raises:
[docs]
[docs]
[docs]
[docs]
[docs] requests.RequestException: If all attempts fail with a requests error.
OSError: If reading from disk fails when preparing a payload. RuntimeError: If no response object is produced (should be rare). response: requests.Response | None = None for attempt in range(1, max_tries + 1):
[docs]
[docs]
[docs]
[docs]
[docs] # Create a fresh set of kwargs for each attempt so that data_factory()
# can produce a fresh payload. attempt_kwargs = dict(kwargs)
[docs]
[docs]
[docs]
[docs]
[docs] payload: IO[bytes] | None = None
timeout_value = ( request_timeout if request_timeout is not None else 2**attempt try: if data_factory: payload = data_factory()
[docs]
[docs]
[docs]
[docs]
[docs] attempt_kwargs["data"] = payload
response = requests.request( method=method, url=url,
[docs]
[docs]
[docs]
[docs]
[docs] timeout=timeout_value,
**attempt_kwargs, ) if response.status_code in RETRYABLE_STATUS_CODES: raise requests.HTTPError( f"Retryable status {response.status_code} from {url}", response=response, )
[docs]
[docs]
[docs]
[docs]
[docs] return response
except (requests.RequestException, OSError, requests.HTTPError) as exc: if attempt == max_tries:
[docs]
[docs]
[docs]
[docs]
[docs] raise
wait = 2**attempt logger.warning(
[docs]
[docs]
[docs]
[docs]
[docs] f"Attempt #{attempt} for {url} failed with {exc}, retrying in {wait}s"
) time.sleep(wait) if payload: payload.close()
[docs]
[docs]
[docs]
[docs]
[docs] if response is None:
raise RuntimeError( f"Failed to complete request to {url} after {max_tries} tries" return response
[docs] def get_deposition(self, deposition_id: int) -> _LegacyDeposition: """LEGACY API: Get JSON describing a deposition. Depositions can be published *or* unpublished. """
[docs]
[docs]
[docs]
[docs]
[docs] response = self.retry_request(
method="GET", url=f"{self.base_url}/deposit/depositions/{deposition_id}", headers=self.auth_headers, ) logger.debug( f"License from JSON for {deposition_id} is " f"{response.json()['metadata'].get('license')}" )
[docs]
[docs]
[docs]
[docs]
[docs] return _LegacyDeposition(**response.json())
[docs] def get_record(self, record_id: int) -> _NewRecord:
[docs]
[docs]
[docs]
[docs]
[docs] """NEW API: Get JSON describing a record.
All records are published records. response = self.retry_request( method="GET", url=f"{self.base_url}/records/{record_id}", headers=self.auth_headers, ) return _NewRecord(**response.json())
[docs] def new_record_version(self, record_id: int) -> _NewRecord: """NEW API: get or create the draft associated with a record ID. Finds the latest record in the concept that record_id points to, and makes a new version unless one exists already. """ response = self.retry_request( method="POST", url=f"{self.base_url}/records/{record_id}/versions", headers=self.auth_headers, ) return _NewRecord(**response.json())
[docs] def update_deposition_metadata( self, deposition_id: int, metadata: _LegacyMetadata ) -> _LegacyDeposition: """LEGACY API: Update deposition metadata. Replaces the existing metadata completely - so make sure to pass in complete metadata. You cannot update metadata fields one at a time. """ url = f"{self.base_url}/deposit/depositions/{deposition_id}" data = {"metadata": metadata.model_dump()} logger.debug(f"Setting metadata for {deposition_id} to {data}") response = self.retry_request( method="PUT", url=url, json=data, headers=self.auth_headers ) return _LegacyDeposition(**response.json())
[docs] def delete_deposition_file(self, deposition_id: int, file_id) -> requests.Response: """LEGACY API: Delete file from deposition. Note: file_id is not always the file name. """ return self.retry_request( method="DELETE", url=f"{self.base_url}/deposit/depositions/{deposition_id}/files/{file_id}", headers=self.auth_headers, )
[docs] def create_bucket_file( self, bucket_url: AnyHttpUrl, file_path: Path, max_tries: int = 6, ) -> requests.Response: """LEGACY API: Upload a file to a deposition's file bucket. We prefer this API this over the /deposit/depositions/{id}/files endpoint because it allows for files >100MB. Args: bucket_url: Upload destination returned by Zenodo for the draft. file_path: Local path to the artifact being uploaded. max_tries: Maximum number of upload attempts before failing. Returns: The ``requests.Response`` from the successful upload attempt. Raises: ValueError: If ``file_path`` is empty. requests.RequestException: If all upload attempts fail. """ actual_name = file_path.name url = f"{bucket_url}/{actual_name}" logger.info(f"Uploading file to {url}") size = file_path.stat().st_size if size == 0: raise ValueError(f"Upload source for {actual_name} has zero bytes") return self.retry_request( method="PUT", url=url, max_tries=max_tries, request_timeout=60, headers=self.auth_headers, stream=True, data_factory=lambda: file_path.open("rb"), )
[docs] def publish_deposition(self, deposition_id: int) -> _LegacyDeposition: """LEGACY API: publish deposition. The publish action isn't safely retriable: if a request times out after Zenodo already processed it server-side, a retried POST to the same ``actions/publish`` URL 404s, since a deposition that's already published no longer has a pending publish action -- even though the publish itself succeeded. Rather than fail on that specific 404, check whether the deposition is actually already published before giving up. """ response = self.retry_request( method="POST", url=f"{self.base_url}/deposit/depositions/{deposition_id}/actions/publish", headers=self.auth_headers, ) if response.status_code == 404: logger.warning( f"Publish action for {deposition_id} returned 404 -- an earlier " f"attempt's response may have been lost (e.g. to a timeout) after " f"the publish actually succeeded. Checking current deposition state..." ) deposition = self.get_deposition(deposition_id) if deposition.submitted: return deposition response.raise_for_status() return _LegacyDeposition(**response.json())
@dataclass
[docs] class State: """Parent class for dataset states. Provides an abstraction layer that hides Zenodo's data model from the caller. Subclasses + their limited method definitions provide a way to avoid calling the operations in the wrong order. """
[docs] record_id: int
[docs] zenodo_client: ZenodoClient
[docs] class InitialDataset(State): """Represent initial dataset state. At this point, we don't know if there is an existing draft or not - the only thing we can do is try to get a fresh draft. """
[docs] def get_empty_draft(self) -> "EmptyDraft": """Get an empty draft for this dataset. Use new API to get any draft, then use legacy API to delete any files in the draft. """ logger.info(f"Getting new version for {self.record_id}") latest_record = self.zenodo_client.get_record(self.record_id) new_version = self.zenodo_client.new_record_version(latest_record.id_) new_rec_id = new_version.id_ existing_files = new_version.files logger.info( f"Draft {new_rec_id} has {len(existing_files)} existing files, deleting..." ) for f in existing_files: self.zenodo_client.delete_deposition_file(new_rec_id, f.id_) return EmptyDraft(record_id=new_rec_id, zenodo_client=self.zenodo_client)
[docs] class EmptyDraft(State): """We can only sync the directory once we've gotten an empty draft.""" @staticmethod
[docs] def _sync_local_path( openable_file: fsspec.core.OpenFile, staging_dir: Path ) -> Path: """Ensure the given ``fsspec`` file exists on the local filesystem. When ``openable_file`` already resides on the local filesystem we avoid copying and return its existing path. Remote files are downloaded into ``staging_dir`` (a shared temporary directory) so the rest of the upload pipeline can treat every artifact as a simple ``Path`` without caring where it came from. Args: openable_file: ``fsspec`` handle pointing to the source artifact. staging_dir: Directory used to cache remote files locally. Returns: A ``Path`` pointing to a readable local copy of ``openable_file``. """ # fsspec supports chained protocols, so this isn't always just a string protocol = openable_file.fs.protocol protocol_parts = protocol if isinstance(protocol, (list, tuple)) else [protocol] if "local" in protocol_parts: return Path(openable_file.path) tmp_path = Path(staging_dir, Path(openable_file.path).name) # Remove the tmp_path if it already exists to avoid conflicts tmp_path.unlink(missing_ok=True) # Actually download the remote file to the tmp_path that mirrors the original openable_file.fs.get(openable_file.path, tmp_path) return tmp_path
[docs] def sync_directory(self, source_dir: str, ignore: tuple[str]) -> "ContentComplete": """Upload every file in ``source_dir`` to the draft bucket. The method enumerates files (not subdirectories) via ``fsspec`` so the source can live on local disk, GCS, S3, etc. Remote objects are first staged into a temporary directory to ensure uploads always come from local ``Path`` objects that can be rewound for retries. Regex patterns provided via ``ignore`` are applied to the full path of each candidate file, allowing us to drop logs, intermediate data, or other nightlies-only artifacts before hitting Zenodo. Args: source_dir: Directory (local or remote) whose contents will be sent to Zenodo. ignore: Tuple of regex patterns; any path matching one is skipped. Returns: A ``ContentComplete`` state ready for metadata updates. """ logger.info(f"Syncing files from {source_dir} to draft {self.record_id}...") bucket_url = self.zenodo_client.get_deposition(self.record_id).links.bucket dir_fs, dir_path = fsspec.core.url_to_fs(source_dir) if not dir_fs.isdir(dir_path): raise ValueError(f"{source_dir} is not a directory!") # fsspec supports chained protocols, so this isn't always just a string protocol = dir_fs.protocol protocol_prefix = ( protocol[0] if isinstance(protocol, (list, tuple)) else protocol ) files = fsspec.open_files( [ f"{protocol_prefix}://{entry['name']}" for entry in dir_fs.ls(dir_path, detail=True) # Only upload files, not subdirectories. # Zenodo doesn't support nested folders. if entry.get("type") == "file" or dir_fs.isfile(entry["name"]) ] ) all_ignore_regex = re.compile("|".join(ignore)) if ignore else None with tempfile.TemporaryDirectory() as staging_dir: staging_path = Path(staging_dir) for openable_file in files: name = Path(openable_file.path).name if all_ignore_regex and all_ignore_regex.search(openable_file.path): logger.debug( f"Ignoring {openable_file.path} because it matched {all_ignore_regex}" ) continue local_path = self._sync_local_path(openable_file, staging_path) try: response = self.zenodo_client.create_bucket_file( bucket_url=bucket_url, file_path=local_path, ) logger.info(f"Uploading to {bucket_url}/{name} got {response.text}") finally: # Remove the local copy if we put it in the temporary staging area if local_path.is_relative_to(staging_path): local_path.unlink(missing_ok=True) return ContentComplete( record_id=self.record_id, zenodo_client=self.zenodo_client )
[docs] class ContentComplete(State): """Now that we've uploaded all the data, we need to update metadata."""
[docs] def update_metadata(self): """Copy over old metadata and update publication date. We need to make sure there is complete metadata, including a publication date. To do this, we: 1. use the *legacy* API to get the concept record ID associated with the draft 2. use the *new* API to get the latest record associated with the concept 3. use the *legacy* API to get the metadata from the latest record 4. use the *legacy* API to update the draft's metadata Since we are using the legacy API to publish, we need the legacy metadata format. But the legacy concept DOI -> published record mapping is broken, so we have to take a detour through the new API. """ deposition_info = self.zenodo_client.get_deposition(self.record_id) concept_rec_id = deposition_info.conceptrecid concept_info = self.zenodo_client.get_record(concept_rec_id) latest_published_deposition = self.zenodo_client.get_deposition( concept_info.id_ ) base_metadata = { k: v for k, v in latest_published_deposition.metadata.model_dump().items() if k not in {"doi", "prereserve_doi", "publication_date"} } logger.info( f"Using metadata from {latest_published_deposition.id_} to publish {self.record_id}..." ) pub_date = {"publication_date": datetime.date.today().isoformat()} metadata = _LegacyMetadata(**(base_metadata | pub_date)) self.zenodo_client.update_deposition_metadata(self.record_id, metadata=metadata) return CompleteDraft(record_id=self.record_id, zenodo_client=self.zenodo_client)
[docs] class CompleteDraft(State): """Now that we've uploaded all the data, we can publish."""
[docs] def publish(self) -> None: """Publish the draft.""" self.zenodo_client.publish_deposition(self.record_id)
[docs] def get_html_url(self): """A URL for viewing this draft.""" return self.zenodo_client.get_deposition(self.record_id).links.html
[docs] def build_zenodo_release_zulip_message( env: str, publish: bool, succeeded: bool, record_url: str | None, ) -> str: """Build a markdown Zulip message summarizing a Zenodo release attempt. Makes the sandbox/production environment and publish/draft mode immediately visible, so a misconfigured run is obvious at a glance, and links to the resulting record when the release succeeded -- the live record if ``publish`` was requested, otherwise the draft awaiting manual review. """ nl = "\n" env_label = "PRODUCTION" if env == PRODUCTION else "SANDBOX" mode_label = "publish" if publish else "draft, no-publish" if succeeded: message = ( f"{nl}# :check: PUDL Zenodo Release Succeeded " f"({env_label}, {mode_label}){nl}{nl}" ) else: message = ( f"{nl}# :x: PUDL Zenodo Release Failed ({env_label}, {mode_label}){nl}{nl}" ) if record_url: record_label = "Published record" if publish else "Draft record" message += f"- {record_label}: {record_url}{nl}" return message
@click.command( help=__doc__, context_settings={"help_option_names": ["-h", "--help"]}, ) @click.option( "--env", type=click.Choice([SANDBOX, PRODUCTION], case_sensitive=False), default=SANDBOX, help="Whether to use the Zenodo sandbox server (for testing) or the production server.", show_default=True, ) @click.option( "--source-dir", type=str, required=True, help="Path to a directory whose contents will be uploaded to Zenodo. " "Subdirectories are ignored. Accepts GCS or S3 URLs as well. Prefix remote paths " "with e.g. gs:// or s3://.", ) @click.option( "--ignore", multiple=True, help="Filenames that match these regex patterns will be ignored.", ) @click.option( "--publish/--no-publish", default=False, help="Whether to publish the new record without confirmation, or leave it as a " "draft to be reviewed and approved manually.", show_default=True, )
[docs] def main(env: str, source_dir: str, publish: bool, ignore: tuple[str]) -> int: """Publish a new PUDL data release to Zenodo.""" zenodo_client = ZenodoClient(env) if env == SANDBOX: rec_id = 5563 elif env == PRODUCTION: rec_id = 3653158 else: raise ValueError(f"{env=}, expected {SANDBOX} or {PRODUCTION}") zulip_api_key = os.environ.get("ZULIP_API_KEY") record_url: str | None = None succeeded = False try: completed_draft = ( InitialDataset(zenodo_client=zenodo_client, record_id=rec_id) .get_empty_draft() .sync_directory(source_dir, ignore) .update_metadata() ) if publish: completed_draft.publish() logger.info(f"Published at {completed_draft.get_html_url()}") else: logger.info(f"Completed draft at {completed_draft.get_html_url()}") record_url = str(completed_draft.get_html_url()) succeeded = True finally: if zulip_api_key: send_zulip_message( build_zenodo_release_zulip_message( env=env, publish=publish, succeeded=succeeded, record_url=record_url, ), api_key=zulip_api_key, ) else: logger.warning("Skipping Zulip notification: ZULIP_API_KEY is unset.") return 0
if __name__ == "__main__": sys.exit(main())