"""Where the results table of a dataset is stored.
The heavy lifting is left to the dependencies: Polars reads and writes Parquet,
including its key-value metadata, and ``deltalake`` provides atomic, versioned,
concurrent appends on local disks and object stores. The two stores here only
add what those do not: carrying the :class:`~qpdk.models.datasets.metadata.DatasetMetadata`
in the files, and a readable error for an unresolved Git LFS pointer.
:class:`ParquetParts`
A local directory of append-only ``*.parquet`` parts, each carrying the
metadata in its footer. The default, used for curated datasets in Git LFS.
One writer at a time; use a Delta table for concurrent writers.
:class:`DeltaStore`
A `Delta Lake <https://delta.io>`_ table at a local path or an object-store
URI such as ``gs://``, ``s3://``, or ``az://``. Each append is one atomic
commit and a new table version, so a team can write sweep results straight to
a cloud bucket and pin a model to an exact version. The metadata is kept on
the ``value`` column of the table schema, since Delta tables accept no
free-form table properties. Requires the ``delta`` extra.
Credentials are passed as ``storage_options``, in the keys Polars and
``deltalake`` accept (for example ``aws_endpoint_url``,
``google_service_account``, ``azure_storage_account_name``), or come from the
environment and application-default credentials of the cloud SDKs.
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
from typing import TYPE_CHECKING, Any
from qpdk.models.datasets.metadata import METADATA_KEY, DatasetMetadata
if TYPE_CHECKING:
import polars as pl
_LFS_POINTER_PREFIX = b"version https://git-lfs"
[docs]
class LFSPointerError(FileNotFoundError):
"""A results file is an unresolved Git LFS pointer instead of Parquet data."""
def _check_not_lfs_pointer(path: Path) -> None:
"""Raise :class:`LFSPointerError` if ``path`` holds an unresolved LFS pointer."""
with path.open("rb") as file:
head = file.read(len(_LFS_POINTER_PREFIX))
if head == _LFS_POINTER_PREFIX:
msg = (
f"{path} is a Git LFS pointer, not Parquet data. From the root of a source "
'checkout run `git lfs install && git lfs pull --include "qpdk/models/datasets/data/**"`. '
"Installed qpdk wheels already contain the data."
)
raise LFSPointerError(msg)
[docs]
class ParquetParts:
"""Local, append-only directory of Parquet parts.
Args:
location: Directory of the parts.
"""
def __init__(self, location: str | Path) -> None:
"""Point at a directory of parts; nothing is read yet."""
self.location = Path(location)
def __repr__(self) -> str:
"""Show the location."""
return f"ParquetParts({str(self.location)!r})"
@property
def files(self) -> list[Path]:
"""Part files in a deterministic order."""
return sorted(self.location.glob("*.parquet"))
[docs]
def scan(self, schema: pl.Schema) -> pl.LazyFrame:
"""Scan all parts with ``schema``.
Returns:
Lazy frame over every part.
Raises:
FileNotFoundError: if there are no parts.
"""
import polars as pl # ruff: ignore[import-outside-top-level]
if not (files := self.files):
msg = f"No *.parquet parts in {self.location}."
raise FileNotFoundError(msg)
for file in files:
_check_not_lfs_pointer(file)
return pl.scan_parquet(files, schema=schema)
[docs]
def write(
self, frame: pl.DataFrame, metadata: DatasetMetadata, part: str | None
) -> str:
"""Publish ``frame`` as a new part without ever replacing an existing one.
Returns:
Path of the written part.
Raises:
FileExistsError: if the part already exists.
ValueError: if the part is not a filename stem.
"""
if part is not None and (
not part or Path(part).name != part or part in {".", ".."} or "\\" in part
):
raise ValueError("Part must be a filename stem without path separators")
self.location.mkdir(parents=True, exist_ok=True)
target = self.location / f"{part or uuid.uuid4().hex}.parquet"
if target.exists():
msg = f"{target} already exists; result parts are append-only."
raise FileExistsError(msg)
staging = self.location / f".{target.name}.{uuid.uuid4().hex}.tmp"
try:
frame.write_parquet(
staging, statistics=True, metadata={METADATA_KEY: metadata.to_json()}
)
# A hard link publishes atomically and, unlike a rename, never
# replaces an existing part.
os.link(staging, target)
finally:
staging.unlink(missing_ok=True)
return str(target)
[docs]
class DeltaStore:
"""Results stored as a Delta Lake table.
Every append is one atomic commit and creates a new table version. Reading a
pinned ``version`` reproduces exactly the rows a model was built from.
Concurrent appends are committed atomically on local disks, Google Cloud
Storage, and Azure. On S3, pass ``{"conditional_put": "etag"}`` (S3 and most
S3-compatible stores support it) or configure a ``deltalake`` locking
provider. Validation runs before each commit and again on every read, so a
duplicate committed by a racing writer is reported rather than silently used.
Args:
uri: Local path or object-store URI of the table, e.g. ``gs://bucket/dataset``.
storage_options: Object-store options, passed to Polars and ``deltalake``.
version: Table version to read; ``None`` reads the latest. A pinned store
is read-only.
"""
def __init__(
self,
uri: str | Path,
*,
storage_options: dict[str, str] | None = None,
version: int | None = None,
) -> None:
"""Point at a table; nothing is read yet."""
self.uri = str(uri)
self.storage_options = storage_options
self.version = version
def __repr__(self) -> str:
"""Show the URI and pinned version."""
pinned = "" if self.version is None else f", version={self.version}"
return f"DeltaStore({self.uri!r}{pinned})"
def _table(self) -> Any:
"""Open the table.
Returns:
The ``deltalake.DeltaTable``, or ``None`` if there is none yet.
"""
import deltalake # ruff: ignore[import-outside-top-level]
import deltalake.exceptions # ruff: ignore[import-outside-top-level]
try:
return deltalake.DeltaTable(
self.uri, version=self.version, storage_options=self.storage_options
)
except deltalake.exceptions.TableNotFoundError:
return None
@property
def table_version(self) -> int:
"""Version of the table that :meth:`scan` reads."""
if (table := self._table()) is None:
msg = f"No Delta table at {self.uri}."
raise FileNotFoundError(msg)
return table.version()
[docs]
def scan(self, schema: pl.Schema) -> pl.LazyFrame:
"""Scan the table, cast to ``schema``.
Returns:
Lazy frame over the table.
Raises:
FileNotFoundError: if there is no table.
"""
import polars as pl # ruff: ignore[import-outside-top-level]
if (table := self._table()) is None:
msg = f"No Delta table at {self.uri}."
raise FileNotFoundError(msg)
return pl.scan_delta(table).select(schema.names()).cast(schema)
[docs]
def write(
self, frame: pl.DataFrame, metadata: DatasetMetadata, part: str | None
) -> str:
"""Commit ``frame`` as one append, creating the table if needed.
New tables are partitioned by quantity and variants, which are selected
exactly and never interpolated, and carry ``metadata`` on the ``value``
column. Initial creation is exclusive; retry an append if another
writer creates the table first.
Returns:
The URI and the committed table version.
Raises:
ValueError: if the store is pinned to a version.
"""
import deltalake # ruff: ignore[import-outside-top-level]
from arro3.core import Table # ruff: ignore[import-outside-top-level]
if self.version is not None:
msg = f"{self!r} is pinned to a version and read-only."
raise ValueError(msg)
table = self._table()
exists = table is not None
data: Any = frame
if not exists:
table = Table.from_arrow(frame)
index = table.schema.get_field_index("value")
field = table.schema.field(index).with_metadata({
METADATA_KEY: metadata.to_json()
})
data = Table.from_batches(
table.to_batches(), schema=table.schema.set(index, field)
)
deltalake.write_deltalake(
table if exists else self.uri,
data,
mode="append" if exists else "error",
storage_options=self.storage_options,
# Table name, description and partitioning are set on creation only.
name=None if exists else metadata.name,
description=None if exists else metadata.description,
partition_by=None if exists else ["quantity", *metadata.variants],
commit_properties=deltalake.CommitProperties(
custom_metadata={"qpdk.part": part or ""}
),
)
# The writer updates this snapshot to its own commit, not a later append.
return f"{self.uri}@v{table.version() if exists else 0}"