Source code for pandera_catalog.catalog

"""Core catalog abstraction for pandera-catalog.

The :class:`PanderaCatalog` class is the primary entry-point for registering,
looking up, listing, and removing Pandera schema entries.
"""
from __future__ import annotations

from collections.abc import Mapping
from typing import TYPE_CHECKING

import pandera.pandas as pa

from .types import SchemaEntry, SchemaProjectionEntry, SchemaProjectionStep

if TYPE_CHECKING:
    from .backend import SqlCatalogBackend


[docs] class PanderaCatalog: """A registry for Pandera schemas. Schemas are stored in memory by name. A future version will persist entries to a SQLAlchemy-backed database (SQLite by default). Examples -------- >>> from pandera_catalog import PanderaCatalog >>> import pandera.pandas as pa >>> catalog = PanderaCatalog() >>> schema = pa.DataFrameSchema({"value": pa.Column(float)}) >>> catalog.register("my_schema", schema) >>> catalog.get("my_schema") <Schema DataFrameSchema(columns={'value': ...}, ...)> """ def __init__(self, *, backend: SqlCatalogBackend | None = None) -> None: self._store: dict[str, SchemaEntry] = {} self._projections: dict[str, SchemaProjectionEntry] = {} self._backend = backend if self._backend is not None: self._backend.initialize() for entry in self._backend.load_schemas(): self._store[entry.name] = entry for projection_entry in self._backend.load_projections(): self._projections[projection_entry.name] = projection_entry # ------------------------------------------------------------------ # Mutating operations # ------------------------------------------------------------------
[docs] def register( self, name: str, schema: pa.DataFrameSchema, *, description: str | None = None, tags: list[str] | None = None, overwrite: bool = False, ) -> None: """Register a Pandera schema under *name*. Parameters ---------- name: Unique identifier for the schema within this catalog. schema: A :class:`pandera.DataFrameSchema` instance. description: Optional human-readable description of the schema. tags: Optional list of string tags for categorisation. overwrite: When ``True``, silently replace any existing entry with the same name. When ``False`` (default), raise :class:`KeyError` if the name is already registered. Raises ------ KeyError If *name* is already registered and *overwrite* is ``False``. """ if name in self._store and not overwrite: raise KeyError( f"Schema '{name}' is already registered. " "Pass overwrite=True to replace it." ) self._store[name] = SchemaEntry( name=name, schema=schema, description=description, tags=list(tags or []), ) if self._backend is not None: self._backend.upsert_schema(self._store[name])
[docs] def register_projection( self, name: str, steps: list[SchemaProjectionStep | Mapping[str, object]], *, description: str | None = None, overwrite: bool = False, ) -> None: """Register a named projection from ordered step definitions. Parameters ---------- name: Unique projection name within this catalog. steps: Ordered list of projection steps. Each step must include ``schema``, ``kind``, and ``names``. description: Optional human-readable description of the projection. overwrite: When ``True``, replace any existing projection with the same name. Raises ------ KeyError If any step schema is not registered, or if *name* already exists and *overwrite* is ``False``. ValueError If *steps* are invalid, include duplicates, include unknown columns, or include an unknown step kind. NotImplementedError If a ``kind: group`` step is provided. """ if name in self._projections and not overwrite: raise KeyError( f"Projection '{name}' is already registered. " "Pass overwrite=True to replace it." ) resolved_steps = self._normalise_projection_steps(steps) resolved_columns = self._resolve_projection_columns(resolved_steps) duplicate_columns = self._find_duplicate_columns( [column for _, column in resolved_columns] ) if duplicate_columns: raise ValueError( f"Projection columns contain duplicates across steps: " f"{duplicate_columns!r}." ) self._projections[name] = SchemaProjectionEntry( name=name, steps=resolved_steps, description=description, ) if self._backend is not None: self._backend.upsert_projection(self._projections[name], resolved_columns)
[docs] def remove(self, name: str) -> None: """Remove the schema registered under *name*. Parameters ---------- name: Name of the schema to remove. Raises ------ KeyError If *name* is not registered. """ if name not in self._store: raise KeyError(f"Schema '{name}' is not registered.") del self._store[name] if self._backend is not None: self._backend.remove_schema(name)
[docs] def remove_projection(self, name: str) -> None: """Remove the projection registered under *name*.""" if name not in self._projections: raise KeyError(f"Projection '{name}' is not registered.") del self._projections[name] if self._backend is not None: self._backend.remove_projection(name)
# ------------------------------------------------------------------ # Read operations # ------------------------------------------------------------------
[docs] def get(self, name: str) -> pa.DataFrameSchema: """Return the schema registered under *name*. Parameters ---------- name: Name of the schema to retrieve. Returns ------- pandera.DataFrameSchema Raises ------ KeyError If *name* is not registered. """ if name not in self._store: raise KeyError(f"Schema '{name}' is not registered.") return self._store[name].schema
[docs] def get_entry(self, name: str) -> SchemaEntry: """Return the full :class:`~pandera_catalog.types.SchemaEntry` for *name*. Parameters ---------- name: Name of the schema entry to retrieve. Returns ------- SchemaEntry Raises ------ KeyError If *name* is not registered. """ if name not in self._store: raise KeyError(f"Schema '{name}' is not registered.") return self._store[name]
[docs] def get_projection_entry(self, name: str) -> SchemaProjectionEntry: """Return the full projection entry registered under *name*.""" if name not in self._projections: raise KeyError(f"Projection '{name}' is not registered.") return self._projections[name]
[docs] def export_projection(self, name: str) -> pa.DataFrameSchema: """Materialise and return the schema defined by projection *name*.""" projection = self.get_projection_entry(name) resolved_columns = self._resolve_projection_columns(projection.steps) columns: dict[str, pa.Column] = {} for schema_name, column_name in resolved_columns: source_schema = self.get(schema_name) columns[column_name] = source_schema.columns[column_name] return pa.DataFrameSchema(columns=columns, name=projection.name)
[docs] def list(self) -> list[str]: """Return a sorted list of all registered schema names. Returns ------- list[str] """ return sorted(self._store.keys())
[docs] def list_projections(self) -> list[str]: """Return a sorted list of all registered projection names.""" return sorted(self._projections.keys())
def __len__(self) -> int: return len(self._store) def __contains__(self, name: object) -> bool: return name in self._store def __repr__(self) -> str: names = self.list() projections = self.list_projections() return f"PanderaCatalog(schemas={names!r}, projections={projections!r})" @property def backend(self) -> SqlCatalogBackend | None: """Return the configured SQL backend, if any.""" return self._backend @staticmethod def _find_duplicate_columns(columns: list[str]) -> list[str]: seen: set[str] = set() duplicates: list[str] = [] for column in columns: if column in seen and column not in duplicates: duplicates.append(column) seen.add(column) return duplicates def _normalise_projection_steps( self, steps: list[SchemaProjectionStep | Mapping[str, object]] ) -> list[SchemaProjectionStep]: if not steps: raise ValueError("Projection steps cannot be empty.") normalised_steps: list[SchemaProjectionStep] = [] for step in steps: if isinstance(step, SchemaProjectionStep): normalised = step elif isinstance(step, Mapping): normalised = SchemaProjectionStep( schema=self._require_string(step, "schema"), kind=self._require_string(step, "kind"), names=self._require_string_list(step, "names"), ) else: raise ValueError("Projection steps must be mappings or SchemaProjectionStep.") if normalised.kind not in {"columns", "group"}: raise ValueError(f"Unknown projection step kind: {normalised.kind!r}.") if normalised.kind == "group": raise NotImplementedError("Projection step kind 'group' is not implemented.") if normalised.schema not in self._store: raise KeyError(f"Schema '{normalised.schema}' is not registered.") if not normalised.names: raise ValueError("Projection step names cannot be empty.") normalised_steps.append(normalised) return normalised_steps def _resolve_projection_columns( self, steps: list[SchemaProjectionStep] ) -> list[tuple[str, str]]: resolved: list[tuple[str, str]] = [] for step in steps: source_columns = set(self._store[step.schema].schema.columns.keys()) unknown_columns = [name for name in step.names if name not in source_columns] if unknown_columns: raise ValueError( f"Projection columns not found in source schema '{step.schema}': " f"{unknown_columns!r}." ) resolved.extend((step.schema, name) for name in step.names) return resolved @staticmethod def _require_string(step: Mapping[str, object], key: str) -> str: value = step.get(key) if not isinstance(value, str) or not value: raise ValueError(f"Projection step '{key}' must be a non-empty string.") return value @staticmethod def _require_string_list(step: Mapping[str, object], key: str) -> list[str]: value = step.get(key) if not isinstance(value, list) or not all( isinstance(item, str) and item for item in value ): raise ValueError( f"Projection step '{key}' must be a list of non-empty strings." ) return list(value)