Source code for lost_years.sources.base

"""The contract every fetchable life-table source implements.

An update is always download to a scratch directory, build a typed table from
what arrived, check it, and only then swap it into place. The check is the
point: an upstream that truncates a file, changes a column, or ships a table
that disagrees with the published figures must not become the user's data just
because the download completed.
"""

import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any

import pyarrow as pa
import pyarrow.parquet as pq
import requests

from ..datasets import ValidationError

logger = logging.getLogger(__name__)

# Public hosts serving multi-megabyte files; the timeout is per-read, not for
# the whole transfer.
READ_TIMEOUT = 120
USER_AGENT = "lost-years (https://github.com/gojiplus/lost-years)"


[docs] class SourceUnavailableError(RuntimeError): """Upstream could not be reached or refused the request."""
[docs] class Source(ABC): """One upstream life-table database and how to turn it into a table.""" name: str title: str home_url: str download_url: str license: str filename: str # A release never has fewer rows than the one the package was tested # against, so a short table means a truncated or partial download. min_rows: int # True for the one table small enough and freely enough licensed to ship. ships_in_wheel: bool = False
[docs] @abstractmethod def schema(self) -> pa.Schema: """Return the Arrow schema the derived table must conform to. Returns: The declared schema, without metadata. """
[docs] @abstractmethod def fetch(self, workdir: Path) -> Path: """Download the raw upstream artifact. Args: workdir: Scratch directory to download into. Returns: Path to the downloaded artifact. """
[docs] @abstractmethod def build(self, raw: Path, destination: Path) -> dict[str, Any]: """Turn a raw artifact into the derived Parquet table. Args: raw: Downloaded or locally supplied artifact. destination: Path to write the Parquet file to. Returns: Build notes for the manifest. """
[docs] @abstractmethod def release_of(self, raw: Path) -> str: """Read the upstream release identifier out of a raw artifact. Args: raw: Downloaded or locally supplied artifact. Returns: Upstream's own identifier for the release, such as a date. """
[docs] @abstractmethod def upstream_release(self) -> str: """Ask upstream what its current release is. Returns: Upstream's identifier for the release available right now. """
[docs] def url_for(self, release: str) -> str: """Return the URL a given release is published at. Args: release: Upstream release identifier. Returns: The canonical download URL, which for most sources does not depend on the release. """ del release return self.download_url
[docs] @abstractmethod def check_values(self, table: pa.Table) -> None: """Check the values in a candidate table against known-good figures. Args: table: The candidate table. """
[docs] def validate(self, path: Path) -> None: """Refuse a candidate table that fails schema, size or value checks. Args: path: Candidate Parquet file. Raises: ValidationError: When the table does not conform. """ found = pq.read_schema(path).remove_metadata() expected = self.schema() if found != expected: raise ValidationError( f"{self.name}: schema mismatch.\nexpected:\n{expected}\ngot:\n{found}" ) rows = pq.read_metadata(path).num_rows if rows < self.min_rows: raise ValidationError( f"{self.name}: {rows} rows, fewer than the {self.min_rows} this " "package was tested against. Upstream does not shrink, so this " "is a truncated or partial download." ) self.check_values(pq.read_table(path))
[docs] def download(self, url: str, target: Path) -> Path: """Stream a URL to disk, refusing a short read. ``Content-Length`` is the only cheap end-to-end check a plain HTTP download offers, so a transfer that stops early is caught here rather than surfacing as a corrupt archive later. Args: url: Source URL. target: Destination path. Returns: The destination path. Raises: SourceUnavailableError: When upstream refuses, or the transfer is short. """ logger.info("Downloading %s", url) try: response = requests.get( url, timeout=READ_TIMEOUT, stream=True, headers={"User-Agent": USER_AGENT}, ) except requests.RequestException as exc: raise SourceUnavailableError( f"{self.name}: could not reach {url}: {exc}" ) from exc if response.status_code != 200: raise SourceUnavailableError( f"{self.name}: {url} returned HTTP {response.status_code}" ) declared = response.headers.get("Content-Length") written = 0 with target.open("wb") as handle: for chunk in response.iter_content(chunk_size=1024 * 1024): handle.write(chunk) written += len(chunk) if declared is not None and written != int(declared): raise SourceUnavailableError( f"{self.name}: {url} announced {declared} bytes but delivered " f"{written}; the transfer was cut short" ) logger.info("Downloaded %d bytes to %s", written, target) return target
[docs] def get_text(self, url: str) -> str: """Fetch a small text resource. Args: url: Source URL. Returns: The response body. Raises: SourceUnavailableError: When upstream refuses or cannot be reached. """ try: response = requests.get( url, timeout=READ_TIMEOUT, headers={"User-Agent": USER_AGENT} ) except requests.RequestException as exc: raise SourceUnavailableError( f"{self.name}: could not reach {url}: {exc}" ) from exc if response.status_code != 200: raise SourceUnavailableError( f"{self.name}: {url} returned HTTP {response.status_code}" ) return response.text