Skip to content

API Reference

Auto-generated from source using mkdocstrings.

pg_partsmith

Top-level public API: entities, enums, exceptions, and period calculators.

Entities

Represents a time period for partition boundaries.

Every period has exactly one granularity kind (a :class:PartitionGranularity member), decided once by _granularity_key. All kind-specific behaviour (validation, arithmetic, ordering, formatting) lives in the _SPECS table below, so supporting a new kind means adding one _GranularitySpec entry.

Attributes:

Name Type Description
year int

Year component.

month int | None

Month component (1-12), optional.

day int | None

Day component (1-31), optional.

week int | None

ISO week number (1-53), optional.

hour int | None

Hour component (0-23), optional; requires day.

quarter int | None

Quarter component (1-4), optional.

Source code in pg_partsmith/entities.py
@dataclass(frozen=True)
@functools.total_ordering
class Period:
    """Represents a time period for partition boundaries.

    Every period has exactly one granularity kind (a
    :class:`PartitionGranularity` member), decided once by
    ``_granularity_key``. All kind-specific behaviour (validation,
    arithmetic, ordering, formatting) lives in the ``_SPECS`` table below,
    so supporting a new kind means adding one ``_GranularitySpec`` entry.

    Attributes:
        year: Year component.
        month: Month component (1-12), optional.
        day: Day component (1-31), optional.
        week: ISO week number (1-53), optional.
        hour: Hour component (0-23), optional; requires day.
        quarter: Quarter component (1-4), optional.
    """

    year: int
    month: int | None = None
    day: int | None = None
    week: int | None = None
    hour: int | None = None
    quarter: int | None = None

    def __post_init__(self) -> None:
        """Validate period components for this period's granularity kind."""
        self._spec.validate(self)

    @property
    def _spec(self) -> _GranularitySpec:
        """Per-kind behaviour for this period."""
        return _SPECS[_granularity_key(self)]

    def to_date(self) -> date:
        """Return the period's start date.

        Weekly periods return the Monday of the ISO week; hourly periods
        return the calendar date (use :meth:`to_datetime` to preserve the
        hour component).
        """
        return self._spec.start_date(self)

    def to_datetime(self) -> datetime:
        """Convert period start to a timezone-aware UTC datetime.

        Unlike :meth:`to_date`, preserves the hour component, so hourly
        periods within one day map to distinct instants.
        """
        base = datetime.combine(self.to_date(), datetime.min.time(), tzinfo=UTC)
        if self.hour is not None:
            base = base.replace(hour=self.hour)
        return base

    def __add__(self, offset: int) -> Period:
        """Add offset to period (implementation depends on granularity).

        Args:
            offset: Number of periods to add.

        Returns:
            New Period object.
        """
        return self._spec.add(self, offset)

    def __sub__(self, offset: int) -> Period:
        """Subtract offset from period.

        Args:
            offset: Number of periods to subtract.

        Returns:
            New Period object.
        """
        return self.__add__(-offset)

    def __lt__(self, other: object) -> bool:
        """Compare periods of the same granularity kind."""
        if not isinstance(other, Period):
            return NotImplemented

        kind = _granularity_key(self)
        if kind != _granularity_key(other):
            return NotImplemented

        spec = _SPECS[kind]
        return spec.sort_key(self) < spec.sort_key(other)

    def __str__(self) -> str:
        """String representation."""
        return self._spec.fmt(self)

__add__(offset)

Add offset to period (implementation depends on granularity).

Parameters:

Name Type Description Default
offset int

Number of periods to add.

required

Returns:

Type Description
Period

New Period object.

Source code in pg_partsmith/entities.py
def __add__(self, offset: int) -> Period:
    """Add offset to period (implementation depends on granularity).

    Args:
        offset: Number of periods to add.

    Returns:
        New Period object.
    """
    return self._spec.add(self, offset)

__lt__(other)

Compare periods of the same granularity kind.

Source code in pg_partsmith/entities.py
def __lt__(self, other: object) -> bool:
    """Compare periods of the same granularity kind."""
    if not isinstance(other, Period):
        return NotImplemented

    kind = _granularity_key(self)
    if kind != _granularity_key(other):
        return NotImplemented

    spec = _SPECS[kind]
    return spec.sort_key(self) < spec.sort_key(other)

__post_init__()

Validate period components for this period's granularity kind.

Source code in pg_partsmith/entities.py
def __post_init__(self) -> None:
    """Validate period components for this period's granularity kind."""
    self._spec.validate(self)

__str__()

String representation.

Source code in pg_partsmith/entities.py
def __str__(self) -> str:
    """String representation."""
    return self._spec.fmt(self)

__sub__(offset)

Subtract offset from period.

Parameters:

Name Type Description Default
offset int

Number of periods to subtract.

required

Returns:

Type Description
Period

New Period object.

Source code in pg_partsmith/entities.py
def __sub__(self, offset: int) -> Period:
    """Subtract offset from period.

    Args:
        offset: Number of periods to subtract.

    Returns:
        New Period object.
    """
    return self.__add__(-offset)

to_date()

Return the period's start date.

Weekly periods return the Monday of the ISO week; hourly periods return the calendar date (use :meth:to_datetime to preserve the hour component).

Source code in pg_partsmith/entities.py
def to_date(self) -> date:
    """Return the period's start date.

    Weekly periods return the Monday of the ISO week; hourly periods
    return the calendar date (use :meth:`to_datetime` to preserve the
    hour component).
    """
    return self._spec.start_date(self)

to_datetime()

Convert period start to a timezone-aware UTC datetime.

Unlike :meth:to_date, preserves the hour component, so hourly periods within one day map to distinct instants.

Source code in pg_partsmith/entities.py
def to_datetime(self) -> datetime:
    """Convert period start to a timezone-aware UTC datetime.

    Unlike :meth:`to_date`, preserves the hour component, so hourly
    periods within one day map to distinct instants.
    """
    base = datetime.combine(self.to_date(), datetime.min.time(), tzinfo=UTC)
    if self.hour is not None:
        base = base.replace(hour=self.hour)
    return base

Bases: BaseModel

Metadata about a partition.

Attributes:

Name Type Description
name StrippedNonEmptyStr

Partition table name.

partition_type PartitionType

Type of partition (RANGE, LIST, HASH).

from_value str | None

Start boundary value (for RANGE).

to_value str | None

End boundary value (for RANGE).

boundaries_expr str | None

Raw boundary expression as reported by PostgreSQL (pg_get_expr(relpartbound, oid)). Useful when parsing boundaries fails but the partition is still attached.

bounds PartitionBounds | None

Structured form of the same boundaries, discriminated on the bound kind. Populated from from_value/to_value for RANGE partitions when not supplied, so the two views never disagree.

is_attached bool

Whether partition is currently attached to parent table.

is_default bool

Whether this is the DEFAULT partition (no explicit boundaries).

subpartition_type PartitionType | None

How this partition partitions its own children, when it is itself a partitioned table. None for a leaf — which is what distinguishes a legacy leaf from a subpartitioned branch.

parent_table StrippedNonEmptyStr | None

Name of parent partitioned table.

Source code in pg_partsmith/entities.py
class PartitionInfo(BaseModel):
    """Metadata about a partition.

    Attributes:
        name: Partition table name.
        partition_type: Type of partition (RANGE, LIST, HASH).
        from_value: Start boundary value (for RANGE).
        to_value: End boundary value (for RANGE).
        boundaries_expr: Raw boundary expression as reported by PostgreSQL
            (``pg_get_expr(relpartbound, oid)``). Useful when parsing boundaries
            fails but the partition is still attached.
        bounds: Structured form of the same boundaries, discriminated on the
            bound kind. Populated from ``from_value``/``to_value`` for RANGE
            partitions when not supplied, so the two views never disagree.
        is_attached: Whether partition is currently attached to parent table.
        is_default: Whether this is the DEFAULT partition (no explicit boundaries).
        subpartition_type: How this partition partitions its own children, when
            it is itself a partitioned table. ``None`` for a leaf — which is
            what distinguishes a legacy leaf from a subpartitioned branch.
        parent_table: Name of parent partitioned table.
    """

    model_config = ConfigDict(frozen=True)

    name: StrippedNonEmptyStr
    partition_type: PartitionType
    from_value: str | None = None
    to_value: str | None = None
    boundaries_expr: str | None = None
    bounds: PartitionBounds | None = None
    is_attached: bool = True
    is_default: bool = False
    subpartition_type: PartitionType | None = None
    parent_table: StrippedNonEmptyStr | None = None

    @model_validator(mode="before")
    @classmethod
    def derive_range_bounds(cls, data: object) -> object:
        """Keep ``bounds`` and ``from_value``/``to_value`` in step.

        Both spellings of a RANGE boundary are part of the public surface:
        callers written before structured bounds existed pass the pair, newer
        ones pass ``bounds``. Deriving the missing side here means neither kind
        of caller can observe a half-populated model.
        """
        if not isinstance(data, dict):
            return data

        # A validator that wrote into ``data`` would be editing the caller's own
        # dict -- one they may be about to reuse, or may have built from another
        # model's dump. The copy is shallow: nothing below this level is touched.
        data = dict(data)

        bounds = data.get("bounds")
        if bounds is None:
            from_value, to_value = data.get("from_value"), data.get("to_value")
            if not data.get("is_default") and from_value is not None and to_value is not None:
                data["bounds"] = RangeBounds(from_value=from_value, to_value=to_value)
            elif data.get("is_default"):
                data["bounds"] = DefaultBounds()
        else:
            # ``model_dump()`` renders the bound as a plain dict, so a round-trip
            # through it must derive the pair from the same shape the model form
            # does -- otherwise dumping and re-validating loses from/to_value.
            range_bounds = _as_range_bounds(bounds)
            if range_bounds is not None:
                data.setdefault("from_value", range_bounds.from_value)
                data.setdefault("to_value", range_bounds.to_value)

        return data

    @property
    def is_subpartitioned(self) -> bool:
        """True when this partition is itself a partitioned table (a branch)."""
        return self.subpartition_type is not None

    @property
    def hash_bounds(self) -> HashBounds | None:
        """This partition's ``MODULUS``/``REMAINDER`` bounds, when hash-bound."""
        return self.bounds if isinstance(self.bounds, HashBounds) else None

    @model_validator(mode="after")
    def validate_range_boundaries(self) -> PartitionInfo:
        """Validate that attached RANGE partitions have boundaries.

        Detached (orphaned) partitions may have lost their boundary metadata
        from the catalog and are allowed to carry ``None`` boundaries.

        For attached partitions we accept either parsed boundaries
        (``from_value`` + ``to_value``) OR a raw boundaries expression so that
        callers can still reason about partitions even when expression parsing
        fails.
        """
        if self._requires_boundaries() and not (self._has_parsed_boundaries() or self._has_raw_boundaries()):
            msg = "Attached RANGE partitions must have from_value/to_value or boundaries_expr"
            raise ValueError(msg)
        return self

    def _requires_boundaries(self) -> bool:
        return self.partition_type == PartitionType.RANGE and self.is_attached and not self.is_default

    def _has_parsed_boundaries(self) -> bool:
        return self.from_value is not None and self.to_value is not None

    def _has_raw_boundaries(self) -> bool:
        return self.boundaries_expr is not None and self.boundaries_expr.strip() != ""

    @property
    def schema_name(self) -> str | None:
        """Schema part of :attr:`name`, or None when the name is unqualified."""
        schema, _ = _split_name(self.name)
        return schema

    @property
    def relname(self) -> str:
        """Bare relation name without the schema qualifier.

        ``list_partitions`` always returns schema-qualified names; use this
        when addressing the partition through code that works with bare names
        (period parsing, export layouts, catalogue lookups).
        """
        _, relname = _split_name(self.name)
        return relname

hash_bounds property

This partition's MODULUS/REMAINDER bounds, when hash-bound.

is_subpartitioned property

True when this partition is itself a partitioned table (a branch).

relname property

Bare relation name without the schema qualifier.

list_partitions always returns schema-qualified names; use this when addressing the partition through code that works with bare names (period parsing, export layouts, catalogue lookups).

schema_name property

Schema part of :attr:name, or None when the name is unqualified.

derive_range_bounds(data) classmethod

Keep bounds and from_value/to_value in step.

Both spellings of a RANGE boundary are part of the public surface: callers written before structured bounds existed pass the pair, newer ones pass bounds. Deriving the missing side here means neither kind of caller can observe a half-populated model.

Source code in pg_partsmith/entities.py
@model_validator(mode="before")
@classmethod
def derive_range_bounds(cls, data: object) -> object:
    """Keep ``bounds`` and ``from_value``/``to_value`` in step.

    Both spellings of a RANGE boundary are part of the public surface:
    callers written before structured bounds existed pass the pair, newer
    ones pass ``bounds``. Deriving the missing side here means neither kind
    of caller can observe a half-populated model.
    """
    if not isinstance(data, dict):
        return data

    # A validator that wrote into ``data`` would be editing the caller's own
    # dict -- one they may be about to reuse, or may have built from another
    # model's dump. The copy is shallow: nothing below this level is touched.
    data = dict(data)

    bounds = data.get("bounds")
    if bounds is None:
        from_value, to_value = data.get("from_value"), data.get("to_value")
        if not data.get("is_default") and from_value is not None and to_value is not None:
            data["bounds"] = RangeBounds(from_value=from_value, to_value=to_value)
        elif data.get("is_default"):
            data["bounds"] = DefaultBounds()
    else:
        # ``model_dump()`` renders the bound as a plain dict, so a round-trip
        # through it must derive the pair from the same shape the model form
        # does -- otherwise dumping and re-validating loses from/to_value.
        range_bounds = _as_range_bounds(bounds)
        if range_bounds is not None:
            data.setdefault("from_value", range_bounds.from_value)
            data.setdefault("to_value", range_bounds.to_value)

    return data

validate_range_boundaries()

Validate that attached RANGE partitions have boundaries.

Detached (orphaned) partitions may have lost their boundary metadata from the catalog and are allowed to carry None boundaries.

For attached partitions we accept either parsed boundaries (from_value + to_value) OR a raw boundaries expression so that callers can still reason about partitions even when expression parsing fails.

Source code in pg_partsmith/entities.py
@model_validator(mode="after")
def validate_range_boundaries(self) -> PartitionInfo:
    """Validate that attached RANGE partitions have boundaries.

    Detached (orphaned) partitions may have lost their boundary metadata
    from the catalog and are allowed to carry ``None`` boundaries.

    For attached partitions we accept either parsed boundaries
    (``from_value`` + ``to_value``) OR a raw boundaries expression so that
    callers can still reason about partitions even when expression parsing
    fails.
    """
    if self._requires_boundaries() and not (self._has_parsed_boundaries() or self._has_raw_boundaries()):
        msg = "Attached RANGE partitions must have from_value/to_value or boundaries_expr"
        raise ValueError(msg)
    return self

Bases: BaseModel

Configuration for table partitioning maintenance.

A root is either time-based — RANGE over a date/time dimension, with a create-ahead window and a retention window — or static: HASH_BASED or VALUE_BASED, divided into a fixed set of partitions described by :attr:root_layout, which neither grows with the clock nor ages out.

Either kind can be subpartitioned further.

Attributes:

Name Type Description
schema

Optional schema name for the partitioned table. When set, all DDL and catalogue queries are schema-qualified, making behaviour deterministic in databases with multiple schemas.

table_name StrippedNonEmptyStr

Name of the partitioned table (lowercase, max 63 chars minus the longest generated partition suffix).

partition_type PartitionType

Type of partitioning (RANGE, LIST, HASH).

partition_strategy PartitionStrategy

Strategy for partitioning.

partition_column StrippedNonEmptyStr

The leading column of the table's partition key. For a time-based table this is the time dimension.

trailing_partition_columns tuple[StrippedNonEmptyStr, ...]

The rest of a composite partition key, in key order; empty for the usual single-column case. Trailing columns are bounded with MINVALUE at both ends, so each partition covers exactly one period -- but only for rows whose trailing columns are all non-NULL. PostgreSQL adds an IS NOT NULL test for every key column, so a NULL in any of them routes the row to DEFAULT whatever its period, and DEFAULT is never pruned. Declare them NOT NULL unless you want that.

granularity PartitionGranularity | None

Time granularity (for TIME_BASED strategy).

create_ahead_count PositiveInt

Number of periods to ensure exist, including the current period.

retention_count PositiveInt

Number of partitions to retain. Counted in top-level time periods, never in subpartitions - the time dimension is the lifecycle dimension.

auto_attach_after_create bool

Whether to attach immediately after creation.

root_layout SubpartitionSpec | None

For a HASH_BASED or VALUE_BASED root, the fixed set of partitions the table itself is divided into. Such a table has no time dimension, so it has no create-ahead window and nothing ages out of it — maintenance only converges the set. Must be None for a TIME_BASED root, whose partitions come from its periods.

subpartition SubpartitionSpec | None

Optional subpartitioning applied inside each partition, making it a partitioned table in its own right (for example RANGE(created_at) weekly -> HASH(tenant_id)). Leave None for the classic one-leaf-per-partition layout.

Source code in pg_partsmith/entities.py
class TablePartitionConfig(BaseModel):
    """Configuration for table partitioning maintenance.

    A root is either **time-based** — RANGE over a date/time dimension, with a
    create-ahead window and a retention window — or **static**: HASH_BASED or
    VALUE_BASED, divided into a fixed set of partitions described by
    :attr:`root_layout`, which neither grows with the clock nor ages out.

    Either kind can be subpartitioned further.

    Attributes:
        schema: Optional schema name for the partitioned table. When set, all
            DDL and catalogue queries are schema-qualified, making behaviour
            deterministic in databases with multiple schemas.
        table_name: Name of the partitioned table (lowercase, max 63 chars minus
            the longest generated partition suffix).
        partition_type: Type of partitioning (RANGE, LIST, HASH).
        partition_strategy: Strategy for partitioning.
        partition_column: The leading column of the table's partition key. For
            a time-based table this is the time dimension.
        trailing_partition_columns: The rest of a composite partition key, in
            key order; empty for the usual single-column case. Trailing columns
            are bounded with MINVALUE at both ends, so each partition covers
            exactly one period -- but only for rows whose trailing columns are
            all non-NULL. PostgreSQL adds an IS NOT NULL test for every key
            column, so a NULL in any of them routes the row to DEFAULT whatever
            its period, and DEFAULT is never pruned. Declare them NOT NULL
            unless you want that.
        granularity: Time granularity (for TIME_BASED strategy).
        create_ahead_count: Number of periods to ensure exist, including the current period.
        retention_count: Number of partitions to retain. Counted in top-level
            time periods, never in subpartitions - the time dimension is the
            lifecycle dimension.
        auto_attach_after_create: Whether to attach immediately after creation.
        root_layout: For a HASH_BASED or VALUE_BASED root, the fixed set of
            partitions the table itself is divided into. Such a table has no
            time dimension, so it has no create-ahead window and nothing ages
            out of it — maintenance only converges the set. Must be ``None``
            for a TIME_BASED root, whose partitions come from its periods.
        subpartition: Optional subpartitioning applied inside each partition,
            making it a partitioned table in its own right (for example
            ``RANGE(created_at)`` weekly -> ``HASH(tenant_id)``). Leave ``None``
            for the classic one-leaf-per-partition layout.
    """

    model_config = ConfigDict(frozen=True)

    # NOTE: We store the value under a different field name to avoid Pydantic's
    # warning about shadowing BaseModel.schema(). Externally, the public API is
    # still `schema=...` and `config.db_schema`.
    schema_name: StrippedNonEmptyStr | None = Field(default=None, alias="schema")
    table_name: StrippedNonEmptyStr
    partition_type: PartitionType
    partition_strategy: PartitionStrategy
    partition_column: StrippedNonEmptyStr
    trailing_partition_columns: tuple[StrippedNonEmptyStr, ...] = ()
    granularity: PartitionGranularity | None = None
    create_ahead_count: PositiveInt = Field(
        default=DEFAULT_CREATE_AHEAD_COUNT,
        description="Number of periods to ensure exist, including the current period",
    )
    retention_count: PositiveInt = Field(default=DEFAULT_RETENTION_COUNT, description="Number of partitions to retain")
    auto_attach_after_create: bool = True
    root_layout: SubpartitionSpec | None = None
    subpartition: SubpartitionSpec | None = None

    @property
    def db_schema(self) -> StrippedNonEmptyStr | None:
        """PostgreSQL schema name."""
        return self.schema_name

    @property
    def partition_columns(self) -> tuple[str, ...]:
        """The table's whole partition key, in key order.

        For a time-based table the leading column is the time dimension;
        trailing columns are bounded with MINVALUE at both ends.
        """
        return (self.partition_column, *self.trailing_partition_columns)

    @property
    def key_arity(self) -> int:
        """Number of columns in the table's partition key."""
        return 1 + len(self.trailing_partition_columns)

    @field_validator("table_name", "partition_column")
    @classmethod
    def validate_identifier(cls, v: str) -> str:
        """Validate and normalise SQL identifiers."""
        result = _validate_pg_identifier(v)
        if result is None:
            msg = "SQL identifier cannot be empty"
            raise ValueError(msg)
        return result

    @field_validator("trailing_partition_columns")
    @classmethod
    def validate_trailing_partition_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
        """Validate and normalise the rest of the partition key."""
        return tuple(_require_pg_identifier(column) for column in v)

    @field_validator("schema_name")
    @classmethod
    def validate_schema(cls, v: str | None) -> str | None:
        """Validate and normalise schema name."""
        return _validate_pg_identifier(v)

    @model_validator(mode="after")
    def validate_strategy_requirements(self) -> TablePartitionConfig:
        """Validate strategy-specific requirements."""
        if self.partition_strategy == PartitionStrategy.TIME_BASED:
            self._validate_time_based()
        else:
            self._validate_static_root()

        return self

    @property
    def is_time_based(self) -> bool:
        """True when this table's partitions come from a calendar period.

        A time-based table has a create-ahead window and a retention window; a
        static one — HASH or LIST at the root — has a fixed set of partitions
        that neither grows with the clock nor ages out.
        """
        return self.partition_strategy == PartitionStrategy.TIME_BASED

    def _validate_time_based(self) -> None:
        """Check a TIME_BASED root and the names its periods will generate."""
        if self.granularity is None:
            msg = "TIME_BASED strategy requires granularity"
            raise ValueError(msg)
        if self.partition_type != PartitionType.RANGE:
            msg = "TIME_BASED strategy requires RANGE partition type"
            raise ValueError(msg)
        if self.root_layout is not None:
            msg = (
                "root_layout is only for HASH_BASED / VALUE_BASED roots; a TIME_BASED table's "
                "partitions come from its periods. Use `subpartition` to divide each period."
            )
            raise ValueError(msg)

        # Validate that generated partition names will not exceed PostgreSQL's
        # 63-byte identifier limit (max_identifier_length default). PostgreSQL
        # truncates silently, so two hash buckets could otherwise collapse
        # onto a single name.
        suffix_len = _NAME_SUFFIX_LEN[self.granularity]
        subpartition_len = self.subpartition.name_length_budget() if self.subpartition is not None else 0
        total = len(self.table_name) + suffix_len + subpartition_len
        if total > MAX_IDENTIFIER_LENGTH:
            subpartition_part = f" + subpartition suffix ({subpartition_len})" if subpartition_len else ""
            msg = (
                f"table_name {self.table_name!r} is too long for "
                f"{self.granularity.value} granularity: "
                f"table_name ({len(self.table_name)}) + suffix ({suffix_len})"
                f"{subpartition_part} = {total} > {MAX_IDENTIFIER_LENGTH} bytes."
            )
            raise ValueError(msg)

    def _validate_static_root(self) -> None:
        """Check a HASH_BASED or VALUE_BASED root and its generated names."""
        expected_type = _STATIC_ROOT_TYPES[self.partition_strategy]

        if self.root_layout is None:
            msg = (
                f"{self.partition_strategy.value!r} strategy requires root_layout, describing the "
                f"{expected_type.value.upper()} partitions the table is divided into"
            )
            raise ValueError(msg)
        if self.partition_type != expected_type:
            msg = (
                f"{self.partition_strategy.value!r} strategy requires "
                f"{expected_type.value.upper()} partition type, got {self.partition_type.value.upper()}"
            )
            raise ValueError(msg)
        if self.partition_type == PartitionType.LIST and self.trailing_partition_columns:
            msg = (
                f"LIST partitioning takes exactly one column, got {self.partition_columns!r}. "
                "PostgreSQL rejects a composite LIST key."
            )
            raise ValueError(msg)
        if self.root_layout.partition_type != expected_type:
            msg = (
                f"root_layout describes {self.root_layout.partition_type.value.upper()} partitions but "
                f"{self.partition_strategy.value!r} needs {expected_type.value.upper()}"
            )
            raise ValueError(msg)
        if self.root_layout.columns != self.partition_columns:
            msg = (
                f"root_layout columns {self.root_layout.columns!r} must be the table's own partition "
                f"key {self.partition_columns!r}"
            )
            raise ValueError(msg)
        if self.granularity is not None:
            msg = f"{self.partition_strategy.value!r} strategy has no periods, so granularity must be unset"
            raise ValueError(msg)
        if self.subpartition is not None:
            msg = "Nest deeper levels inside root_layout's own `subpartition` rather than alongside it"
            raise ValueError(msg)

        total = len(self.table_name) + self.root_layout.name_length_budget()
        if total > MAX_IDENTIFIER_LENGTH:
            msg = (
                f"table_name {self.table_name!r} is too long for this layout: table_name "
                f"({len(self.table_name)}) + partition suffix ({self.root_layout.name_length_budget()}) "
                f"= {total} > {MAX_IDENTIFIER_LENGTH} bytes."
            )
            raise ValueError(msg)

    @model_validator(mode="after")
    def validate_subpartitioning(self) -> TablePartitionConfig:
        """Reject subpartitioning the library cannot manage on this root.

        Defence in depth rather than a reachable path today: a static root
        rejects ``subpartition`` earlier and points at ``root_layout``, and a
        TIME_BASED root already has to be RANGE. Kept so that adding a strategy
        cannot open the case silently.
        """
        if self.subpartition is not None and self.partition_type != PartitionType.RANGE:
            msg = "Subpartitioning is only supported under a RANGE-partitioned root table"
            raise ValueError(msg)

        # Every level must divide on a fresh dimension: reusing a column would
        # leave the lower level with nothing left to separate. The root counts,
        # and for a static root it is already the first declared spec.
        columns = list(self.partition_columns)
        if self.root_layout is not None:
            for spec in self.root_layout.walk()[1:]:
                columns.extend(spec.columns)
        elif self.subpartition is not None:
            for spec in self.subpartition.walk():
                columns.extend(spec.columns)

        duplicates = sorted({column for column in columns if columns.count(column) > 1})
        if duplicates:
            msg = f"Partition columns must be distinct across levels; {duplicates!r} appears more than once"
            raise ValueError(msg)

        return self

    @property
    def subpartition_levels(self) -> list[SubpartitionSpec]:
        """Every declared level, outermost first.

        For a static root that starts with :attr:`root_layout` itself; for a
        time-based one the root is the period dimension, which is not a spec, so
        the list starts below it.
        """
        if self.root_layout is not None:
            return self.root_layout.walk()
        return self.subpartition.walk() if self.subpartition is not None else []

db_schema property

PostgreSQL schema name.

is_time_based property

True when this table's partitions come from a calendar period.

A time-based table has a create-ahead window and a retention window; a static one — HASH or LIST at the root — has a fixed set of partitions that neither grows with the clock nor ages out.

key_arity property

Number of columns in the table's partition key.

partition_columns property

The table's whole partition key, in key order.

For a time-based table the leading column is the time dimension; trailing columns are bounded with MINVALUE at both ends.

subpartition_levels property

Every declared level, outermost first.

For a static root that starts with :attr:root_layout itself; for a time-based one the root is the period dimension, which is not a spec, so the list starts below it.

validate_identifier(v) classmethod

Validate and normalise SQL identifiers.

Source code in pg_partsmith/entities.py
@field_validator("table_name", "partition_column")
@classmethod
def validate_identifier(cls, v: str) -> str:
    """Validate and normalise SQL identifiers."""
    result = _validate_pg_identifier(v)
    if result is None:
        msg = "SQL identifier cannot be empty"
        raise ValueError(msg)
    return result

validate_schema(v) classmethod

Validate and normalise schema name.

Source code in pg_partsmith/entities.py
@field_validator("schema_name")
@classmethod
def validate_schema(cls, v: str | None) -> str | None:
    """Validate and normalise schema name."""
    return _validate_pg_identifier(v)

validate_strategy_requirements()

Validate strategy-specific requirements.

Source code in pg_partsmith/entities.py
@model_validator(mode="after")
def validate_strategy_requirements(self) -> TablePartitionConfig:
    """Validate strategy-specific requirements."""
    if self.partition_strategy == PartitionStrategy.TIME_BASED:
        self._validate_time_based()
    else:
        self._validate_static_root()

    return self

validate_subpartitioning()

Reject subpartitioning the library cannot manage on this root.

Defence in depth rather than a reachable path today: a static root rejects subpartition earlier and points at root_layout, and a TIME_BASED root already has to be RANGE. Kept so that adding a strategy cannot open the case silently.

Source code in pg_partsmith/entities.py
@model_validator(mode="after")
def validate_subpartitioning(self) -> TablePartitionConfig:
    """Reject subpartitioning the library cannot manage on this root.

    Defence in depth rather than a reachable path today: a static root
    rejects ``subpartition`` earlier and points at ``root_layout``, and a
    TIME_BASED root already has to be RANGE. Kept so that adding a strategy
    cannot open the case silently.
    """
    if self.subpartition is not None and self.partition_type != PartitionType.RANGE:
        msg = "Subpartitioning is only supported under a RANGE-partitioned root table"
        raise ValueError(msg)

    # Every level must divide on a fresh dimension: reusing a column would
    # leave the lower level with nothing left to separate. The root counts,
    # and for a static root it is already the first declared spec.
    columns = list(self.partition_columns)
    if self.root_layout is not None:
        for spec in self.root_layout.walk()[1:]:
            columns.extend(spec.columns)
    elif self.subpartition is not None:
        for spec in self.subpartition.walk():
            columns.extend(spec.columns)

    duplicates = sorted({column for column in columns if columns.count(column) > 1})
    if duplicates:
        msg = f"Partition columns must be distinct across levels; {duplicates!r} appears more than once"
        raise ValueError(msg)

    return self

validate_trailing_partition_columns(v) classmethod

Validate and normalise the rest of the partition key.

Source code in pg_partsmith/entities.py
@field_validator("trailing_partition_columns")
@classmethod
def validate_trailing_partition_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
    """Validate and normalise the rest of the partition key."""
    return tuple(_require_pg_identifier(column) for column in v)

Bases: BaseModel

Result of partition maintenance operation.

Attributes:

Name Type Description
created_count NonNegativeInt

Number of top-level partitions created. A subpartitioned branch counts once, however many leaves it contains - the branch is the lifecycle unit.

repaired_count NonNegativeInt

Number of subpartitions created inside pre-existing branches to close gaps in their child sets.

detached_count NonNegativeInt

Number of partitions detached in this run.

dropped_count NonNegativeInt

Number of partitions dropped.

duration_ms NonNegativeInt

Duration of maintenance in milliseconds.

error str | None

Fatal error message (set when the whole maintenance run fails).

issues tuple[MaintenanceIssue, ...]

Non-fatal problems. Step failures land here when the run was started with continue_on_error=True; topology divergences that reconciliation deliberately refused to repair are always recorded, since leaving them unreported would hide rejected writes.

Source code in pg_partsmith/entities.py
class MaintenanceResult(BaseModel):
    """Result of partition maintenance operation.

    Attributes:
        created_count: Number of top-level partitions created. A subpartitioned
            branch counts once, however many leaves it contains - the branch is
            the lifecycle unit.
        repaired_count: Number of subpartitions created inside *pre-existing*
            branches to close gaps in their child sets.
        detached_count: Number of partitions detached in this run.
        dropped_count: Number of partitions dropped.
        duration_ms: Duration of maintenance in milliseconds.
        error: Fatal error message (set when the whole maintenance run fails).
        issues: Non-fatal problems. Step failures land here when the run was
            started with ``continue_on_error=True``; topology divergences that
            reconciliation deliberately refused to repair are always recorded,
            since leaving them unreported would hide rejected writes.
    """

    model_config = ConfigDict(frozen=True)

    created_count: NonNegativeInt = 0
    repaired_count: NonNegativeInt = 0
    detached_count: NonNegativeInt = 0
    dropped_count: NonNegativeInt = 0
    duration_ms: NonNegativeInt = 0
    error: str | None = None
    issues: tuple[MaintenanceIssue, ...] = ()

    @property
    def success(self) -> bool:
        """True only when there is no fatal error (non-fatal ``issues`` may exist)."""
        return self.error is None

success property

True only when there is no fatal error (non-fatal issues may exist).

Bases: BaseModel

A non-fatal problem recorded during a maintenance run.

Attributes:

Name Type Description
step MaintenanceIssueStep

Lifecycle step the problem occurred in.

error StrippedNonEmptyStr

Error message (TypeName: message).

partition_name str | None

Partition the problem concerns, when it is specific to one - subpartition reconciliation always sets it.

Source code in pg_partsmith/entities.py
class MaintenanceIssue(BaseModel):
    """A non-fatal problem recorded during a maintenance run.

    Attributes:
        step: Lifecycle step the problem occurred in.
        error: Error message (``TypeName: message``).
        partition_name: Partition the problem concerns, when it is specific to
            one - subpartition reconciliation always sets it.
    """

    model_config = ConfigDict(frozen=True)

    step: MaintenanceIssueStep
    error: StrippedNonEmptyStr
    partition_name: str | None = None

Partition topology

Bounds, subpartition specs, and the introspected tree.

PartitionBounds is the discriminated union of every bound below (RangeBounds | ListBounds | HashBounds | DefaultBounds); SubpartitionBounds is the narrower one a subpartition can be attached with (HashBounds | ListBounds | DefaultBounds), which excludes RANGE because that belongs to the time dimension at the root.

Bases: SubpartitionSpecBase

Divide each partition of the level above into HASH buckets.

modulus is the bucket count for newly created branches only. Existing branches keep the modulus they were built with — a hash set cannot change modulus without a rewrite — so lowering or raising it changes future periods and leaves history alone. See the reconciliation guide.

Attributes:

Name Type Description
strategy Literal['hash']

Discriminator; always "hash".

modulus PositiveInt

Number of hash buckets to create per branch.

name_suffix str

Must contain {remainder} and otherwise only lowercase identifier characters.

Source code in pg_partsmith/topology.py
class HashSubpartitionSpec(SubpartitionSpecBase):
    """Divide each partition of the level above into HASH buckets.

    ``modulus`` is the bucket count for *newly created* branches only. Existing
    branches keep the modulus they were built with — a hash set cannot change
    modulus without a rewrite — so lowering or raising it changes future
    periods and leaves history alone. See the reconciliation guide.

    Attributes:
        strategy: Discriminator; always ``"hash"``.
        modulus: Number of hash buckets to create per branch.
        name_suffix: Must contain ``{remainder}`` and otherwise only lowercase
            identifier characters.
    """

    _NAME_SUFFIX_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^[a-z0-9_]*\{remainder\}[a-z0-9_]*$")

    strategy: Literal["hash"] = "hash"
    modulus: PositiveInt
    name_suffix: str = DEFAULT_HASH_NAME_SUFFIX

    @property
    def partition_type(self) -> PartitionType:
        """PostgreSQL partition type this spec describes."""
        return PartitionType.HASH

    @field_validator("name_suffix")
    @classmethod
    def validate_name_suffix(cls, v: str) -> str:
        """Reject templates that could not produce a safe, unique identifier."""
        if not cls._NAME_SUFFIX_PATTERN.match(v):
            msg = (
                f"name_suffix {v!r} must contain '{{remainder}}' and otherwise only "
                "lowercase letters, digits, and underscores"
            )
            raise ValueError(msg)
        return v

    def child_name(self, parent_relname: str, remainder: int) -> str:
        """Return the bare relation name of one bucket under ``parent_relname``."""
        return f"{parent_relname}{self.name_suffix.format(remainder=remainder)}"

    def bounds_for(self, remainder: int) -> HashBounds:
        """Return the bounds of bucket ``remainder`` at this spec's modulus."""
        return HashBounds(modulus=self.modulus, remainder=remainder)

    def own_name_budget(self) -> int:
        """Bytes this level adds, sized for the widest remainder."""
        widest = len(str(self.modulus - 1))
        return len(self.name_suffix) - len("{remainder}") + widest

columns property

The whole partition key of this level, in key order.

partition_type property

PostgreSQL partition type this spec describes.

bounds_for(remainder)

Return the bounds of bucket remainder at this spec's modulus.

Source code in pg_partsmith/topology.py
def bounds_for(self, remainder: int) -> HashBounds:
    """Return the bounds of bucket ``remainder`` at this spec's modulus."""
    return HashBounds(modulus=self.modulus, remainder=remainder)

child_name(parent_relname, remainder)

Return the bare relation name of one bucket under parent_relname.

Source code in pg_partsmith/topology.py
def child_name(self, parent_relname: str, remainder: int) -> str:
    """Return the bare relation name of one bucket under ``parent_relname``."""
    return f"{parent_relname}{self.name_suffix.format(remainder=remainder)}"

depth()

Number of subpartition levels this spec describes, including itself.

Source code in pg_partsmith/topology.py
def depth(self) -> int:
    """Number of subpartition levels this spec describes, including itself."""
    return 1 + (self.subpartition.depth() if self.subpartition is not None else 0)

name_length_budget()

Bytes this level and everything below it add to a partition name.

Used to keep generated names inside PostgreSQL's 63-byte identifier limit, which truncates silently — two children could otherwise collapse onto one name.

Source code in pg_partsmith/topology.py
def name_length_budget(self) -> int:
    """Bytes this level and everything below it add to a partition name.

    Used to keep generated names inside PostgreSQL's 63-byte identifier
    limit, which truncates silently — two children could otherwise collapse
    onto one name.
    """
    below = self.subpartition.name_length_budget() if self.subpartition is not None else 0
    return self.own_name_budget() + below

own_name_budget()

Bytes this level adds, sized for the widest remainder.

Source code in pg_partsmith/topology.py
def own_name_budget(self) -> int:
    """Bytes this level adds, sized for the widest remainder."""
    widest = len(str(self.modulus - 1))
    return len(self.name_suffix) - len("{remainder}") + widest

validate_column(v) classmethod

Validate and normalise the leading partition key identifier.

Source code in pg_partsmith/topology.py
@field_validator("column")
@classmethod
def validate_column(cls, v: str) -> str:
    """Validate and normalise the leading partition key identifier."""
    return validate_pg_identifier(v)

validate_depth()

Bound the tree depth so a typo cannot fan out into thousands of tables.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_depth(self) -> SubpartitionSpecBase:
    """Bound the tree depth so a typo cannot fan out into thousands of tables."""
    if self.depth() > MAX_SUBPARTITION_DEPTH:
        msg = f"Subpartitioning is limited to {MAX_SUBPARTITION_DEPTH} levels, got {self.depth()}"
        raise ValueError(msg)
    return self

validate_key_is_distinct()

A column repeated in the key would leave one position doing nothing.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_key_is_distinct(self) -> SubpartitionSpecBase:
    """A column repeated in the key would leave one position doing nothing."""
    if len(set(self.columns)) != len(self.columns):
        msg = f"Partition key columns must be distinct, got {self.columns!r}"
        raise ValueError(msg)
    return self

validate_name_suffix(v) classmethod

Reject templates that could not produce a safe, unique identifier.

Source code in pg_partsmith/topology.py
@field_validator("name_suffix")
@classmethod
def validate_name_suffix(cls, v: str) -> str:
    """Reject templates that could not produce a safe, unique identifier."""
    if not cls._NAME_SUFFIX_PATTERN.match(v):
        msg = (
            f"name_suffix {v!r} must contain '{{remainder}}' and otherwise only "
            "lowercase letters, digits, and underscores"
        )
        raise ValueError(msg)
    return v

validate_trailing_columns(v) classmethod

Validate and normalise the rest of the partition key.

Source code in pg_partsmith/topology.py
@field_validator("trailing_columns")
@classmethod
def validate_trailing_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
    """Validate and normalise the rest of the partition key."""
    return tuple(validate_pg_identifier(column) for column in v)

walk()

Return this spec and every spec below it, outermost first.

Source code in pg_partsmith/topology.py
def walk(self) -> list[SubpartitionSpec]:
    """Return this spec and every spec below it, outermost first."""
    specs: list[SubpartitionSpec] = [self]  # type: ignore[list-item]
    if self.subpartition is not None:
        specs.extend(self.subpartition.walk())
    return specs

Bases: SubpartitionSpecBase

Divide each partition of the level above into named LIST partitions.

Unlike HASH, a LIST level is never "complete": there is always another value the world could produce. That is what include_default is for — a catch-all partition so an unknown value is stored rather than rejected.

Because groups are matched by the values they own rather than by name, a tree built by another tool is recognised and left alone instead of being duplicated under different names.

Attributes:

Name Type Description
strategy Literal['list']

Discriminator; always "list".

groups tuple[ListGroup, ...]

The partitions to maintain, each owning an explicit value set.

include_default bool

Maintain a DEFAULT catch-all partition alongside them.

default_name StrippedNonEmptyStr

Identifier fragment for that DEFAULT partition.

name_suffix str

Must contain {name} and otherwise only lowercase identifier characters.

Source code in pg_partsmith/topology.py
class ListSubpartitionSpec(SubpartitionSpecBase):
    """Divide each partition of the level above into named LIST partitions.

    Unlike HASH, a LIST level is never "complete": there is always another
    value the world could produce. That is what ``include_default`` is for — a
    catch-all partition so an unknown value is stored rather than rejected.

    Because groups are matched by the values they own rather than by name, a
    tree built by another tool is recognised and left alone instead of being
    duplicated under different names.

    Attributes:
        strategy: Discriminator; always ``"list"``.
        groups: The partitions to maintain, each owning an explicit value set.
        include_default: Maintain a DEFAULT catch-all partition alongside them.
        default_name: Identifier fragment for that DEFAULT partition.
        name_suffix: Must contain ``{name}`` and otherwise only lowercase
            identifier characters.
    """

    _NAME_SUFFIX_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^[a-z0-9_]*\{name\}[a-z0-9_]*$")

    strategy: Literal["list"] = "list"
    groups: tuple[ListGroup, ...]
    include_default: bool = False
    default_name: StrippedNonEmptyStr = DEFAULT_LIST_DEFAULT_NAME
    name_suffix: str = DEFAULT_LIST_NAME_SUFFIX

    @property
    def partition_type(self) -> PartitionType:
        """PostgreSQL partition type this spec describes."""
        return PartitionType.LIST

    @field_validator("name_suffix")
    @classmethod
    def validate_name_suffix(cls, v: str) -> str:
        """Reject templates that could not produce a safe, unique identifier."""
        if not cls._NAME_SUFFIX_PATTERN.match(v):
            msg = (
                f"name_suffix {v!r} must contain '{{name}}' and otherwise only "
                "lowercase letters, digits, and underscores"
            )
            raise ValueError(msg)
        return v

    @field_validator("default_name")
    @classmethod
    def validate_default_name(cls, v: str) -> str:
        """Keep the DEFAULT partition's fragment safe to splice into a name."""
        return validate_pg_identifier(v)

    @model_validator(mode="after")
    def validate_groups(self) -> ListSubpartitionSpec:
        """Reject a spec PostgreSQL would refuse or that names two partitions alike."""
        if self.trailing_columns:
            msg = (
                f"LIST partitioning takes exactly one column, got {self.columns!r}. "
                "PostgreSQL rejects a composite LIST key."
            )
            raise ValueError(msg)
        if not self.groups:
            msg = "LIST subpartitioning requires at least one group"
            raise ValueError(msg)

        names = [g.name for g in self.groups]
        if self.include_default:
            names.append(self.default_name)
        if len(set(names)) != len(names):
            msg = f"LIST group names must be distinct, got {names!r}"
            raise ValueError(msg)

        seen: dict[str, str] = {}
        for group in self.groups:
            for value in group.values:
                if value in seen:
                    msg = f"LIST value {value!r} is claimed by both {seen[value]!r} and {group.name!r}"
                    raise ValueError(msg)
                seen[value] = group.name

        return self

    def child_name(self, parent_relname: str, name: str) -> str:
        """Return the bare relation name of one child under ``parent_relname``."""
        return f"{parent_relname}{self.name_suffix.format(name=name)}"

    def own_name_budget(self) -> int:
        """Bytes this level adds, sized for the longest group name."""
        names = [g.name for g in self.groups]
        if self.include_default:
            names.append(self.default_name)
        return len(self.name_suffix) - len("{name}") + max(len(n) for n in names)

columns property

The whole partition key of this level, in key order.

partition_type property

PostgreSQL partition type this spec describes.

child_name(parent_relname, name)

Return the bare relation name of one child under parent_relname.

Source code in pg_partsmith/topology.py
def child_name(self, parent_relname: str, name: str) -> str:
    """Return the bare relation name of one child under ``parent_relname``."""
    return f"{parent_relname}{self.name_suffix.format(name=name)}"

depth()

Number of subpartition levels this spec describes, including itself.

Source code in pg_partsmith/topology.py
def depth(self) -> int:
    """Number of subpartition levels this spec describes, including itself."""
    return 1 + (self.subpartition.depth() if self.subpartition is not None else 0)

name_length_budget()

Bytes this level and everything below it add to a partition name.

Used to keep generated names inside PostgreSQL's 63-byte identifier limit, which truncates silently — two children could otherwise collapse onto one name.

Source code in pg_partsmith/topology.py
def name_length_budget(self) -> int:
    """Bytes this level and everything below it add to a partition name.

    Used to keep generated names inside PostgreSQL's 63-byte identifier
    limit, which truncates silently — two children could otherwise collapse
    onto one name.
    """
    below = self.subpartition.name_length_budget() if self.subpartition is not None else 0
    return self.own_name_budget() + below

own_name_budget()

Bytes this level adds, sized for the longest group name.

Source code in pg_partsmith/topology.py
def own_name_budget(self) -> int:
    """Bytes this level adds, sized for the longest group name."""
    names = [g.name for g in self.groups]
    if self.include_default:
        names.append(self.default_name)
    return len(self.name_suffix) - len("{name}") + max(len(n) for n in names)

validate_column(v) classmethod

Validate and normalise the leading partition key identifier.

Source code in pg_partsmith/topology.py
@field_validator("column")
@classmethod
def validate_column(cls, v: str) -> str:
    """Validate and normalise the leading partition key identifier."""
    return validate_pg_identifier(v)

validate_default_name(v) classmethod

Keep the DEFAULT partition's fragment safe to splice into a name.

Source code in pg_partsmith/topology.py
@field_validator("default_name")
@classmethod
def validate_default_name(cls, v: str) -> str:
    """Keep the DEFAULT partition's fragment safe to splice into a name."""
    return validate_pg_identifier(v)

validate_depth()

Bound the tree depth so a typo cannot fan out into thousands of tables.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_depth(self) -> SubpartitionSpecBase:
    """Bound the tree depth so a typo cannot fan out into thousands of tables."""
    if self.depth() > MAX_SUBPARTITION_DEPTH:
        msg = f"Subpartitioning is limited to {MAX_SUBPARTITION_DEPTH} levels, got {self.depth()}"
        raise ValueError(msg)
    return self

validate_groups()

Reject a spec PostgreSQL would refuse or that names two partitions alike.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_groups(self) -> ListSubpartitionSpec:
    """Reject a spec PostgreSQL would refuse or that names two partitions alike."""
    if self.trailing_columns:
        msg = (
            f"LIST partitioning takes exactly one column, got {self.columns!r}. "
            "PostgreSQL rejects a composite LIST key."
        )
        raise ValueError(msg)
    if not self.groups:
        msg = "LIST subpartitioning requires at least one group"
        raise ValueError(msg)

    names = [g.name for g in self.groups]
    if self.include_default:
        names.append(self.default_name)
    if len(set(names)) != len(names):
        msg = f"LIST group names must be distinct, got {names!r}"
        raise ValueError(msg)

    seen: dict[str, str] = {}
    for group in self.groups:
        for value in group.values:
            if value in seen:
                msg = f"LIST value {value!r} is claimed by both {seen[value]!r} and {group.name!r}"
                raise ValueError(msg)
            seen[value] = group.name

    return self

validate_key_is_distinct()

A column repeated in the key would leave one position doing nothing.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_key_is_distinct(self) -> SubpartitionSpecBase:
    """A column repeated in the key would leave one position doing nothing."""
    if len(set(self.columns)) != len(self.columns):
        msg = f"Partition key columns must be distinct, got {self.columns!r}"
        raise ValueError(msg)
    return self

validate_name_suffix(v) classmethod

Reject templates that could not produce a safe, unique identifier.

Source code in pg_partsmith/topology.py
@field_validator("name_suffix")
@classmethod
def validate_name_suffix(cls, v: str) -> str:
    """Reject templates that could not produce a safe, unique identifier."""
    if not cls._NAME_SUFFIX_PATTERN.match(v):
        msg = (
            f"name_suffix {v!r} must contain '{{name}}' and otherwise only "
            "lowercase letters, digits, and underscores"
        )
        raise ValueError(msg)
    return v

validate_trailing_columns(v) classmethod

Validate and normalise the rest of the partition key.

Source code in pg_partsmith/topology.py
@field_validator("trailing_columns")
@classmethod
def validate_trailing_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
    """Validate and normalise the rest of the partition key."""
    return tuple(validate_pg_identifier(column) for column in v)

walk()

Return this spec and every spec below it, outermost first.

Source code in pg_partsmith/topology.py
def walk(self) -> list[SubpartitionSpec]:
    """Return this spec and every spec below it, outermost first."""
    specs: list[SubpartitionSpec] = [self]  # type: ignore[list-item]
    if self.subpartition is not None:
        specs.extend(self.subpartition.walk())
    return specs

Bases: BaseModel

One named LIST partition and the key values it owns.

Attributes:

Name Type Description
name StrippedNonEmptyStr

Identifier fragment used to name the partition.

values tuple[StrippedNonEmptyStr, ...]

Values routed to it. Rendered as SQL string literals, which PostgreSQL coerces to the partition key's type, so numeric and textual keys are both written as strings here.

Source code in pg_partsmith/topology.py
class ListGroup(BaseModel):
    """One named LIST partition and the key values it owns.

    Attributes:
        name: Identifier fragment used to name the partition.
        values: Values routed to it. Rendered as SQL string literals, which
            PostgreSQL coerces to the partition key's type, so numeric and
            textual keys are both written as strings here.
    """

    model_config = ConfigDict(frozen=True)

    name: StrippedNonEmptyStr
    values: tuple[StrippedNonEmptyStr, ...]

    @field_validator("name")
    @classmethod
    def validate_name(cls, v: str) -> str:
        """Keep the fragment safe to splice into an identifier."""
        return validate_pg_identifier(v)

    @model_validator(mode="after")
    def validate_values(self) -> ListGroup:
        """A LIST partition owning no values could never route a row."""
        if not self.values:
            msg = f"LIST group {self.name!r} must own at least one value"
            raise ValueError(msg)
        if len(set(self.values)) != len(self.values):
            msg = f"LIST group {self.name!r} repeats a value: {self.values!r}"
            raise ValueError(msg)
        return self

    def bounds(self) -> ListBounds:
        """Return this group's partition bounds."""
        return ListBounds(values=self.values)

bounds()

Return this group's partition bounds.

Source code in pg_partsmith/topology.py
def bounds(self) -> ListBounds:
    """Return this group's partition bounds."""
    return ListBounds(values=self.values)

validate_name(v) classmethod

Keep the fragment safe to splice into an identifier.

Source code in pg_partsmith/topology.py
@field_validator("name")
@classmethod
def validate_name(cls, v: str) -> str:
    """Keep the fragment safe to splice into an identifier."""
    return validate_pg_identifier(v)

validate_values()

A LIST partition owning no values could never route a row.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_values(self) -> ListGroup:
    """A LIST partition owning no values could never route a row."""
    if not self.values:
        msg = f"LIST group {self.name!r} must own at least one value"
        raise ValueError(msg)
    if len(set(self.values)) != len(self.values):
        msg = f"LIST group {self.name!r} repeats a value: {self.values!r}"
        raise ValueError(msg)
    return self

Bases: BaseModel

Fields and tree arithmetic shared by every subpartitioning strategy.

A key is spelled as one leading column plus an optional tail, rather than a single tuple, so that column stays an ordinary field: it can be read without ever raising, and model_copy(update={"column": ...}) does what it says. :attr:columns derives the whole key from the two.

Attributes:

Name Type Description
column StrippedNonEmptyStr

The leading column this level partitions on.

trailing_columns tuple[StrippedNonEmptyStr, ...]

The rest of the key, in key order; empty for the usual single-column case. Every column named here and above must be part of every UNIQUE/PRIMARY KEY constraint on the root table, or PostgreSQL refuses the subtree.

name_suffix str

Template appended to the parent's name to name each child.

subpartition SubpartitionSpec | None

Optional further level of subpartitioning.

Source code in pg_partsmith/topology.py
class SubpartitionSpecBase(BaseModel):
    """Fields and tree arithmetic shared by every subpartitioning strategy.

    A key is spelled as one leading column plus an optional tail, rather than a
    single tuple, so that ``column`` stays an ordinary field: it can be read
    without ever raising, and ``model_copy(update={"column": ...})`` does what
    it says. :attr:`columns` derives the whole key from the two.

    Attributes:
        column: The leading column this level partitions on.
        trailing_columns: The rest of the key, in key order; empty for the usual
            single-column case. Every column named here and above must be part
            of every UNIQUE/PRIMARY KEY constraint on the root table, or
            PostgreSQL refuses the subtree.
        name_suffix: Template appended to the parent's name to name each child.
        subpartition: Optional further level of subpartitioning.
    """

    model_config = ConfigDict(frozen=True)

    column: StrippedNonEmptyStr
    trailing_columns: tuple[StrippedNonEmptyStr, ...] = ()
    name_suffix: str
    subpartition: SubpartitionSpec | None = None

    @property
    def columns(self) -> tuple[str, ...]:
        """The whole partition key of this level, in key order."""
        return (self.column, *self.trailing_columns)

    @property
    def partition_type(self) -> PartitionType:
        """PostgreSQL partition type this spec describes."""
        raise NotImplementedError

    @field_validator("column")
    @classmethod
    def validate_column(cls, v: str) -> str:
        """Validate and normalise the leading partition key identifier."""
        return validate_pg_identifier(v)

    @field_validator("trailing_columns")
    @classmethod
    def validate_trailing_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
        """Validate and normalise the rest of the partition key."""
        return tuple(validate_pg_identifier(column) for column in v)

    @model_validator(mode="after")
    def validate_key_is_distinct(self) -> SubpartitionSpecBase:
        """A column repeated in the key would leave one position doing nothing."""
        if len(set(self.columns)) != len(self.columns):
            msg = f"Partition key columns must be distinct, got {self.columns!r}"
            raise ValueError(msg)
        return self

    def own_name_budget(self) -> int:
        """Bytes this level alone adds to a child's name."""
        raise NotImplementedError

    def name_length_budget(self) -> int:
        """Bytes this level and everything below it add to a partition name.

        Used to keep generated names inside PostgreSQL's 63-byte identifier
        limit, which truncates silently — two children could otherwise collapse
        onto one name.
        """
        below = self.subpartition.name_length_budget() if self.subpartition is not None else 0
        return self.own_name_budget() + below

    def depth(self) -> int:
        """Number of subpartition levels this spec describes, including itself."""
        return 1 + (self.subpartition.depth() if self.subpartition is not None else 0)

    def walk(self) -> list[SubpartitionSpec]:
        """Return this spec and every spec below it, outermost first."""
        specs: list[SubpartitionSpec] = [self]  # type: ignore[list-item]
        if self.subpartition is not None:
            specs.extend(self.subpartition.walk())
        return specs

    @model_validator(mode="after")
    def validate_depth(self) -> SubpartitionSpecBase:
        """Bound the tree depth so a typo cannot fan out into thousands of tables."""
        if self.depth() > MAX_SUBPARTITION_DEPTH:
            msg = f"Subpartitioning is limited to {MAX_SUBPARTITION_DEPTH} levels, got {self.depth()}"
            raise ValueError(msg)
        return self

columns property

The whole partition key of this level, in key order.

partition_type property

PostgreSQL partition type this spec describes.

depth()

Number of subpartition levels this spec describes, including itself.

Source code in pg_partsmith/topology.py
def depth(self) -> int:
    """Number of subpartition levels this spec describes, including itself."""
    return 1 + (self.subpartition.depth() if self.subpartition is not None else 0)

name_length_budget()

Bytes this level and everything below it add to a partition name.

Used to keep generated names inside PostgreSQL's 63-byte identifier limit, which truncates silently — two children could otherwise collapse onto one name.

Source code in pg_partsmith/topology.py
def name_length_budget(self) -> int:
    """Bytes this level and everything below it add to a partition name.

    Used to keep generated names inside PostgreSQL's 63-byte identifier
    limit, which truncates silently — two children could otherwise collapse
    onto one name.
    """
    below = self.subpartition.name_length_budget() if self.subpartition is not None else 0
    return self.own_name_budget() + below

own_name_budget()

Bytes this level alone adds to a child's name.

Source code in pg_partsmith/topology.py
def own_name_budget(self) -> int:
    """Bytes this level alone adds to a child's name."""
    raise NotImplementedError

validate_column(v) classmethod

Validate and normalise the leading partition key identifier.

Source code in pg_partsmith/topology.py
@field_validator("column")
@classmethod
def validate_column(cls, v: str) -> str:
    """Validate and normalise the leading partition key identifier."""
    return validate_pg_identifier(v)

validate_depth()

Bound the tree depth so a typo cannot fan out into thousands of tables.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_depth(self) -> SubpartitionSpecBase:
    """Bound the tree depth so a typo cannot fan out into thousands of tables."""
    if self.depth() > MAX_SUBPARTITION_DEPTH:
        msg = f"Subpartitioning is limited to {MAX_SUBPARTITION_DEPTH} levels, got {self.depth()}"
        raise ValueError(msg)
    return self

validate_key_is_distinct()

A column repeated in the key would leave one position doing nothing.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_key_is_distinct(self) -> SubpartitionSpecBase:
    """A column repeated in the key would leave one position doing nothing."""
    if len(set(self.columns)) != len(self.columns):
        msg = f"Partition key columns must be distinct, got {self.columns!r}"
        raise ValueError(msg)
    return self

validate_trailing_columns(v) classmethod

Validate and normalise the rest of the partition key.

Source code in pg_partsmith/topology.py
@field_validator("trailing_columns")
@classmethod
def validate_trailing_columns(cls, v: tuple[str, ...]) -> tuple[str, ...]:
    """Validate and normalise the rest of the partition key."""
    return tuple(validate_pg_identifier(column) for column in v)

walk()

Return this spec and every spec below it, outermost first.

Source code in pg_partsmith/topology.py
def walk(self) -> list[SubpartitionSpec]:
    """Return this spec and every spec below it, outermost first."""
    specs: list[SubpartitionSpec] = [self]  # type: ignore[list-item]
    if self.subpartition is not None:
        specs.extend(self.subpartition.walk())
    return specs

Bases: BaseModel

One relation in an introspected partition tree.

A node describes both how it sits in its parent (:attr:bounds) and how it partitions its own children (:attr:partition_type) — the two are independent, which is exactly what makes a nested tree expressible: a branch is a partition and a partitioned table at once.

Attributes:

Name Type Description
name StrippedNonEmptyStr

Schema-qualified relation name.

parent_name StrippedNonEmptyStr | None

Schema-qualified parent name; None for the queried root.

level NonNegativeInt

Depth below the queried root (0 for the root itself).

partition_type PartitionType | None

How this relation partitions its children; None when it is a plain (leaf) table. Note that :attr:~pg_partsmith.PartitionInfo.partition_type means the opposite -- how the relation's parent partitions it -- and that the equivalent of this field there is subpartition_type.

partition_columns tuple[str, ...]

This relation's own partition key columns.

bounds PartitionBounds | None

How this relation is bound inside its parent; None for the root.

is_attached bool

pg_class.relispartition. Descendants reached through a parent are attached by construction — a detached relation is not in anyone's tree — so this is informative mainly for the root itself.

children tuple[PartitionNode, ...]

Direct children, ordered by name.

has_unaddressable_children bool

Whether a child was left out of :attr:children because its name cannot be addressed by qualified-name DDL. The child set is then a subset of the real one, so nothing may be planned from it: a partition that looks missing may be one of the omitted ones.

has_expression_key bool

Whether any position of this relation's own partition key is an expression rather than a column. Such a position has no name, so :attr:partition_columns is shorter than the real key and must not be compared against a spec as if it were complete.

Source code in pg_partsmith/topology.py
class PartitionNode(BaseModel):
    """One relation in an introspected partition tree.

    A node describes both how it sits in its parent (:attr:`bounds`) and how it
    partitions its own children (:attr:`partition_type`) — the two are
    independent, which is exactly what makes a nested tree expressible: a
    branch is a partition *and* a partitioned table at once.

    Attributes:
        name: Schema-qualified relation name.
        parent_name: Schema-qualified parent name; None for the queried root.
        level: Depth below the queried root (0 for the root itself).
        partition_type: How this relation partitions its *children*; None when
            it is a plain (leaf) table. Note that
            :attr:`~pg_partsmith.PartitionInfo.partition_type` means the
            opposite -- how the relation's *parent* partitions it -- and that
            the equivalent of this field there is ``subpartition_type``.
        partition_columns: This relation's own partition key columns.
        bounds: How this relation is bound inside its parent; None for the root.
        is_attached: ``pg_class.relispartition``. Descendants reached through a
            parent are attached by construction — a detached relation is not in
            anyone's tree — so this is informative mainly for the root itself.
        children: Direct children, ordered by name.
        has_unaddressable_children: Whether a child was left out of
            :attr:`children` because its name cannot be addressed by
            qualified-name DDL. The child set is then a subset of the real one,
            so nothing may be planned from it: a partition that looks missing
            may be one of the omitted ones.
        has_expression_key: Whether any position of this relation's own
            partition key is an expression rather than a column. Such a
            position has no name, so :attr:`partition_columns` is shorter than
            the real key and must not be compared against a spec as if it were
            complete.
    """

    model_config = ConfigDict(frozen=True)

    name: StrippedNonEmptyStr
    parent_name: StrippedNonEmptyStr | None = None
    level: NonNegativeInt = 0
    partition_type: PartitionType | None = None
    partition_columns: tuple[str, ...] = ()
    bounds: PartitionBounds | None = None
    is_attached: bool = True
    children: tuple[PartitionNode, ...] = ()
    has_unaddressable_children: bool = False
    has_expression_key: bool = False

    @property
    def is_leaf(self) -> bool:
        """True when this relation is a plain table that cannot hold partitions."""
        return self.partition_type is None

    @property
    def relname(self) -> str:
        """Bare relation name without the schema qualifier."""
        _, _, relname = self.name.rpartition(".")
        return relname or self.name

    @property
    def hash_children(self) -> tuple[PartitionNode, ...]:
        """Children bound by ``MODULUS``/``REMAINDER``."""
        return tuple(c for c in self.children if isinstance(c.bounds, HashBounds))

    def walk(self) -> list[PartitionNode]:
        """Return this node and every node below it, depth-first."""
        nodes = [self]
        for child in self.children:
            nodes.extend(child.walk())
        return nodes

    def find(self, name: str) -> PartitionNode | None:
        """Return the node with schema-qualified ``name``, or None."""
        return next((n for n in self.walk() if n.name == name), None)

    def describe_topology(self) -> str:
        """Render a one-line summary used in topology diagnostics."""
        if self.is_leaf:
            return "a plain leaf table"
        columns = ", ".join(self.partition_columns) or "?"
        assert self.partition_type is not None  # guarded by is_leaf above
        return f"partitioned by {self.partition_type.value.upper()} ({columns})"

hash_children property

Children bound by MODULUS/REMAINDER.

is_leaf property

True when this relation is a plain table that cannot hold partitions.

relname property

Bare relation name without the schema qualifier.

describe_topology()

Render a one-line summary used in topology diagnostics.

Source code in pg_partsmith/topology.py
def describe_topology(self) -> str:
    """Render a one-line summary used in topology diagnostics."""
    if self.is_leaf:
        return "a plain leaf table"
    columns = ", ".join(self.partition_columns) or "?"
    assert self.partition_type is not None  # guarded by is_leaf above
    return f"partitioned by {self.partition_type.value.upper()} ({columns})"

find(name)

Return the node with schema-qualified name, or None.

Source code in pg_partsmith/topology.py
def find(self, name: str) -> PartitionNode | None:
    """Return the node with schema-qualified ``name``, or None."""
    return next((n for n in self.walk() if n.name == name), None)

walk()

Return this node and every node below it, depth-first.

Source code in pg_partsmith/topology.py
def walk(self) -> list[PartitionNode]:
    """Return this node and every node below it, depth-first."""
    nodes = [self]
    for child in self.children:
        nodes.extend(child.walk())
    return nodes

Bases: BaseModel

FOR VALUES FROM (from_value) TO (to_value).

Attributes:

Name Type Description
from_value StrippedNonEmptyStr

Lower bound, inclusive.

to_value StrippedNonEmptyStr

Upper bound, exclusive.

Source code in pg_partsmith/topology.py
class RangeBounds(BaseModel):
    """``FOR VALUES FROM (from_value) TO (to_value)``.

    Attributes:
        from_value: Lower bound, inclusive.
        to_value: Upper bound, exclusive.
    """

    model_config = ConfigDict(frozen=True)

    kind: Literal["range"] = "range"
    from_value: StrippedNonEmptyStr
    to_value: StrippedNonEmptyStr

Bases: BaseModel

FOR VALUES WITH (MODULUS modulus, REMAINDER remainder).

A hash partition owns the rows whose key hash is congruent to remainder modulo modulus; a set of them is complete only when the owned residue classes tile the whole keyspace (see :func:hash_keyspace_covered).

Attributes:

Name Type Description
modulus PositiveInt

Number of buckets this partition's residue class is taken from.

remainder NonNegativeInt

Residue this partition owns; always < modulus.

Source code in pg_partsmith/topology.py
class HashBounds(BaseModel):
    """``FOR VALUES WITH (MODULUS modulus, REMAINDER remainder)``.

    A hash partition owns the rows whose key hash is congruent to
    ``remainder`` modulo ``modulus``; a set of them is complete only when the
    owned residue classes tile the whole keyspace (see
    :func:`hash_keyspace_covered`).

    Attributes:
        modulus: Number of buckets this partition's residue class is taken from.
        remainder: Residue this partition owns; always ``< modulus``.
    """

    model_config = ConfigDict(frozen=True)

    kind: Literal["hash"] = "hash"
    modulus: PositiveInt
    remainder: NonNegativeInt

    @model_validator(mode="after")
    def validate_remainder_in_range(self) -> HashBounds:
        """Reject a remainder outside ``[0, modulus)`` — PostgreSQL would too."""
        if self.remainder >= self.modulus:
            msg = f"remainder must be < modulus, got remainder={self.remainder} modulus={self.modulus}"
            raise ValueError(msg)
        return self

validate_remainder_in_range()

Reject a remainder outside [0, modulus) — PostgreSQL would too.

Source code in pg_partsmith/topology.py
@model_validator(mode="after")
def validate_remainder_in_range(self) -> HashBounds:
    """Reject a remainder outside ``[0, modulus)`` — PostgreSQL would too."""
    if self.remainder >= self.modulus:
        msg = f"remainder must be < modulus, got remainder={self.remainder} modulus={self.modulus}"
        raise ValueError(msg)
    return self

Bases: BaseModel

FOR VALUES IN (values…).

Attributes:

Name Type Description
values tuple[str, ...]

The literal values routed to this partition.

includes_null bool

Whether NULL itself is one of them. It is kept apart from :attr:values because IN (NULL) and IN ('NULL') are different partitions, and reading them as the same one would make the planner propose a partition PostgreSQL already has.

Source code in pg_partsmith/topology.py
class ListBounds(BaseModel):
    """``FOR VALUES IN (values…)``.

    Attributes:
        values: The literal values routed to this partition.
        includes_null: Whether ``NULL`` itself is one of them. It is kept apart
            from :attr:`values` because ``IN (NULL)`` and ``IN ('NULL')`` are
            different partitions, and reading them as the same one would make
            the planner propose a partition PostgreSQL already has.
    """

    model_config = ConfigDict(frozen=True)

    kind: Literal["list"] = "list"
    values: tuple[str, ...]
    includes_null: bool = False

Bases: BaseModel

DEFAULT — the catch-all partition of a RANGE or LIST parent.

Source code in pg_partsmith/topology.py
class DefaultBounds(BaseModel):
    """``DEFAULT`` — the catch-all partition of a RANGE or LIST parent."""

    model_config = ConfigDict(frozen=True)

    kind: Literal["default"] = "default"

Shape of a PostgreSQL partition tree: bounds, subpartition specs, and nodes.

Everything here is IO-free and shared by the aio and sync mirrors:

  • :class:PartitionType — how a relation partitions its children.
  • *Bounds — how a relation is bound inside its parent, as PostgreSQL renders it (FOR VALUES FROM … TO … / WITH (MODULUS … REMAINDER …) / IN (…) / DEFAULT).
  • :class:HashSubpartitionSpec — the subpartitioning a user asks for.
  • :class:PartitionNode — the tree that actually exists, as introspected from pg_partition_tree and friends.

The planner that turns the difference between the last two into DDL intentions lives in :mod:pg_partsmith.subpartition_plan.

uniform_modulus(bounds)

Return the single modulus shared by bounds, or None when they differ.

An empty set has no modulus and also returns None; callers distinguish the two cases by checking bounds themselves.

Source code in pg_partsmith/topology.py
def uniform_modulus(bounds: tuple[HashBounds, ...]) -> int | None:
    """Return the single modulus shared by ``bounds``, or None when they differ.

    An empty set has no modulus and also returns None; callers distinguish the
    two cases by checking ``bounds`` themselves.
    """
    moduli = {b.modulus for b in bounds}
    return moduli.pop() if len(moduli) == 1 else None

hash_keyspace_covered(bounds)

True when bounds tile the whole hash keyspace.

PostgreSQL allows hash siblings at different moduli as long as their residue classes do not overlap — (2, 1) and (4, 0) coexist happily. Such a set is complete only if every residue modulo the least common multiple of the moduli is owned by someone; a gap means rows hashing there are rejected outright with a check violation, so it must be detected rather than assumed.

Returns:

Type Description
bool | None

True/False, or None when the moduli are too coarse to enumerate within

bool | None

data:~pg_partsmith.constants.MAX_HASH_KEYSPACE_LCM (coverage is then

bool | None

unknown and must not be guessed at).

Source code in pg_partsmith/topology.py
def hash_keyspace_covered(bounds: tuple[HashBounds, ...]) -> bool | None:
    """True when ``bounds`` tile the whole hash keyspace.

    PostgreSQL allows hash siblings at *different* moduli as long as their
    residue classes do not overlap — ``(2, 1)`` and ``(4, 0)`` coexist happily.
    Such a set is complete only if every residue modulo the least common
    multiple of the moduli is owned by someone; a gap means rows hashing there
    are rejected outright with a check violation, so it must be detected rather
    than assumed.

    Returns:
        True/False, or None when the moduli are too coarse to enumerate within
        :data:`~pg_partsmith.constants.MAX_HASH_KEYSPACE_LCM` (coverage is then
        unknown and must not be guessed at).
    """
    if not bounds:
        return False

    span = math.lcm(*(b.modulus for b in bounds))
    if span > MAX_HASH_KEYSPACE_LCM:
        return None

    covered: set[int] = set()
    for b in bounds:
        covered.update(range(b.remainder, span, b.modulus))
    return len(covered) == span

missing_remainders(modulus, bounds)

Return the remainders at modulus that bounds do not already own.

Source code in pg_partsmith/topology.py
def missing_remainders(modulus: int, bounds: tuple[HashBounds, ...]) -> tuple[int, ...]:
    """Return the remainders at ``modulus`` that ``bounds`` do not already own."""
    present = {b.remainder for b in bounds if b.modulus == modulus}
    return tuple(r for r in range(modulus) if r not in present)

Subpartition reconciliation

Bases: BaseModel

What to create under one branch, and what was deliberately left alone.

Attributes:

Name Type Description
actions tuple[SubpartitionAction, ...]

Partitions to create, nested parent-before-child.

findings tuple[TopologyFinding, ...]

Divergences the planner refused to repair automatically.

Source code in pg_partsmith/subpartition_plan.py
class SubpartitionPlan(BaseModel):
    """What to create under one branch, and what was deliberately left alone.

    Attributes:
        actions: Partitions to create, nested parent-before-child.
        findings: Divergences the planner refused to repair automatically.
    """

    model_config = ConfigDict(frozen=True)

    actions: tuple[SubpartitionAction, ...] = ()
    findings: tuple[TopologyFinding, ...] = ()

    @property
    def is_noop(self) -> bool:
        """True when converging this branch requires no DDL at all."""
        return not self.actions

    def count(self) -> NonNegativeInt:
        """Total number of relations this plan creates."""
        return sum(action.count() for action in self.actions)

    @property
    def actionable_findings(self) -> tuple[TopologyFinding, ...]:
        """Findings an operator has to act on."""
        return tuple(f for f in self.findings if f.is_actionable)

actionable_findings property

Findings an operator has to act on.

is_noop property

True when converging this branch requires no DDL at all.

count()

Total number of relations this plan creates.

Source code in pg_partsmith/subpartition_plan.py
def count(self) -> NonNegativeInt:
    """Total number of relations this plan creates."""
    return sum(action.count() for action in self.actions)

Bases: BaseModel

One partition to create, together with the subtree to build inside it.

Executed depth-first: create child_name detached, build its own children, and only then attach it to parent_name. A subtree therefore becomes reachable from the parent only once it is complete, so a crash mid-way can never expose a branch that rejects rows.

Attributes:

Name Type Description
parent_name StrippedNonEmptyStr

Schema-qualified relation the new partition attaches to.

child_name StrippedNonEmptyStr

Schema-qualified name of the partition to create.

bounds SubpartitionBounds

Bounds to attach child_name with (hash bucket, list values, or DEFAULT).

subpartition SubpartitionSpec | None

How child_name partitions its own children, if at all.

children tuple[SubpartitionAction, ...]

Partitions to create inside child_name before attaching it.

Source code in pg_partsmith/subpartition_plan.py
class SubpartitionAction(BaseModel):
    """One partition to create, together with the subtree to build inside it.

    Executed depth-first: create ``child_name`` detached, build its own
    ``children``, and only then attach it to ``parent_name``. A subtree
    therefore becomes reachable from the parent only once it is complete, so a
    crash mid-way can never expose a branch that rejects rows.

    Attributes:
        parent_name: Schema-qualified relation the new partition attaches to.
        child_name: Schema-qualified name of the partition to create.
        bounds: Bounds to attach ``child_name`` with (hash bucket, list
            values, or DEFAULT).
        subpartition: How ``child_name`` partitions its own children, if at all.
        children: Partitions to create inside ``child_name`` before attaching it.
    """

    model_config = ConfigDict(frozen=True)

    parent_name: StrippedNonEmptyStr
    child_name: StrippedNonEmptyStr
    bounds: SubpartitionBounds
    subpartition: SubpartitionSpec | None = None
    children: tuple[SubpartitionAction, ...] = ()

    def count(self) -> int:
        """Total number of relations this action and its descendants create."""
        return 1 + sum(child.count() for child in self.children)

count()

Total number of relations this action and its descendants create.

Source code in pg_partsmith/subpartition_plan.py
def count(self) -> int:
    """Total number of relations this action and its descendants create."""
    return 1 + sum(child.count() for child in self.children)

Bases: BaseModel

Outcome of converging one or more branches towards their spec.

Attributes:

Name Type Description
created_count NonNegativeInt

Subpartitions actually attached during this run.

findings tuple[TopologyFinding, ...]

Divergences the planner refused to repair automatically.

Source code in pg_partsmith/subpartition_plan.py
class SubpartitionReconcileResult(BaseModel):
    """Outcome of converging one or more branches towards their spec.

    Attributes:
        created_count: Subpartitions actually attached during this run.
        findings: Divergences the planner refused to repair automatically.
    """

    model_config = ConfigDict(frozen=True)

    created_count: NonNegativeInt = 0
    findings: tuple[TopologyFinding, ...] = ()

    def merge(self, other: SubpartitionReconcileResult) -> SubpartitionReconcileResult:
        """Combine two results, preserving finding order."""
        return SubpartitionReconcileResult(
            created_count=self.created_count + other.created_count,
            findings=self.findings + other.findings,
        )

merge(other)

Combine two results, preserving finding order.

Source code in pg_partsmith/subpartition_plan.py
def merge(self, other: SubpartitionReconcileResult) -> SubpartitionReconcileResult:
    """Combine two results, preserving finding order."""
    return SubpartitionReconcileResult(
        created_count=self.created_count + other.created_count,
        findings=self.findings + other.findings,
    )

Bases: BaseModel

Something the planner observed and chose not to change.

Attributes:

Name Type Description
partition_name StrippedNonEmptyStr

Schema-qualified name of the branch concerned.

reason TopologyReason

Which convergence rule applied.

detail StrippedNonEmptyStr

Human-readable explanation, safe to log or surface verbatim.

Source code in pg_partsmith/subpartition_plan.py
class TopologyFinding(BaseModel):
    """Something the planner observed and chose not to change.

    Attributes:
        partition_name: Schema-qualified name of the branch concerned.
        reason: Which convergence rule applied.
        detail: Human-readable explanation, safe to log or surface verbatim.
    """

    model_config = ConfigDict(frozen=True)

    partition_name: StrippedNonEmptyStr
    reason: TopologyReason
    detail: StrippedNonEmptyStr

    @property
    def is_actionable(self) -> bool:
        """True when an operator has to do something about this finding."""
        return self.reason not in _INFORMATIONAL_REASONS

is_actionable property

True when an operator has to do something about this finding.

Bases: StrEnum

Why the planner left an existing subtree alone.

Attributes:

Name Type Description
LEGACY_LEAF

The branch is a plain table created before the current subpartitioning policy. PostgreSQL cannot add partitions to it.

STRATEGY_MISMATCH

The branch is subpartitioned by a different strategy than the config asks for.

COLUMN_MISMATCH

The branch is subpartitioned by the right strategy but on a different column.

MODULUS_PRESERVED

The branch has a complete hash set at a modulus the config no longer uses. It already tiles the keyspace, so it stays.

MODULUS_REPAIRED

The branch has an incomplete hash set at a modulus the config no longer uses; the gaps were filled at the branch's own modulus, which is the only modulus that cannot overlap it.

NON_UNIFORM_COMPLETE

Hash siblings disagree on modulus but still tile the keyspace — legal, and left untouched.

NON_UNIFORM_INCOMPLETE

Hash siblings disagree on modulus and leave a gap. Rows hashing into it are rejected, and no repair is provably safe, so this needs a human.

COVERAGE_UNKNOWN

The moduli are too coarse to enumerate, so coverage could not be verified.

LIST_VALUES_CONFLICT

A configured LIST group claims a value another partition already owns. A value belongs to exactly one partition, so this needs a human.

NAME_UNUSABLE

The partition the configuration asks for cannot be given a usable name -- the name is taken by a relation that does not match it, or it exceeds PostgreSQL's identifier limit.

DEFAULT_HOLDS_ROWS

A DEFAULT sibling holds rows belonging to the partition being created, so PostgreSQL refuses to attach it until they move.

UNCONVERGEABLE

Converging this branch failed outright; the rest of the table was still maintained.

Source code in pg_partsmith/subpartition_plan.py
class TopologyReason(StrEnum):
    """Why the planner left an existing subtree alone.

    Attributes:
        LEGACY_LEAF: The branch is a plain table created before the current
            subpartitioning policy. PostgreSQL cannot add partitions to it.
        STRATEGY_MISMATCH: The branch is subpartitioned by a different strategy
            than the config asks for.
        COLUMN_MISMATCH: The branch is subpartitioned by the right strategy but
            on a different column.
        MODULUS_PRESERVED: The branch has a complete hash set at a modulus the
            config no longer uses. It already tiles the keyspace, so it stays.
        MODULUS_REPAIRED: The branch has an *incomplete* hash set at a modulus
            the config no longer uses; the gaps were filled at the branch's own
            modulus, which is the only modulus that cannot overlap it.
        NON_UNIFORM_COMPLETE: Hash siblings disagree on modulus but still tile
            the keyspace — legal, and left untouched.
        NON_UNIFORM_INCOMPLETE: Hash siblings disagree on modulus and leave a
            gap. Rows hashing into it are rejected, and no repair is provably
            safe, so this needs a human.
        COVERAGE_UNKNOWN: The moduli are too coarse to enumerate, so coverage
            could not be verified.
        LIST_VALUES_CONFLICT: A configured LIST group claims a value another
            partition already owns. A value belongs to exactly one partition,
            so this needs a human.
        NAME_UNUSABLE: The partition the configuration asks for cannot be given
            a usable name -- the name is taken by a relation that does not match
            it, or it exceeds PostgreSQL's identifier limit.
        DEFAULT_HOLDS_ROWS: A DEFAULT sibling holds rows belonging to the
            partition being created, so PostgreSQL refuses to attach it until
            they move.
        UNCONVERGEABLE: Converging this branch failed outright; the rest of the
            table was still maintained.
    """

    LEGACY_LEAF = "legacy_leaf"
    STRATEGY_MISMATCH = "strategy_mismatch"
    COLUMN_MISMATCH = "column_mismatch"
    MODULUS_PRESERVED = "modulus_preserved"
    MODULUS_REPAIRED = "modulus_repaired"
    NON_UNIFORM_COMPLETE = "non_uniform_complete"
    NON_UNIFORM_INCOMPLETE = "non_uniform_incomplete"
    COVERAGE_UNKNOWN = "coverage_unknown"
    LIST_VALUES_CONFLICT = "list_values_conflict"
    NAME_UNUSABLE = "name_unusable"
    DEFAULT_HOLDS_ROWS = "default_holds_rows"
    UNCONVERGEABLE = "unconvergeable"

Desired-vs-actual planning for subpartitioned branches.

Pure and IO-free, so one implementation serves both the aio and sync mirrors and every convergence rule is unit-testable without a database.

The planner never mutates and never guesses. Given the subpartitioning a config asks for and the subtree that actually exists, it returns:

  • :attr:SubpartitionPlan.actions — the nodes that are safe to create, nested so a branch is only attached once its own children exist, and
  • :attr:SubpartitionPlan.findings — everything it deliberately refused to touch, each with the reason a human needs to act on it.

The refusals matter as much as the creations. A hash set cannot change modulus online, an existing partition may predate the current policy, and a partition that PostgreSQL is happy with must never be "fixed" into one it would reject.

plan_subpartitions(spec, node)

Plan the DDL that converges node's subtree towards spec.

Parameters:

Name Type Description Default
spec SubpartitionSpec

The subpartitioning the config asks for at node's level.

required
node PartitionNode

The branch as it currently exists, with its children populated.

required

Returns:

Type Description
SubpartitionPlan

A plan whose actions are safe to execute in order, plus findings for

SubpartitionPlan

everything left untouched.

Source code in pg_partsmith/subpartition_plan.py
def plan_subpartitions(spec: SubpartitionSpec, node: PartitionNode) -> SubpartitionPlan:
    """Plan the DDL that converges ``node``'s subtree towards ``spec``.

    Args:
        spec: The subpartitioning the config asks for at ``node``'s level.
        node: The branch as it currently exists, with its children populated.

    Returns:
        A plan whose actions are safe to execute in order, plus findings for
        everything left untouched.
    """
    actions: list[SubpartitionAction] = []
    findings: list[TopologyFinding] = []
    _plan_into(spec, node, actions, findings)
    return SubpartitionPlan(actions=tuple(actions), findings=tuple(findings))

plan_new_subtree(spec, branch_name, findings=None)

Plan the complete subtree of a branch that does not exist yet.

Used on the creation path, where there is nothing to reconcile against and every child the spec describes has to be built.

Parameters:

Name Type Description Default
spec SubpartitionSpec

The subpartitioning to materialise.

required
branch_name str

Schema-qualified name of the branch being created.

required
findings list[TopologyFinding] | None

Collector for children the planner refuses to name. Pass one: without it a refusal is invisible, and a branch planned with fewer children than the spec asks for is a branch that rejects rows.

None

Returns:

Type Description
tuple[SubpartitionAction, ...]

Actions creating every child described by spec, nested.

Source code in pg_partsmith/subpartition_plan.py
def plan_new_subtree(
    spec: SubpartitionSpec,
    branch_name: str,
    findings: list[TopologyFinding] | None = None,
) -> tuple[SubpartitionAction, ...]:
    """Plan the complete subtree of a branch that does not exist yet.

    Used on the creation path, where there is nothing to reconcile against and
    every child the spec describes has to be built.

    Args:
        spec: The subpartitioning to materialise.
        branch_name: Schema-qualified name of the branch being created.
        findings: Collector for children the planner refuses to name. Pass one:
            without it a refusal is invisible, and a branch planned with fewer
            children than the spec asks for is a branch that rejects rows.

    Returns:
        Actions creating every child described by ``spec``, nested.
    """
    if isinstance(spec, HashSubpartitionSpec):
        return _hash_actions(spec, branch_name, range(spec.modulus), findings=findings)
    return _list_actions(spec, branch_name, spec.groups, include_default=spec.include_default, findings=findings)

Boundary codecs

Bases: Protocol

Translates between instants and the literals a RANGE partition is bound by.

Implement this to partition by any time-sortable key. The only contract is that :meth:encode is monotonic in its argument and :meth:decode inverts it closely enough for retention comparisons — adjacent periods must produce contiguous [lower, upper) literals with no gap and no overlap, or rows fall through into the DEFAULT partition.

Source code in pg_partsmith/boundaries.py
@runtime_checkable
class RangeBoundaryCodec(Protocol):
    """Translates between instants and the literals a RANGE partition is bound by.

    Implement this to partition by any time-sortable key. The only contract is
    that :meth:`encode` is monotonic in its argument and :meth:`decode` inverts
    it closely enough for retention comparisons — adjacent periods must produce
    contiguous ``[lower, upper)`` literals with no gap and no overlap, or rows
    fall through into the DEFAULT partition.
    """

    def encode(self, start: datetime, end: datetime) -> tuple[str, str]:
        """Encode a half-open period into ``(from_value, to_value)`` literals.

        Args:
            start: Period start, inclusive; timezone-aware.
            end: Period end, exclusive; timezone-aware.

        Returns:
            The literals to use in ``FOR VALUES FROM (…) TO (…)``.
        """
        ...

    def decode(self, literal: str) -> datetime | None:
        """Decode a catalog boundary literal back to a UTC instant.

        Args:
            literal: A boundary as read from ``pg_get_expr(relpartbound, oid)``
                and unwrapped of quoting and casts.

        Returns:
            The instant the literal stands for, or None when it carries no
            instant (``MINVALUE``, ``MAXVALUE``, an unparseable value).
        """
        ...

decode(literal)

Decode a catalog boundary literal back to a UTC instant.

Parameters:

Name Type Description Default
literal str

A boundary as read from pg_get_expr(relpartbound, oid) and unwrapped of quoting and casts.

required

Returns:

Type Description
datetime | None

The instant the literal stands for, or None when it carries no

datetime | None

instant (MINVALUE, MAXVALUE, an unparseable value).

Source code in pg_partsmith/boundaries.py
def decode(self, literal: str) -> datetime | None:
    """Decode a catalog boundary literal back to a UTC instant.

    Args:
        literal: A boundary as read from ``pg_get_expr(relpartbound, oid)``
            and unwrapped of quoting and casts.

    Returns:
        The instant the literal stands for, or None when it carries no
        instant (``MINVALUE``, ``MAXVALUE``, an unparseable value).
    """
    ...

encode(start, end)

Encode a half-open period into (from_value, to_value) literals.

Parameters:

Name Type Description Default
start datetime

Period start, inclusive; timezone-aware.

required
end datetime

Period end, exclusive; timezone-aware.

required

Returns:

Type Description
tuple[str, str]

The literals to use in FOR VALUES FROM (…) TO (…).

Source code in pg_partsmith/boundaries.py
def encode(self, start: datetime, end: datetime) -> tuple[str, str]:
    """Encode a half-open period into ``(from_value, to_value)`` literals.

    Args:
        start: Period start, inclusive; timezone-aware.
        end: Period end, exclusive; timezone-aware.

    Returns:
        The literals to use in ``FOR VALUES FROM (…) TO (…)``.
    """
    ...

Encodes periods as the smallest UUIDv7 of each boundary instant.

UUIDv7 (RFC 9562) puts a 48-bit big-endian Unix-milliseconds timestamp in its leading bits, so UUIDv7 values sort chronologically and a table keyed by one can be RANGE-partitioned by time.

Both boundaries use the minimum UUID for their instant — every random bit zero. Using the minimum on both ends is what makes adjacent periods exactly contiguous: one period's upper bound is the next period's lower bound, so no identifier can fall between two partitions.

Timestamps are truncated to milliseconds, matching UUIDv7's own resolution. Period boundaries are whole hours or larger, so this never loses a boundary.

Source code in pg_partsmith/boundaries.py
class UUIDv7BoundaryCodec:
    """Encodes periods as the smallest UUIDv7 of each boundary instant.

    UUIDv7 (RFC 9562) puts a 48-bit big-endian Unix-milliseconds timestamp in
    its leading bits, so UUIDv7 values sort chronologically and a table keyed by
    one can be RANGE-partitioned by time.

    Both boundaries use the *minimum* UUID for their instant — every random bit
    zero. Using the minimum on both ends is what makes adjacent periods exactly
    contiguous: one period's upper bound is the next period's lower bound, so no
    identifier can fall between two partitions.

    Timestamps are truncated to milliseconds, matching UUIDv7's own resolution.
    Period boundaries are whole hours or larger, so this never loses a boundary.
    """

    _VERSION = 0x7
    _VARIANT = 0x2
    _TIMESTAMP_BITS = 48
    _MAX_TIMESTAMP_MS = (1 << _TIMESTAMP_BITS) - 1

    def encode(self, start: datetime, end: datetime) -> tuple[str, str]:
        """Return the minimum UUIDv7 for each boundary instant.

        Args:
            start: Period start, inclusive.
            end: Period end, exclusive.

        Returns:
            Canonical UUID strings for the two boundaries.
        """
        return str(self.min_uuid_for(start)), str(self.min_uuid_for(end))

    def decode(self, literal: str) -> datetime | None:
        """Return the instant encoded in a UUIDv7 literal, or None.

        Non-UUID literals (``MINVALUE``, ``MAXVALUE``, anything the catalog
        renders for a differently-typed key) and UUIDs of another version
        decode to None rather than raising, so a mixed-history table can still
        be introspected.
        """
        stripped = literal.strip()
        if not _UUID_PATTERN.match(stripped):
            return None

        try:
            value = UUID(stripped)
        except ValueError:
            return None

        if value.version != self._VERSION:
            return None

        timestamp_ms = int.from_bytes(value.bytes[:6], byteorder="big")
        return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)

    def min_uuid_for(self, instant: datetime) -> UUID:
        """Return the smallest valid UUIDv7 whose timestamp is ``instant``.

        Deterministic: every bit outside the timestamp, version, and variant
        fields is zero, so the same instant always yields the same boundary.

        Args:
            instant: A timezone-aware datetime; naive values are read as UTC.

        Returns:
            The minimum UUIDv7 for that millisecond.
        """
        if instant.tzinfo is None:
            instant = instant.replace(tzinfo=UTC)

        # UUIDv7's timestamp field is unsigned and 48 bits wide; clamping keeps
        # far-past and far-future periods encodable instead of raising.
        timestamp_ms = max(0, min(int(instant.timestamp() * 1000), self._MAX_TIMESTAMP_MS))

        # [48-bit timestamp][ver=7][12 bits rand_a][variant=0b10][62 bits rand_b]
        as_int = (timestamp_ms << 80) | (self._VERSION << 76) | (self._VARIANT << 62)
        return UUID(int=as_int)

decode(literal)

Return the instant encoded in a UUIDv7 literal, or None.

Non-UUID literals (MINVALUE, MAXVALUE, anything the catalog renders for a differently-typed key) and UUIDs of another version decode to None rather than raising, so a mixed-history table can still be introspected.

Source code in pg_partsmith/boundaries.py
def decode(self, literal: str) -> datetime | None:
    """Return the instant encoded in a UUIDv7 literal, or None.

    Non-UUID literals (``MINVALUE``, ``MAXVALUE``, anything the catalog
    renders for a differently-typed key) and UUIDs of another version
    decode to None rather than raising, so a mixed-history table can still
    be introspected.
    """
    stripped = literal.strip()
    if not _UUID_PATTERN.match(stripped):
        return None

    try:
        value = UUID(stripped)
    except ValueError:
        return None

    if value.version != self._VERSION:
        return None

    timestamp_ms = int.from_bytes(value.bytes[:6], byteorder="big")
    return datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)

encode(start, end)

Return the minimum UUIDv7 for each boundary instant.

Parameters:

Name Type Description Default
start datetime

Period start, inclusive.

required
end datetime

Period end, exclusive.

required

Returns:

Type Description
tuple[str, str]

Canonical UUID strings for the two boundaries.

Source code in pg_partsmith/boundaries.py
def encode(self, start: datetime, end: datetime) -> tuple[str, str]:
    """Return the minimum UUIDv7 for each boundary instant.

    Args:
        start: Period start, inclusive.
        end: Period end, exclusive.

    Returns:
        Canonical UUID strings for the two boundaries.
    """
    return str(self.min_uuid_for(start)), str(self.min_uuid_for(end))

min_uuid_for(instant)

Return the smallest valid UUIDv7 whose timestamp is instant.

Deterministic: every bit outside the timestamp, version, and variant fields is zero, so the same instant always yields the same boundary.

Parameters:

Name Type Description Default
instant datetime

A timezone-aware datetime; naive values are read as UTC.

required

Returns:

Type Description
UUID

The minimum UUIDv7 for that millisecond.

Source code in pg_partsmith/boundaries.py
def min_uuid_for(self, instant: datetime) -> UUID:
    """Return the smallest valid UUIDv7 whose timestamp is ``instant``.

    Deterministic: every bit outside the timestamp, version, and variant
    fields is zero, so the same instant always yields the same boundary.

    Args:
        instant: A timezone-aware datetime; naive values are read as UTC.

    Returns:
        The minimum UUIDv7 for that millisecond.
    """
    if instant.tzinfo is None:
        instant = instant.replace(tzinfo=UTC)

    # UUIDv7's timestamp field is unsigned and 48 bits wide; clamping keeps
    # far-past and far-future periods encodable instead of raising.
    timestamp_ms = max(0, min(int(instant.timestamp() * 1000), self._MAX_TIMESTAMP_MS))

    # [48-bit timestamp][ver=7][12 bits rand_a][variant=0b10][62 bits rand_b]
    as_int = (timestamp_ms << 80) | (self._VERSION << 76) | (self._VARIANT << 62)
    return UUID(int=as_int)

Bases: Protocol

Calculator that can read its own physical boundary literals back.

Retention selects partitions by comparing a partition's catalog upper bound against the cutoff instant. When the partition key is not a timestamp — a UUIDv7, a ULID, an epoch bigint — that comparison is only possible if the component that encoded the boundary can also decode it. Calculators without this capability keep the historical timestamp interpretation.

Source code in pg_partsmith/protocols.py
@runtime_checkable
class BoundaryDecoder(Protocol):
    """Calculator that can read its own physical boundary literals back.

    Retention selects partitions by comparing a partition's *catalog* upper
    bound against the cutoff instant. When the partition key is not a timestamp
    — a UUIDv7, a ULID, an epoch bigint — that comparison is only possible if
    the component that encoded the boundary can also decode it. Calculators
    without this capability keep the historical timestamp interpretation.
    """

    def decode_boundary(self, literal: str) -> datetime | None:
        """Return the instant a boundary literal stands for, or None."""
        ...

decode_boundary(literal)

Return the instant a boundary literal stands for, or None.

Source code in pg_partsmith/protocols.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a boundary literal stands for, or None."""
    ...

Exceptions

Domain exceptions for partition management.

PartitionError

Bases: Exception

Base exception for partition-related errors.

Source code in pg_partsmith/exceptions.py
class PartitionError(Exception):
    """Base exception for partition-related errors."""

PartitionAlreadyExistsError

Bases: PartitionError

Raised when attempting to create a partition that already exists.

Source code in pg_partsmith/exceptions.py
class PartitionAlreadyExistsError(PartitionError):
    """Raised when attempting to create a partition that already exists."""

    def __init__(self, partition_name: str) -> None:
        super().__init__(f"Partition already exists: {partition_name}")
        self.partition_name = partition_name

PartitionNotFoundError

Bases: PartitionError

Raised when a partition is not found.

Source code in pg_partsmith/exceptions.py
class PartitionNotFoundError(PartitionError):
    """Raised when a partition is not found."""

    def __init__(self, partition_name: str) -> None:
        super().__init__(f"Partition not found: {partition_name}")
        self.partition_name = partition_name

PartitionAttachedError

Bases: PartitionError

Raised when attempting to drop an attached partition.

Source code in pg_partsmith/exceptions.py
class PartitionAttachedError(PartitionError):
    """Raised when attempting to drop an attached partition."""

    def __init__(self, partition_name: str, table_name: str) -> None:
        super().__init__(f"Partition {partition_name} is still attached to table {table_name}")
        self.partition_name = partition_name
        self.table_name = table_name

PartitionDetachInProgressError

Bases: PartitionError

Raised when detach operation is in progress.

Source code in pg_partsmith/exceptions.py
class PartitionDetachInProgressError(PartitionError):
    """Raised when detach operation is in progress."""

    def __init__(self, partition_name: str) -> None:
        super().__init__(f"Detach operation in progress for partition: {partition_name}")
        self.partition_name = partition_name

InvalidPartitionConfigError

Bases: PartitionError

Raised when partition configuration is invalid.

Source code in pg_partsmith/exceptions.py
class InvalidPartitionConfigError(PartitionError):
    """Raised when partition configuration is invalid."""

    def __init__(self, message: str) -> None:
        super().__init__(f"Invalid partition configuration: {message}")

LockAcquisitionError

Bases: PartitionError

Raised when unable to acquire lock for partition operation.

Source code in pg_partsmith/exceptions.py
class LockAcquisitionError(PartitionError):
    """Raised when unable to acquire lock for partition operation."""

    def __init__(self, table_name: str, reason: str | None = None) -> None:
        msg = f"Failed to acquire lock for table {table_name}"
        if reason:
            msg = f"{msg}: {reason}"
        super().__init__(msg)
        self.table_name = table_name
        self.reason = reason

DropRetryExhaustedError

Bases: PartitionError

Raised when all drop_partition retry attempts are exhausted.

This means PostgreSQL returned a retryable error (deadlock, lock timeout, or query cancellation) on every attempt. Inspect cause for the last underlying error.

Source code in pg_partsmith/exceptions.py
class DropRetryExhaustedError(PartitionError):
    """Raised when all drop_partition retry attempts are exhausted.

    This means PostgreSQL returned a retryable error (deadlock, lock timeout,
    or query cancellation) on every attempt.  Inspect ``cause`` for the last
    underlying error.
    """

    def __init__(self, partition_name: str, attempts: int, cause: BaseException | None = None) -> None:
        msg = (
            f"Failed to drop partition {partition_name!r} after {attempts} attempt(s) "
            "due to persistent lock contention or deadlock"
        )
        if cause:
            msg = f"{msg}: {cause}"
        super().__init__(msg)
        self.partition_name = partition_name
        self.attempts = attempts
        self.cause = cause

UnmanagedPartitionDropError

Bases: PartitionError

Raised when attempting to drop a table not managed by this library.

Source code in pg_partsmith/exceptions.py
class UnmanagedPartitionDropError(PartitionError):
    """Raised when attempting to drop a table not managed by this library."""

    def __init__(self, partition_name: str) -> None:
        msg = f"Refusing to drop unmanaged table {partition_name!r}; set drop_allow_unmanaged=True to override."
        super().__init__(msg)
        self.partition_name = partition_name

PartitionTopologyError

Bases: PartitionError

Raised when an existing partition tree diverges from the configured one.

Carries the planner's finding verbatim so callers can branch on :attr:reason instead of matching on message text. Reconciliation records these on MaintenanceResult.issues rather than raising, because one historical branch with an unexpected shape must not abort maintenance for every other partition.

Source code in pg_partsmith/exceptions.py
class PartitionTopologyError(PartitionError):
    """Raised when an existing partition tree diverges from the configured one.

    Carries the planner's finding verbatim so callers can branch on
    :attr:`reason` instead of matching on message text. Reconciliation records
    these on ``MaintenanceResult.issues`` rather than raising, because one
    historical branch with an unexpected shape must not abort maintenance for
    every other partition.
    """

    def __init__(self, partition_name: str, reason: str, detail: str) -> None:
        super().__init__(detail)
        self.partition_name = partition_name
        self.reason = reason
        self.detail = detail

UnsupportedCapabilityError

Bases: PartitionError

Raised when a config is wired to components that cannot serve it.

Custom repositories and metadata providers written against the flat protocols keep working for flat configs; they are only refused when a config actually asks for something they do not implement. The capability is named rather than assumed, because more than one of them is optional.

Source code in pg_partsmith/exceptions.py
class UnsupportedCapabilityError(PartitionError):
    """Raised when a config is wired to components that cannot serve it.

    Custom repositories and metadata providers written against the flat
    protocols keep working for flat configs; they are only refused when a
    config actually asks for something they do not implement. The capability is
    named rather than assumed, because more than one of them is optional.
    """

    def __init__(self, component: str, capability: str, expected: str) -> None:
        msg = (
            f"{component} does not support {capability}: it must implement {expected}. "
            "Use the bundled PostgreSQL implementation, or extend yours with the missing methods."
        )
        super().__init__(msg)
        self.component = component
        self.capability = capability
        self.expected = expected

Period strategies

Bases: ABC

Base class for all period calculators.

Implements common logic for period calculations and defines the interface for granularity-specific strategies. Subclass and override any method to customise behaviour.

Periods are computed in the calculator's timezone (UTC by default): the current period is derived from "now" in that zone, and naive boundary literals mean period starts in that zone. Keep the repository's ddl_timezone aligned with it — PartitionLifecycleService refuses a mismatched pair.

Subclasses must define _NAME_PATTERN (a compiled regex) and implement _period_from_match to construct a Period from regex groups. Group 1 is conventionally the table name; subsequent groups encode the period.

Passing boundary_codec decouples the semantic period from the physical partition key: periods, names, create-ahead and retention keep working in calendar terms while the FOR VALUES FROM … TO … literals are whatever the key actually stores (a UUIDv7, a sortable id). Without one, boundaries are rendered as the calendar literals they have always been.

Source code in pg_partsmith/strategies/base.py
class BasePeriodCalculator(ABC):
    """Base class for all period calculators.

    Implements common logic for period calculations and defines
    the interface for granularity-specific strategies.
    Subclass and override any method to customise behaviour.

    Periods are computed in the calculator's timezone (UTC by default): the
    current period is derived from "now" in that zone, and naive boundary
    literals mean period starts in that zone. Keep the repository's
    ``ddl_timezone`` aligned with it — ``PartitionLifecycleService`` refuses a
    mismatched pair.

    Subclasses must define ``_NAME_PATTERN`` (a compiled regex) and implement
    ``_period_from_match`` to construct a ``Period`` from regex groups.
    Group 1 is conventionally the table name; subsequent groups encode the period.

    Passing ``boundary_codec`` decouples the semantic period from the physical
    partition key: periods, names, create-ahead and retention keep working in
    calendar terms while the ``FOR VALUES FROM … TO …`` literals are whatever
    the key actually stores (a UUIDv7, a sortable id). Without one, boundaries
    are rendered as the calendar literals they have always been.
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]]

    def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
        """Initialize calculator.

        Args:
            tz: Timezone the calculator works in. Only ``datetime.UTC`` and
                :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
                must have an IANA name usable in ``SET LOCAL TIME ZONE``.
            boundary_codec: Optional encoder for the physical partition key.
                When set, period boundaries are encoded through it instead of
                being rendered as calendar literals.

        Raises:
            ValueError: If ``tz`` carries no IANA name.
        """
        self._tz = tz
        self._tz_name = timezone_name(tz)
        self._boundary_codec = boundary_codec

    @property
    def tz(self) -> tzinfo:
        """Timezone the calculator works in."""
        return self._tz

    @property
    def timezone_name(self) -> str:
        """IANA name of :attr:`tz`, usable in ``SET LOCAL TIME ZONE``."""
        return self._tz_name

    @property
    def boundary_codec(self) -> RangeBoundaryCodec | None:
        """Codec used to render and read physical boundary literals, if any."""
        return self._boundary_codec

    def period_start(self, period: Period) -> datetime:
        """Return the instant a period begins, in the calculator's timezone.

        ``Period.to_datetime`` pins UTC; a calculator working in a business
        timezone means the same calendar period starts at a different instant,
        which is what a boundary codec has to encode.
        """
        return period.to_datetime().replace(tzinfo=self._tz)

    def decode_boundary(self, literal: str) -> datetime | None:
        """Return the instant a catalog boundary literal stands for, or None.

        Retention compares partitions by their upper bound, so whatever encoded
        a boundary has to be able to read it back. Falls back to interpreting
        the literal as a timestamp when no codec is configured.
        """
        if self._boundary_codec is not None:
            return self._boundary_codec.decode(literal)
        return parse_boundary_literal(literal, self._tz)

    def _encoded_boundaries(
        self,
        period: Period,
        render: Callable[[datetime], str],
    ) -> tuple[str, str]:
        """Return this period's half-open boundaries as SQL literals.

        Args:
            period: The period to bound.
            render: Formats a boundary instant the way this granularity has
                always rendered it; used only when no codec is configured.
        """
        start, end = self.period_start(period), self.period_start(period + 1)
        if self._boundary_codec is not None:
            return self._boundary_codec.encode(start, end)
        return render(start), render(end)

    def _now(self) -> datetime:
        """Current time in the calculator's timezone."""
        return datetime.now(self._tz)

    @abstractmethod
    def current_period(self) -> Period:
        """Return the current period based on the current time in :attr:`tz`."""
        ...

    @abstractmethod
    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name for a given table and period."""
        ...

    @abstractmethod
    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Return ``(from_value, to_value)`` boundaries for a period as ISO strings."""
        ...

    @abstractmethod
    def _period_from_match(self, match: re.Match[str]) -> Period:
        """Build a ``Period`` from a successful ``_NAME_PATTERN`` match.

        Subclasses may raise ``ValueError`` for invalid calendar values; the
        public ``parse_partition_name`` translates that into ``None``.
        """
        ...

    def parse_partition_name(self, partition_name: str) -> Period | None:
        """Parse period from a partition name.

        Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
        an invalid calendar value (e.g. month 13).
        """
        match = self._NAME_PATTERN.match(partition_name)
        if not match:
            return None
        try:
            return self._period_from_match(match)
        except ValueError:
            return None

    def next_periods(self, count: int) -> list[Period]:
        """Generate N periods starting from the current period (inclusive)."""
        if count <= 0:
            msg = "Count must be positive"
            raise ValueError(msg)

        current = self.current_period()
        return [self.period_after(current, i) for i in range(count)]

    def period_after(self, reference: Period, offset: int) -> Period:
        """Return the period ``offset`` steps after ``reference``."""
        if offset < 0:
            msg = "Offset must be non-negative"
            raise ValueError(msg)

        return reference + offset

    def period_before(self, reference: Period, offset: int) -> Period:
        """Return the period ``offset`` steps before ``reference``."""
        if offset < 0:
            msg = "Offset must be non-negative"
            raise ValueError(msg)

        return reference - offset

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period() abstractmethod

Return the current period based on the current time in :attr:tz.

Source code in pg_partsmith/strategies/base.py
@abstractmethod
def current_period(self) -> Period:
    """Return the current period based on the current time in :attr:`tz`."""
    ...

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period) abstractmethod

Format partition name for a given table and period.

Source code in pg_partsmith/strategies/base.py
@abstractmethod
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name for a given table and period."""
    ...

get_boundaries(period) abstractmethod

Return (from_value, to_value) boundaries for a period as ISO strings.

Source code in pg_partsmith/strategies/base.py
@abstractmethod
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Return ``(from_value, to_value)`` boundaries for a period as ISO strings."""
    ...

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for hourly partitions.

Generates partitions with hour granularity. UTC only: in a zone with DST a local hour can repeat or vanish, making {table}__YYYY_MM_DD_HH names ambiguous, so non-UTC timezones are rejected. Partition naming: {table}__{YYYY}_{MM}_{DD}_{HH}

Source code in pg_partsmith/strategies/hour.py
class HourPeriodCalculator(BasePeriodCalculator):
    """Calculator for hourly partitions.

    Generates partitions with hour granularity. UTC only: in a zone with DST
    a local hour can repeat or vanish, making ``{table}__YYYY_MM_DD_HH`` names
    ambiguous, so non-UTC timezones are rejected.
    Partition naming: ``{table}__{YYYY}_{MM}_{DD}_{HH}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})_(\d{2})_(\d{2})_(\d{2})$")

    def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
        """Initialize calculator; only ``tz=datetime.UTC`` is accepted.

        Raises:
            ValueError: If ``tz`` is not UTC — local-time hour partition names
                are ambiguous under DST transitions.
        """
        super().__init__(tz=tz, boundary_codec=boundary_codec)
        if self.timezone_name != "UTC":
            msg = (
                f"HourPeriodCalculator supports only UTC, got {self.timezone_name!r}: "
                "local-time hour partition names are ambiguous under DST transitions"
            )
            raise ValueError(msg)

    def current_period(self) -> Period:
        """Get current hour period."""
        now = self._now()
        return Period(year=now.year, month=now.month, day=now.day, hour=now.hour)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY_MM_DD_HH``."""
        if period.month is None or period.day is None or period.hour is None:
            msg = "Month, day and hour are required for HourPeriodCalculator"
            raise ValueError(msg)
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(
            year=int(match.group(2)),
            month=int(match.group(3)),
            day=int(match.group(4)),
            hour=int(match.group(5)),
        )

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get hour boundaries as ``(start, end)`` UTC timestamps with hour precision."""
        if period.month is None or period.day is None or period.hour is None:
            msg = "Month, day and hour are required for HourPeriodCalculator"
            raise ValueError(msg)

        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d %H:00:00+00"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator; only tz=datetime.UTC is accepted.

Raises:

Type Description
ValueError

If tz is not UTC — local-time hour partition names are ambiguous under DST transitions.

Source code in pg_partsmith/strategies/hour.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator; only ``tz=datetime.UTC`` is accepted.

    Raises:
        ValueError: If ``tz`` is not UTC — local-time hour partition names
            are ambiguous under DST transitions.
    """
    super().__init__(tz=tz, boundary_codec=boundary_codec)
    if self.timezone_name != "UTC":
        msg = (
            f"HourPeriodCalculator supports only UTC, got {self.timezone_name!r}: "
            "local-time hour partition names are ambiguous under DST transitions"
        )
        raise ValueError(msg)

current_period()

Get current hour period.

Source code in pg_partsmith/strategies/hour.py
def current_period(self) -> Period:
    """Get current hour period."""
    now = self._now()
    return Period(year=now.year, month=now.month, day=now.day, hour=now.hour)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY_MM_DD_HH.

Source code in pg_partsmith/strategies/hour.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY_MM_DD_HH``."""
    if period.month is None or period.day is None or period.hour is None:
        msg = "Month, day and hour are required for HourPeriodCalculator"
        raise ValueError(msg)
    return f"{table_name}__{period}"

get_boundaries(period)

Get hour boundaries as (start, end) UTC timestamps with hour precision.

Source code in pg_partsmith/strategies/hour.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get hour boundaries as ``(start, end)`` UTC timestamps with hour precision."""
    if period.month is None or period.day is None or period.hour is None:
        msg = "Month, day and hour are required for HourPeriodCalculator"
        raise ValueError(msg)

    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d %H:00:00+00"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for daily partitions.

Generates partitions with day granularity. Partition naming: {table}__{YYYY}_{MM}_{DD}

Source code in pg_partsmith/strategies/day.py
class DayPeriodCalculator(BasePeriodCalculator):
    """Calculator for daily partitions.

    Generates partitions with day granularity.
    Partition naming: ``{table}__{YYYY}_{MM}_{DD}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})_(\d{2})_(\d{2})$")

    def current_period(self) -> Period:
        """Get current day period."""
        now = self._now()
        return Period(year=now.year, month=now.month, day=now.day)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY_MM_DD``."""
        if period.month is None or period.day is None:
            msg = "Month and day are required for DayPeriodCalculator"
            raise ValueError(msg)
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(
            year=int(match.group(2)),
            month=int(match.group(3)),
            day=int(match.group(4)),
        )

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get day boundaries as ``(start_date, end_date)`` in ISO format."""
        if period.month is None or period.day is None:
            msg = "Month and day are required for DayPeriodCalculator"
            raise ValueError(msg)

        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period()

Get current day period.

Source code in pg_partsmith/strategies/day.py
def current_period(self) -> Period:
    """Get current day period."""
    now = self._now()
    return Period(year=now.year, month=now.month, day=now.day)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY_MM_DD.

Source code in pg_partsmith/strategies/day.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY_MM_DD``."""
    if period.month is None or period.day is None:
        msg = "Month and day are required for DayPeriodCalculator"
        raise ValueError(msg)
    return f"{table_name}__{period}"

get_boundaries(period)

Get day boundaries as (start_date, end_date) in ISO format.

Source code in pg_partsmith/strategies/day.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get day boundaries as ``(start_date, end_date)`` in ISO format."""
    if period.month is None or period.day is None:
        msg = "Month and day are required for DayPeriodCalculator"
        raise ValueError(msg)

    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for weekly partitions.

Generates partitions with ISO-week granularity. Partition naming: {table}__{YYYY}_w{WW}

Source code in pg_partsmith/strategies/week.py
class WeekPeriodCalculator(BasePeriodCalculator):
    """Calculator for weekly partitions.

    Generates partitions with ISO-week granularity.
    Partition naming: ``{table}__{YYYY}_w{WW}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})_w(\d{2})$")

    def current_period(self) -> Period:
        """Get current ISO-week period."""
        now = self._now()
        iso_year, iso_week, _ = now.isocalendar()
        return Period(year=iso_year, week=iso_week)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY_wWW``."""
        if period.week is None:
            msg = "Week is required for WeekPeriodCalculator"
            raise ValueError(msg)
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(year=int(match.group(2)), week=int(match.group(3)))

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get ISO-week boundaries (Monday to Monday) as ISO date strings."""
        if period.week is None:
            msg = "Week is required for WeekPeriodCalculator"
            raise ValueError(msg)

        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period()

Get current ISO-week period.

Source code in pg_partsmith/strategies/week.py
def current_period(self) -> Period:
    """Get current ISO-week period."""
    now = self._now()
    iso_year, iso_week, _ = now.isocalendar()
    return Period(year=iso_year, week=iso_week)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY_wWW.

Source code in pg_partsmith/strategies/week.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY_wWW``."""
    if period.week is None:
        msg = "Week is required for WeekPeriodCalculator"
        raise ValueError(msg)
    return f"{table_name}__{period}"

get_boundaries(period)

Get ISO-week boundaries (Monday to Monday) as ISO date strings.

Source code in pg_partsmith/strategies/week.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get ISO-week boundaries (Monday to Monday) as ISO date strings."""
    if period.week is None:
        msg = "Week is required for WeekPeriodCalculator"
        raise ValueError(msg)

    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for monthly partitions.

Generates partitions with month granularity. Partition naming: {table}__{YYYY}_{MM}

Source code in pg_partsmith/strategies/month.py
class MonthPeriodCalculator(BasePeriodCalculator):
    """Calculator for monthly partitions.

    Generates partitions with month granularity.
    Partition naming: ``{table}__{YYYY}_{MM}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})_(\d{2})$")

    def current_period(self) -> Period:
        """Get current month period."""
        now = self._now()
        return Period(year=now.year, month=now.month)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY_MM``."""
        if period.month is None:
            msg = "Month is required for MonthPeriodCalculator"
            raise ValueError(msg)
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(year=int(match.group(2)), month=int(match.group(3)))

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get month boundaries as ``(start_date, end_date)`` in ISO format."""
        if period.month is None:
            msg = "Month is required for MonthPeriodCalculator"
            raise ValueError(msg)

        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period()

Get current month period.

Source code in pg_partsmith/strategies/month.py
def current_period(self) -> Period:
    """Get current month period."""
    now = self._now()
    return Period(year=now.year, month=now.month)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY_MM.

Source code in pg_partsmith/strategies/month.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY_MM``."""
    if period.month is None:
        msg = "Month is required for MonthPeriodCalculator"
        raise ValueError(msg)
    return f"{table_name}__{period}"

get_boundaries(period)

Get month boundaries as (start_date, end_date) in ISO format.

Source code in pg_partsmith/strategies/month.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get month boundaries as ``(start_date, end_date)`` in ISO format."""
    if period.month is None:
        msg = "Month is required for MonthPeriodCalculator"
        raise ValueError(msg)

    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for quarterly partitions.

Generates partitions with quarter granularity. Partition naming: {table}__{YYYY}_q{Q}

Source code in pg_partsmith/strategies/quarter.py
class QuarterPeriodCalculator(BasePeriodCalculator):
    """Calculator for quarterly partitions.

    Generates partitions with quarter granularity.
    Partition naming: ``{table}__{YYYY}_q{Q}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})_q([1-4])$")

    def current_period(self) -> Period:
        """Get current quarter period."""
        now = self._now()
        return Period(year=now.year, quarter=(now.month - 1) // 3 + 1)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY_qQ``."""
        if period.quarter is None:
            msg = "Quarter is required for QuarterPeriodCalculator"
            raise ValueError(msg)
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(year=int(match.group(2)), quarter=int(match.group(3)))

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get quarter boundaries as ``(start_date, end_date)`` in ISO format."""
        if period.quarter is None:
            msg = "Quarter is required for QuarterPeriodCalculator"
            raise ValueError(msg)

        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period()

Get current quarter period.

Source code in pg_partsmith/strategies/quarter.py
def current_period(self) -> Period:
    """Get current quarter period."""
    now = self._now()
    return Period(year=now.year, quarter=(now.month - 1) // 3 + 1)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY_qQ.

Source code in pg_partsmith/strategies/quarter.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY_qQ``."""
    if period.quarter is None:
        msg = "Quarter is required for QuarterPeriodCalculator"
        raise ValueError(msg)
    return f"{table_name}__{period}"

get_boundaries(period)

Get quarter boundaries as (start_date, end_date) in ISO format.

Source code in pg_partsmith/strategies/quarter.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get quarter boundaries as ``(start_date, end_date)`` in ISO format."""
    if period.quarter is None:
        msg = "Quarter is required for QuarterPeriodCalculator"
        raise ValueError(msg)

    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Bases: BasePeriodCalculator

Calculator for yearly partitions.

Generates partitions with year granularity. Partition naming: {table}__{YYYY}

Source code in pg_partsmith/strategies/year.py
class YearPeriodCalculator(BasePeriodCalculator):
    """Calculator for yearly partitions.

    Generates partitions with year granularity.
    Partition naming: ``{table}__{YYYY}``
    """

    _NAME_PATTERN: ClassVar[re.Pattern[str]] = re.compile(r"^(.+)__(\d{4})$")

    def current_period(self) -> Period:
        """Get current year period."""
        now = self._now()
        return Period(year=now.year)

    def format_partition_name(self, table_name: str, period: Period) -> str:
        """Format partition name: ``table__YYYY``."""
        return f"{table_name}__{period}"

    def _period_from_match(self, match: re.Match[str]) -> Period:
        return Period(year=int(match.group(2)))

    def get_boundaries(self, period: Period) -> tuple[str, str]:
        """Get year boundaries as ``(start_date, end_date)`` in ISO format."""
        return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

boundary_codec property

Codec used to render and read physical boundary literals, if any.

timezone_name property

IANA name of :attr:tz, usable in SET LOCAL TIME ZONE.

tz property

Timezone the calculator works in.

__init__(tz=UTC, *, boundary_codec=None)

Initialize calculator.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in. Only datetime.UTC and :class:zoneinfo.ZoneInfo instances are accepted — the zone must have an IANA name usable in SET LOCAL TIME ZONE.

UTC
boundary_codec RangeBoundaryCodec | None

Optional encoder for the physical partition key. When set, period boundaries are encoded through it instead of being rendered as calendar literals.

None

Raises:

Type Description
ValueError

If tz carries no IANA name.

Source code in pg_partsmith/strategies/base.py
def __init__(self, tz: tzinfo = UTC, *, boundary_codec: RangeBoundaryCodec | None = None) -> None:
    """Initialize calculator.

    Args:
        tz: Timezone the calculator works in. Only ``datetime.UTC`` and
            :class:`zoneinfo.ZoneInfo` instances are accepted — the zone
            must have an IANA name usable in ``SET LOCAL TIME ZONE``.
        boundary_codec: Optional encoder for the physical partition key.
            When set, period boundaries are encoded through it instead of
            being rendered as calendar literals.

    Raises:
        ValueError: If ``tz`` carries no IANA name.
    """
    self._tz = tz
    self._tz_name = timezone_name(tz)
    self._boundary_codec = boundary_codec

current_period()

Get current year period.

Source code in pg_partsmith/strategies/year.py
def current_period(self) -> Period:
    """Get current year period."""
    now = self._now()
    return Period(year=now.year)

decode_boundary(literal)

Return the instant a catalog boundary literal stands for, or None.

Retention compares partitions by their upper bound, so whatever encoded a boundary has to be able to read it back. Falls back to interpreting the literal as a timestamp when no codec is configured.

Source code in pg_partsmith/strategies/base.py
def decode_boundary(self, literal: str) -> datetime | None:
    """Return the instant a catalog boundary literal stands for, or None.

    Retention compares partitions by their upper bound, so whatever encoded
    a boundary has to be able to read it back. Falls back to interpreting
    the literal as a timestamp when no codec is configured.
    """
    if self._boundary_codec is not None:
        return self._boundary_codec.decode(literal)
    return parse_boundary_literal(literal, self._tz)

format_partition_name(table_name, period)

Format partition name: table__YYYY.

Source code in pg_partsmith/strategies/year.py
def format_partition_name(self, table_name: str, period: Period) -> str:
    """Format partition name: ``table__YYYY``."""
    return f"{table_name}__{period}"

get_boundaries(period)

Get year boundaries as (start_date, end_date) in ISO format.

Source code in pg_partsmith/strategies/year.py
def get_boundaries(self, period: Period) -> tuple[str, str]:
    """Get year boundaries as ``(start_date, end_date)`` in ISO format."""
    return self._encoded_boundaries(period, lambda d: d.strftime("%Y-%m-%d"))

next_periods(count)

Generate N periods starting from the current period (inclusive).

Source code in pg_partsmith/strategies/base.py
def next_periods(self, count: int) -> list[Period]:
    """Generate N periods starting from the current period (inclusive)."""
    if count <= 0:
        msg = "Count must be positive"
        raise ValueError(msg)

    current = self.current_period()
    return [self.period_after(current, i) for i in range(count)]

parse_partition_name(partition_name)

Parse period from a partition name.

Returns None if the name does not match _NAME_PATTERN or encodes an invalid calendar value (e.g. month 13).

Source code in pg_partsmith/strategies/base.py
def parse_partition_name(self, partition_name: str) -> Period | None:
    """Parse period from a partition name.

    Returns ``None`` if the name does not match ``_NAME_PATTERN`` or encodes
    an invalid calendar value (e.g. month 13).
    """
    match = self._NAME_PATTERN.match(partition_name)
    if not match:
        return None
    try:
        return self._period_from_match(match)
    except ValueError:
        return None

period_after(reference, offset)

Return the period offset steps after reference.

Source code in pg_partsmith/strategies/base.py
def period_after(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps after ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference + offset

period_before(reference, offset)

Return the period offset steps before reference.

Source code in pg_partsmith/strategies/base.py
def period_before(self, reference: Period, offset: int) -> Period:
    """Return the period ``offset`` steps before ``reference``."""
    if offset < 0:
        msg = "Offset must be non-negative"
        raise ValueError(msg)

    return reference - offset

period_start(period)

Return the instant a period begins, in the calculator's timezone.

Period.to_datetime pins UTC; a calculator working in a business timezone means the same calendar period starts at a different instant, which is what a boundary codec has to encode.

Source code in pg_partsmith/strategies/base.py
def period_start(self, period: Period) -> datetime:
    """Return the instant a period begins, in the calculator's timezone.

    ``Period.to_datetime`` pins UTC; a calculator working in a business
    timezone means the same calendar period starts at a different instant,
    which is what a boundary codec has to encode.
    """
    return period.to_datetime().replace(tzinfo=self._tz)

Return the period calculator for the given granularity.

Parameters:

Name Type Description Default
granularity PartitionGranularity

The partition time granularity.

required
tz tzinfo

Timezone the calculator works in (datetime.UTC or a keyed :class:zoneinfo.ZoneInfo). HOUR accepts only UTC.

UTC

Returns:

Type Description
BasePeriodCalculator

A fresh calculator instance for the requested granularity.

Raises:

Type Description
ValueError

If granularity has no registered calculator, or tz is unsupported for it.

Source code in pg_partsmith/strategies/selector.py
def get_period_calculator(granularity: PartitionGranularity, tz: tzinfo = UTC) -> BasePeriodCalculator:
    """Return the period calculator for the given granularity.

    Args:
        granularity: The partition time granularity.
        tz: Timezone the calculator works in (``datetime.UTC`` or a keyed
            :class:`zoneinfo.ZoneInfo`). HOUR accepts only UTC.

    Returns:
        A fresh calculator instance for the requested granularity.

    Raises:
        ValueError: If *granularity* has no registered calculator, or ``tz``
            is unsupported for it.
    """
    try:
        calculator_cls = _CALCULATORS[granularity]
    except KeyError:
        raise ValueError(f"No calculator for granularity: {granularity!r}") from None
    return calculator_cls(tz=tz)

pg_partsmith.aio

Async implementations: service, maintainer, repositories, lock managers, and hooks.

Service and maintainer

Service for managing the full partition lifecycle.

Orchestrates partition creation, detachment, and deletion by delegating to specialized component services.

Source code in pg_partsmith/aio/service.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
class PartitionLifecycleService:
    """Service for managing the full partition lifecycle.

    Orchestrates partition creation, detachment, and deletion by delegating
    to specialized component services.
    """

    def __init__(
        self,
        repo: PartitionRepository,
        metadata: PartitionMetadataProvider,
        locks: LockManager,
        period_calculator: PeriodCalculator[Period] | None = None,
        hooks: list[PartitionLifecycleHooks] | None = None,
    ) -> None:
        """Initialize the partition lifecycle service.

        Args:
            repo: DDL operations on partitions (create / attach / detach / drop).
            metadata: Read-only access to PostgreSQL catalog data.
            locks: Distributed lock manager preventing concurrent maintenance runs.
            period_calculator: Strategy for determining partition names and
                boundaries. Required for a TIME_BASED table and meaningless for
                a static HASH_BASED / VALUE_BASED one, which has no periods.
            hooks: Optional list of lifecycle hooks called around each step.
        """
        validate_timezone_alignment(repo, period_calculator)

        self._locks = locks
        self._metadata = metadata

        # Component services
        self._validation_service = PartitionValidationService(metadata)
        # Subpartitioning is optional, so the collaborators are typed loosely
        # here and checked against the nested protocols only when a config
        # actually asks for it — a flat setup with a custom repository or
        # metadata provider keeps working unchanged.
        self._subpartition_service = PartitionSubpartitionService(
            cast("SubpartitionRepository", repo),
            cast("NestedPartitionMetadata", metadata),
        )
        # The period-driven services exist only when there are periods.
        self._creation_service = (
            PartitionCreationService(repo, metadata, period_calculator, hooks, subpartitions=self._subpartition_service)
            if period_calculator is not None
            else None
        )
        self._pruning_service = (
            PartitionPruningService(metadata, period_calculator) if period_calculator is not None else None
        )
        self._detachment_service = PartitionDetachmentService(repo, hooks)
        self._deletion_service = PartitionDeletionService(repo, hooks)

    async def create_future_partitions(self, config: TablePartitionConfig) -> list[PartitionInfo]:
        """Create partitions for future periods.

        Ensures partitions exist for the next ``config.create_ahead_count`` periods
        starting from the current period (inclusive). Idempotent: existing partitions
        are skipped.

        Args:
            config: Table partitioning configuration.

        Returns:
            List of newly created partitions (empty if all already existed).

        Raises:
            PartitionAlreadyExistsError: If a partition exists with conflicting boundaries.
            InvalidPartitionConfigError: If ``config`` is incompatible with the parent table.
        """
        return await self._require_periods().create_future_partitions(config)

    async def ensure_partition(self, config: TablePartitionConfig, period: Period) -> PartitionInfo | None:
        """Create and attach the partition for one specific period (idempotent).

        Unlike :meth:`create_future_partitions`, targets exactly ``period`` —
        useful for writers that must guarantee a partition exists before an
        insert (e.g. an hourly outbox buffer). Runs the same DEFAULT
        reconciliation and attach-race handling as the create-ahead path.

        Args:
            config: Table partitioning configuration.
            period: The period the partition must cover.

        Returns:
            The created partition, or None when it already existed (an existing
            detached partition is re-attached when ``auto_attach_after_create``).

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return await self._require_periods().ensure_partition(config, period)

    async def ensure_partitions(
        self,
        config: TablePartitionConfig,
        periods: Iterable[Period],
    ) -> list[PartitionInfo]:
        """Create and attach partitions for an explicit set of periods (idempotent).

        The backfill counterpart of :meth:`create_future_partitions`: the caller
        chooses the periods, so data that already sits in the table can be given
        partitions without waiting for create-ahead to reach it.

        Args:
            config: Table partitioning configuration.
            periods: Periods that must have a partition. Duplicates are ignored;
                order is preserved.

        Returns:
            The partitions created by this call; periods that already had one
            are absent from the list.

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return await self._require_periods().ensure_partitions(config, periods)

    async def get_partitions_for_pruning(self, config: TablePartitionConfig) -> list[PartitionInfo]:
        """Return partitions older than ``config.retention_count`` periods.

        Args:
            config: Table partitioning configuration.

        Returns:
            Partitions that are eligible for detach + drop, sorted oldest first.

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return await self._require_pruning().get_partitions_for_pruning(config)

    async def detach_old_partitions(
        self,
        table_name: str,
        partitions: list[PartitionInfo],
    ) -> list[str]:
        """Detach attached partitions from their parent table.

        Args:
            table_name: Qualified parent table name.
            partitions: Attached partitions to detach.

        Returns:
            Names of successfully detached partitions.

        Raises:
            PartitionDetachInProgressError: If a concurrent detach is in progress.
        """
        return await self._detachment_service.detach_old_partitions(table_name, partitions)

    async def drop_detached_partitions(
        self,
        table_name: str,
        partition_names: list[str],
    ) -> int:
        """Drop previously detached, marker-tagged partitions.

        Attached partitions are skipped with a warning (they raise
        ``PartitionAttachedError`` internally). Unmanaged tables are refused
        unless the underlying repository is configured otherwise.

        Args:
            table_name: Qualified parent table name (used for hook context).
            partition_names: Names of partitions to drop.

        Returns:
            Number of partitions actually dropped.
        """
        return await self._deletion_service.drop_detached_partitions(table_name, partition_names)

    async def reconcile_subpartitions(
        self,
        config: TablePartitionConfig,
        *,
        exclude: Collection[str] = (),
    ) -> SubpartitionReconcileResult:
        """Converge the subtree of every attached partition towards the config.

        Idempotent and safe to call on its own: it creates only the buckets a
        branch is genuinely missing, and reports rather than "repairs" any
        branch whose shape it cannot converge without risk.

        It takes **no distributed lock of its own** -- unlike
        :meth:`maintain_lifecycle`, which runs its whole sequence under one.
        Two workers calling this concurrently is safe: a lost race on a bucket
        is recognised by its bounds and reported, not retried into a failure.
        But calling it while a maintainer is mid-run means both are converging
        the same tree, and the wasted work is yours to weigh. Wrap it in your
        own lock if you would rather they queued.

        Args:
            config: Table partitioning configuration. Without a subpartition
                spec this is a no-op returning an empty result.
            exclude: Schema-qualified partition names to skip.

        Returns:
            The subpartitions created and the divergences left alone.

        Raises:
            UnsupportedCapabilityError: If the repository or metadata provider
                cannot serve a nested configuration.
        """
        return await self._subpartition_service.reconcile(config, exclude=exclude)

    async def _maintain_static_root(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool,
        continue_on_error: bool,
    ) -> MaintenanceResult:
        """Converge a HASH_BASED / VALUE_BASED table's own partition set.

        ``created_count`` reports every partition this call created, at any
        level: a static root has no lifecycle stages to attribute them to.
        Detach and drop are absent rather than skipped -- there is no retention
        window without periods -- but ``skip_create`` and ``continue_on_error``
        mean here exactly what they mean everywhere else.
        """
        if skip_create:
            return MaintenanceResult()

        try:
            reconciled = await self._subpartition_service.reconcile(config)
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except Exception as exc:
            if not continue_on_error:
                raise
            error = describe_exception(exc)
            logger.warning(
                "Maintenance step failed; continuing with the remaining steps",
                extra={
                    "table_name": qualify(config.db_schema, config.table_name),
                    "step": MaintenanceIssueStep.RECONCILE.value,
                    "error": error,
                },
            )
            return MaintenanceResult(issues=(MaintenanceIssue(step=MaintenanceIssueStep.RECONCILE, error=error),))

        issues = tuple(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)
        return MaintenanceResult(created_count=reconciled.created_count, issues=issues)

    def _require_periods(self) -> PartitionCreationService:
        """Return the creation service, or explain that this wiring has no periods."""
        if self._creation_service is None:
            raise InvalidPartitionConfigError(_NO_CALCULATOR_MESSAGE)
        return self._creation_service

    def _require_pruning(self) -> PartitionPruningService:
        """Return the pruning service, or explain that this wiring has no periods."""
        if self._pruning_service is None:
            raise InvalidPartitionConfigError(_NO_CALCULATOR_MESSAGE)
        return self._pruning_service

    async def maintain_lifecycle(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Run create + detach + drop in a single locked maintenance window.

        The whole sequence runs under a single distributed lock acquired through
        the configured :class:`LockManager`, so concurrent maintainers do not
        race on the same parent table.

        Args:
            config: Table partitioning configuration.
            skip_create: Skip the create-ahead step.
            skip_detach: Skip detaching old partitions (orphans are still dropped).
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures instead of aborting the run:
                a failed create still prunes (which may free the space create
                needs), a failed detach still drops existing orphans. Failures
                are collected into ``MaintenanceResult.issues``. Validation and
                lock failures are always fatal.

        Subpartitioned configs additionally reconcile each branch's bucket set
        between create and detach; branches whose shape cannot be converged
        safely are reported through ``MaintenanceResult.issues`` regardless of
        ``continue_on_error``, since leaving them silent would hide writes that
        PostgreSQL is rejecting.

        Returns:
            ``MaintenanceResult`` with the per-step counters; ``error`` is unset
            because exceptions propagate from this method (the maintainer is
            responsible for catching them).

        Raises:
            LockAcquisitionError: If the table-level maintenance lock is unavailable.
            InvalidPartitionConfigError: If ``config`` does not match the parent table.
        """
        qualified_parent = qualify(config.db_schema, config.table_name)

        created_count = 0
        repaired_count = 0
        detached_count = 0
        dropped_count = 0
        issues: list[MaintenanceIssue] = []

        def _record_issue(step: MaintenanceIssueStep, exc: Exception) -> None:
            error = describe_exception(exc)
            issues.append(MaintenanceIssue(step=step, error=error))
            logger.warning(
                "Maintenance step failed; continuing with the remaining steps",
                extra={"table_name": qualified_parent, "step": step.value, "error": error},
            )

        async with self._locks.acquire_lock(qualified_parent):
            await self._validation_service.validate_config(config)

            if not config.is_time_based:
                # A static root has no periods: nothing is created ahead and
                # nothing ages out, so converging its partition set is the
                # whole of maintenance.
                return await self._maintain_static_root(
                    config, skip_create=skip_create, continue_on_error=continue_on_error
                )

            # Optimization: fetch all partitions once
            all_partitions = await self._metadata.list_partitions(qualified_parent)

            # Finishing a branch an earlier run left half-built happens during
            # CREATE, before the reconcile stage that would otherwise count it.
            converged: list[SubpartitionReconcileResult] = []

            if not skip_create:
                try:
                    created = await self._require_periods().create_future_partitions(
                        config, existing_partitions=all_partitions, converged=converged
                    )
                except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.CREATE, e)
                else:
                    created_count = len(created)
                    if created:
                        all_partitions.extend(created)

            # Buckets built while completing a half-built branch are repairs of
            # a pre-existing branch, which is exactly what repaired_count means.
            repaired_count += sum(result.created_count for result in converged)
            issues.extend(
                to_maintenance_issue(finding)
                for result in converged
                for finding in result.findings
                if finding.is_actionable
            )

            try:
                partitions_to_prune = await self._require_pruning().identify_partitions_to_prune(config, all_partitions)
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                # Deciding *what* to prune is as failable as pruning it --
                # a boundary this run cannot read is enough. Left outside
                # continue_on_error it would abort the run after create and
                # reconcile had already committed their DDL.
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.DETACH, e)
                partitions_to_prune = []

            # Reconcile before pruning so a branch that is on its way out is not
            # repaired just to be dropped moments later.
            if config.subpartition is not None:
                try:
                    reconciled = await self._subpartition_service.reconcile(
                        config, exclude={p.name for p in partitions_to_prune}
                    )
                except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.RECONCILE, e)
                else:
                    repaired_count += reconciled.created_count
                    issues.extend(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)

            if not partitions_to_prune:
                return MaintenanceResult(
                    created_count=created_count,
                    repaired_count=repaired_count,
                    issues=tuple(issues),
                )

            attached_to_detach = [p for p in partitions_to_prune if p.is_attached]
            orphan_names = [p.name for p in partitions_to_prune if not p.is_attached]

            names_to_drop = orphan_names
            if not skip_detach:
                try:
                    detached_names = await self._detachment_service.detach_old_partitions(
                        qualified_parent,
                        attached_to_detach,
                    )
                except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    # Partially detached partitions carry the orphan marker and
                    # are collected as orphans on the next run.
                    _record_issue(MaintenanceIssueStep.DETACH, e)
                else:
                    detached_count = len(detached_names)
                    names_to_drop = orphan_names + detached_names

            if not skip_drop and names_to_drop:
                try:
                    dropped_count = await self._deletion_service.drop_detached_partitions(
                        qualified_parent,
                        names_to_drop,
                    )
                except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.DROP, e)

        return MaintenanceResult(
            created_count=created_count,
            repaired_count=repaired_count,
            detached_count=detached_count,
            dropped_count=dropped_count,
            issues=tuple(issues),
        )

__init__(repo, metadata, locks, period_calculator=None, hooks=None)

Initialize the partition lifecycle service.

Parameters:

Name Type Description Default
repo PartitionRepository

DDL operations on partitions (create / attach / detach / drop).

required
metadata PartitionMetadataProvider

Read-only access to PostgreSQL catalog data.

required
locks LockManager

Distributed lock manager preventing concurrent maintenance runs.

required
period_calculator PeriodCalculator[Period] | None

Strategy for determining partition names and boundaries. Required for a TIME_BASED table and meaningless for a static HASH_BASED / VALUE_BASED one, which has no periods.

None
hooks list[PartitionLifecycleHooks] | None

Optional list of lifecycle hooks called around each step.

None
Source code in pg_partsmith/aio/service.py
def __init__(
    self,
    repo: PartitionRepository,
    metadata: PartitionMetadataProvider,
    locks: LockManager,
    period_calculator: PeriodCalculator[Period] | None = None,
    hooks: list[PartitionLifecycleHooks] | None = None,
) -> None:
    """Initialize the partition lifecycle service.

    Args:
        repo: DDL operations on partitions (create / attach / detach / drop).
        metadata: Read-only access to PostgreSQL catalog data.
        locks: Distributed lock manager preventing concurrent maintenance runs.
        period_calculator: Strategy for determining partition names and
            boundaries. Required for a TIME_BASED table and meaningless for
            a static HASH_BASED / VALUE_BASED one, which has no periods.
        hooks: Optional list of lifecycle hooks called around each step.
    """
    validate_timezone_alignment(repo, period_calculator)

    self._locks = locks
    self._metadata = metadata

    # Component services
    self._validation_service = PartitionValidationService(metadata)
    # Subpartitioning is optional, so the collaborators are typed loosely
    # here and checked against the nested protocols only when a config
    # actually asks for it — a flat setup with a custom repository or
    # metadata provider keeps working unchanged.
    self._subpartition_service = PartitionSubpartitionService(
        cast("SubpartitionRepository", repo),
        cast("NestedPartitionMetadata", metadata),
    )
    # The period-driven services exist only when there are periods.
    self._creation_service = (
        PartitionCreationService(repo, metadata, period_calculator, hooks, subpartitions=self._subpartition_service)
        if period_calculator is not None
        else None
    )
    self._pruning_service = (
        PartitionPruningService(metadata, period_calculator) if period_calculator is not None else None
    )
    self._detachment_service = PartitionDetachmentService(repo, hooks)
    self._deletion_service = PartitionDeletionService(repo, hooks)

create_future_partitions(config) async

Create partitions for future periods.

Ensures partitions exist for the next config.create_ahead_count periods starting from the current period (inclusive). Idempotent: existing partitions are skipped.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required

Returns:

Type Description
list[PartitionInfo]

List of newly created partitions (empty if all already existed).

Raises:

Type Description
PartitionAlreadyExistsError

If a partition exists with conflicting boundaries.

InvalidPartitionConfigError

If config is incompatible with the parent table.

Source code in pg_partsmith/aio/service.py
async def create_future_partitions(self, config: TablePartitionConfig) -> list[PartitionInfo]:
    """Create partitions for future periods.

    Ensures partitions exist for the next ``config.create_ahead_count`` periods
    starting from the current period (inclusive). Idempotent: existing partitions
    are skipped.

    Args:
        config: Table partitioning configuration.

    Returns:
        List of newly created partitions (empty if all already existed).

    Raises:
        PartitionAlreadyExistsError: If a partition exists with conflicting boundaries.
        InvalidPartitionConfigError: If ``config`` is incompatible with the parent table.
    """
    return await self._require_periods().create_future_partitions(config)

detach_old_partitions(table_name, partitions) async

Detach attached partitions from their parent table.

Parameters:

Name Type Description Default
table_name str

Qualified parent table name.

required
partitions list[PartitionInfo]

Attached partitions to detach.

required

Returns:

Type Description
list[str]

Names of successfully detached partitions.

Raises:

Type Description
PartitionDetachInProgressError

If a concurrent detach is in progress.

Source code in pg_partsmith/aio/service.py
async def detach_old_partitions(
    self,
    table_name: str,
    partitions: list[PartitionInfo],
) -> list[str]:
    """Detach attached partitions from their parent table.

    Args:
        table_name: Qualified parent table name.
        partitions: Attached partitions to detach.

    Returns:
        Names of successfully detached partitions.

    Raises:
        PartitionDetachInProgressError: If a concurrent detach is in progress.
    """
    return await self._detachment_service.detach_old_partitions(table_name, partitions)

drop_detached_partitions(table_name, partition_names) async

Drop previously detached, marker-tagged partitions.

Attached partitions are skipped with a warning (they raise PartitionAttachedError internally). Unmanaged tables are refused unless the underlying repository is configured otherwise.

Parameters:

Name Type Description Default
table_name str

Qualified parent table name (used for hook context).

required
partition_names list[str]

Names of partitions to drop.

required

Returns:

Type Description
int

Number of partitions actually dropped.

Source code in pg_partsmith/aio/service.py
async def drop_detached_partitions(
    self,
    table_name: str,
    partition_names: list[str],
) -> int:
    """Drop previously detached, marker-tagged partitions.

    Attached partitions are skipped with a warning (they raise
    ``PartitionAttachedError`` internally). Unmanaged tables are refused
    unless the underlying repository is configured otherwise.

    Args:
        table_name: Qualified parent table name (used for hook context).
        partition_names: Names of partitions to drop.

    Returns:
        Number of partitions actually dropped.
    """
    return await self._deletion_service.drop_detached_partitions(table_name, partition_names)

ensure_partition(config, period) async

Create and attach the partition for one specific period (idempotent).

Unlike :meth:create_future_partitions, targets exactly period — useful for writers that must guarantee a partition exists before an insert (e.g. an hourly outbox buffer). Runs the same DEFAULT reconciliation and attach-race handling as the create-ahead path.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
period Period

The period the partition must cover.

required

Returns:

Type Description
PartitionInfo | None

The created partition, or None when it already existed (an existing

PartitionInfo | None

detached partition is re-attached when auto_attach_after_create).

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/aio/service.py
async def ensure_partition(self, config: TablePartitionConfig, period: Period) -> PartitionInfo | None:
    """Create and attach the partition for one specific period (idempotent).

    Unlike :meth:`create_future_partitions`, targets exactly ``period`` —
    useful for writers that must guarantee a partition exists before an
    insert (e.g. an hourly outbox buffer). Runs the same DEFAULT
    reconciliation and attach-race handling as the create-ahead path.

    Args:
        config: Table partitioning configuration.
        period: The period the partition must cover.

    Returns:
        The created partition, or None when it already existed (an existing
        detached partition is re-attached when ``auto_attach_after_create``).

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return await self._require_periods().ensure_partition(config, period)

ensure_partitions(config, periods) async

Create and attach partitions for an explicit set of periods (idempotent).

The backfill counterpart of :meth:create_future_partitions: the caller chooses the periods, so data that already sits in the table can be given partitions without waiting for create-ahead to reach it.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
periods Iterable[Period]

Periods that must have a partition. Duplicates are ignored; order is preserved.

required

Returns:

Type Description
list[PartitionInfo]

The partitions created by this call; periods that already had one

list[PartitionInfo]

are absent from the list.

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/aio/service.py
async def ensure_partitions(
    self,
    config: TablePartitionConfig,
    periods: Iterable[Period],
) -> list[PartitionInfo]:
    """Create and attach partitions for an explicit set of periods (idempotent).

    The backfill counterpart of :meth:`create_future_partitions`: the caller
    chooses the periods, so data that already sits in the table can be given
    partitions without waiting for create-ahead to reach it.

    Args:
        config: Table partitioning configuration.
        periods: Periods that must have a partition. Duplicates are ignored;
            order is preserved.

    Returns:
        The partitions created by this call; periods that already had one
        are absent from the list.

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return await self._require_periods().ensure_partitions(config, periods)

get_partitions_for_pruning(config) async

Return partitions older than config.retention_count periods.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required

Returns:

Type Description
list[PartitionInfo]

Partitions that are eligible for detach + drop, sorted oldest first.

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/aio/service.py
async def get_partitions_for_pruning(self, config: TablePartitionConfig) -> list[PartitionInfo]:
    """Return partitions older than ``config.retention_count`` periods.

    Args:
        config: Table partitioning configuration.

    Returns:
        Partitions that are eligible for detach + drop, sorted oldest first.

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return await self._require_pruning().get_partitions_for_pruning(config)

maintain_lifecycle(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False) async

Run create + detach + drop in a single locked maintenance window.

The whole sequence runs under a single distributed lock acquired through the configured :class:LockManager, so concurrent maintainers do not race on the same parent table.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
skip_create bool

Skip the create-ahead step.

False
skip_detach bool

Skip detaching old partitions (orphans are still dropped).

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures instead of aborting the run: a failed create still prunes (which may free the space create needs), a failed detach still drops existing orphans. Failures are collected into MaintenanceResult.issues. Validation and lock failures are always fatal.

False

Subpartitioned configs additionally reconcile each branch's bucket set between create and detach; branches whose shape cannot be converged safely are reported through MaintenanceResult.issues regardless of continue_on_error, since leaving them silent would hide writes that PostgreSQL is rejecting.

Returns:

Type Description
MaintenanceResult

MaintenanceResult with the per-step counters; error is unset

MaintenanceResult

because exceptions propagate from this method (the maintainer is

MaintenanceResult

responsible for catching them).

Raises:

Type Description
LockAcquisitionError

If the table-level maintenance lock is unavailable.

InvalidPartitionConfigError

If config does not match the parent table.

Source code in pg_partsmith/aio/service.py
async def maintain_lifecycle(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Run create + detach + drop in a single locked maintenance window.

    The whole sequence runs under a single distributed lock acquired through
    the configured :class:`LockManager`, so concurrent maintainers do not
    race on the same parent table.

    Args:
        config: Table partitioning configuration.
        skip_create: Skip the create-ahead step.
        skip_detach: Skip detaching old partitions (orphans are still dropped).
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures instead of aborting the run:
            a failed create still prunes (which may free the space create
            needs), a failed detach still drops existing orphans. Failures
            are collected into ``MaintenanceResult.issues``. Validation and
            lock failures are always fatal.

    Subpartitioned configs additionally reconcile each branch's bucket set
    between create and detach; branches whose shape cannot be converged
    safely are reported through ``MaintenanceResult.issues`` regardless of
    ``continue_on_error``, since leaving them silent would hide writes that
    PostgreSQL is rejecting.

    Returns:
        ``MaintenanceResult`` with the per-step counters; ``error`` is unset
        because exceptions propagate from this method (the maintainer is
        responsible for catching them).

    Raises:
        LockAcquisitionError: If the table-level maintenance lock is unavailable.
        InvalidPartitionConfigError: If ``config`` does not match the parent table.
    """
    qualified_parent = qualify(config.db_schema, config.table_name)

    created_count = 0
    repaired_count = 0
    detached_count = 0
    dropped_count = 0
    issues: list[MaintenanceIssue] = []

    def _record_issue(step: MaintenanceIssueStep, exc: Exception) -> None:
        error = describe_exception(exc)
        issues.append(MaintenanceIssue(step=step, error=error))
        logger.warning(
            "Maintenance step failed; continuing with the remaining steps",
            extra={"table_name": qualified_parent, "step": step.value, "error": error},
        )

    async with self._locks.acquire_lock(qualified_parent):
        await self._validation_service.validate_config(config)

        if not config.is_time_based:
            # A static root has no periods: nothing is created ahead and
            # nothing ages out, so converging its partition set is the
            # whole of maintenance.
            return await self._maintain_static_root(
                config, skip_create=skip_create, continue_on_error=continue_on_error
            )

        # Optimization: fetch all partitions once
        all_partitions = await self._metadata.list_partitions(qualified_parent)

        # Finishing a branch an earlier run left half-built happens during
        # CREATE, before the reconcile stage that would otherwise count it.
        converged: list[SubpartitionReconcileResult] = []

        if not skip_create:
            try:
                created = await self._require_periods().create_future_partitions(
                    config, existing_partitions=all_partitions, converged=converged
                )
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.CREATE, e)
            else:
                created_count = len(created)
                if created:
                    all_partitions.extend(created)

        # Buckets built while completing a half-built branch are repairs of
        # a pre-existing branch, which is exactly what repaired_count means.
        repaired_count += sum(result.created_count for result in converged)
        issues.extend(
            to_maintenance_issue(finding)
            for result in converged
            for finding in result.findings
            if finding.is_actionable
        )

        try:
            partitions_to_prune = await self._require_pruning().identify_partitions_to_prune(config, all_partitions)
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except Exception as e:
            # Deciding *what* to prune is as failable as pruning it --
            # a boundary this run cannot read is enough. Left outside
            # continue_on_error it would abort the run after create and
            # reconcile had already committed their DDL.
            if not continue_on_error:
                raise
            _record_issue(MaintenanceIssueStep.DETACH, e)
            partitions_to_prune = []

        # Reconcile before pruning so a branch that is on its way out is not
        # repaired just to be dropped moments later.
        if config.subpartition is not None:
            try:
                reconciled = await self._subpartition_service.reconcile(
                    config, exclude={p.name for p in partitions_to_prune}
                )
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.RECONCILE, e)
            else:
                repaired_count += reconciled.created_count
                issues.extend(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)

        if not partitions_to_prune:
            return MaintenanceResult(
                created_count=created_count,
                repaired_count=repaired_count,
                issues=tuple(issues),
            )

        attached_to_detach = [p for p in partitions_to_prune if p.is_attached]
        orphan_names = [p.name for p in partitions_to_prune if not p.is_attached]

        names_to_drop = orphan_names
        if not skip_detach:
            try:
                detached_names = await self._detachment_service.detach_old_partitions(
                    qualified_parent,
                    attached_to_detach,
                )
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                # Partially detached partitions carry the orphan marker and
                # are collected as orphans on the next run.
                _record_issue(MaintenanceIssueStep.DETACH, e)
            else:
                detached_count = len(detached_names)
                names_to_drop = orphan_names + detached_names

        if not skip_drop and names_to_drop:
            try:
                dropped_count = await self._deletion_service.drop_detached_partitions(
                    qualified_parent,
                    names_to_drop,
                )
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.DROP, e)

    return MaintenanceResult(
        created_count=created_count,
        repaired_count=repaired_count,
        detached_count=detached_count,
        dropped_count=dropped_count,
        issues=tuple(issues),
    )

reconcile_subpartitions(config, *, exclude=()) async

Converge the subtree of every attached partition towards the config.

Idempotent and safe to call on its own: it creates only the buckets a branch is genuinely missing, and reports rather than "repairs" any branch whose shape it cannot converge without risk.

It takes no distributed lock of its own -- unlike :meth:maintain_lifecycle, which runs its whole sequence under one. Two workers calling this concurrently is safe: a lost race on a bucket is recognised by its bounds and reported, not retried into a failure. But calling it while a maintainer is mid-run means both are converging the same tree, and the wasted work is yours to weigh. Wrap it in your own lock if you would rather they queued.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration. Without a subpartition spec this is a no-op returning an empty result.

required
exclude Collection[str]

Schema-qualified partition names to skip.

()

Returns:

Type Description
SubpartitionReconcileResult

The subpartitions created and the divergences left alone.

Raises:

Type Description
UnsupportedCapabilityError

If the repository or metadata provider cannot serve a nested configuration.

Source code in pg_partsmith/aio/service.py
async def reconcile_subpartitions(
    self,
    config: TablePartitionConfig,
    *,
    exclude: Collection[str] = (),
) -> SubpartitionReconcileResult:
    """Converge the subtree of every attached partition towards the config.

    Idempotent and safe to call on its own: it creates only the buckets a
    branch is genuinely missing, and reports rather than "repairs" any
    branch whose shape it cannot converge without risk.

    It takes **no distributed lock of its own** -- unlike
    :meth:`maintain_lifecycle`, which runs its whole sequence under one.
    Two workers calling this concurrently is safe: a lost race on a bucket
    is recognised by its bounds and reported, not retried into a failure.
    But calling it while a maintainer is mid-run means both are converging
    the same tree, and the wasted work is yours to weigh. Wrap it in your
    own lock if you would rather they queued.

    Args:
        config: Table partitioning configuration. Without a subpartition
            spec this is a no-op returning an empty result.
        exclude: Schema-qualified partition names to skip.

    Returns:
        The subpartitions created and the divergences left alone.

    Raises:
        UnsupportedCapabilityError: If the repository or metadata provider
            cannot serve a nested configuration.
    """
    return await self._subpartition_service.reconcile(config, exclude=exclude)

Orchestrator for partition lifecycle maintenance.

Wraps a lifecycle service with timing, logging, and error handling. Operational failures are logged and re-raised by run_maintenance. Use run_maintenance_safe (or the maintain_partitions helper) when you need a scheduler-friendly API that always returns MaintenanceResult.

Source code in pg_partsmith/aio/maintainer.py
class PartitionMaintainer:
    """Orchestrator for partition lifecycle maintenance.

    Wraps a lifecycle service with timing, logging, and error handling.
    Operational failures are logged and re-raised by ``run_maintenance``.
    Use ``run_maintenance_safe`` (or the ``maintain_partitions`` helper) when
    you need a scheduler-friendly API that always returns ``MaintenanceResult``.
    """

    def __init__(
        self,
        lifecycle_service: PartitionLifecycle,
    ) -> None:
        """Initialize maintainer.

        Args:
            lifecycle_service: Partition lifecycle service.
        """
        self._service = lifecycle_service

    async def run_maintenance(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Execute full partition lifecycle maintenance.

        Args:
            config: Table partition configuration.
            skip_create: Skip creating future partitions.
            skip_detach: Skip detaching old partitions.
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures into ``result.issues``
                instead of aborting the run (see ``maintain_lifecycle``).

        Returns:
            Maintenance result with counts and duration.

        Raises:
            asyncio.CancelledError: Propagated after being logged.
            Exception: Propagated after being logged.
        """
        start_time = time.perf_counter()
        qualified_table = qualify(config.db_schema, config.table_name)

        logger.info(
            "Starting partition maintenance",
            extra={
                "table_name": qualified_table,
                "skip_create": skip_create,
                "skip_detach": skip_detach,
                "skip_drop": skip_drop,
            },
        )

        try:
            result = await self._service.maintain_lifecycle(
                config,
                skip_create=skip_create,
                skip_detach=skip_detach,
                skip_drop=skip_drop,
                continue_on_error=continue_on_error,
            )

            duration_ms = elapsed_ms(start_time)
            result = result.model_copy(update={"duration_ms": duration_ms})

            logger.info(
                "Partition maintenance completed successfully",
                extra={
                    "table_name": qualified_table,
                    "created_count": result.created_count,
                    "detached_count": result.detached_count,
                    "dropped_count": result.dropped_count,
                    "duration_ms": duration_ms,
                    "duration": format_duration_ms(duration_ms),
                },
            )
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            duration_ms = elapsed_ms(start_time)

            logger.info(
                "Partition maintenance was interrupted by system signal",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                },
            )
            raise
        except PartitionError as e:
            duration_ms = elapsed_ms(start_time)
            logger.warning(
                "Partition maintenance failed (operational error)",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                    "error": str(e),
                    "error_type": type(e).__name__,
                },
            )
            raise
        except (ValueError, TypeError, RuntimeError) as e:
            duration_ms = elapsed_ms(start_time)
            logger.warning(
                "Partition maintenance failed",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                    "error": str(e),
                    "error_type": type(e).__name__,
                },
            )
            raise
        except Exception:
            duration_ms = elapsed_ms(start_time)

            logger.exception(
                "Partition maintenance raised unexpected exception",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                },
            )
            raise
        else:
            return result

    async def run_maintenance_safe(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Run maintenance and always return ``MaintenanceResult``, never raise.

        Scheduler-friendly wrapper around :meth:`run_maintenance`. Any exception
        — including ``asyncio.CancelledError`` — is captured and reported via
        ``result.error``; the ``duration_ms`` field always reflects the elapsed
        time even on failure.

        Args:
            config: Table partitioning configuration.
            skip_create: Skip creating future partitions.
            skip_detach: Skip detaching old partitions.
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures into ``result.issues``
                instead of aborting the run (see ``maintain_lifecycle``).

        Returns:
            ``MaintenanceResult`` with counts on success or ``error`` set on failure.
        """
        start_time = time.perf_counter()
        try:
            return await self.run_maintenance(
                config,
                skip_create=skip_create,
                skip_detach=skip_detach,
                skip_drop=skip_drop,
                continue_on_error=continue_on_error,
            )
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit) as e:
            return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))
        except Exception as e:
            return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))

run_maintenance(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False) async

Execute full partition lifecycle maintenance.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
skip_create bool

Skip creating future partitions.

False
skip_detach bool

Skip detaching old partitions.

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures into result.issues instead of aborting the run (see maintain_lifecycle).

False

Returns:

Type Description
MaintenanceResult

Maintenance result with counts and duration.

Raises:

Type Description
CancelledError

Propagated after being logged.

Exception

Propagated after being logged.

Source code in pg_partsmith/aio/maintainer.py
async def run_maintenance(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Execute full partition lifecycle maintenance.

    Args:
        config: Table partition configuration.
        skip_create: Skip creating future partitions.
        skip_detach: Skip detaching old partitions.
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures into ``result.issues``
            instead of aborting the run (see ``maintain_lifecycle``).

    Returns:
        Maintenance result with counts and duration.

    Raises:
        asyncio.CancelledError: Propagated after being logged.
        Exception: Propagated after being logged.
    """
    start_time = time.perf_counter()
    qualified_table = qualify(config.db_schema, config.table_name)

    logger.info(
        "Starting partition maintenance",
        extra={
            "table_name": qualified_table,
            "skip_create": skip_create,
            "skip_detach": skip_detach,
            "skip_drop": skip_drop,
        },
    )

    try:
        result = await self._service.maintain_lifecycle(
            config,
            skip_create=skip_create,
            skip_detach=skip_detach,
            skip_drop=skip_drop,
            continue_on_error=continue_on_error,
        )

        duration_ms = elapsed_ms(start_time)
        result = result.model_copy(update={"duration_ms": duration_ms})

        logger.info(
            "Partition maintenance completed successfully",
            extra={
                "table_name": qualified_table,
                "created_count": result.created_count,
                "detached_count": result.detached_count,
                "dropped_count": result.dropped_count,
                "duration_ms": duration_ms,
                "duration": format_duration_ms(duration_ms),
            },
        )
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
        duration_ms = elapsed_ms(start_time)

        logger.info(
            "Partition maintenance was interrupted by system signal",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
            },
        )
        raise
    except PartitionError as e:
        duration_ms = elapsed_ms(start_time)
        logger.warning(
            "Partition maintenance failed (operational error)",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
                "error": str(e),
                "error_type": type(e).__name__,
            },
        )
        raise
    except (ValueError, TypeError, RuntimeError) as e:
        duration_ms = elapsed_ms(start_time)
        logger.warning(
            "Partition maintenance failed",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
                "error": str(e),
                "error_type": type(e).__name__,
            },
        )
        raise
    except Exception:
        duration_ms = elapsed_ms(start_time)

        logger.exception(
            "Partition maintenance raised unexpected exception",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
            },
        )
        raise
    else:
        return result

run_maintenance_safe(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False) async

Run maintenance and always return MaintenanceResult, never raise.

Scheduler-friendly wrapper around :meth:run_maintenance. Any exception — including asyncio.CancelledError — is captured and reported via result.error; the duration_ms field always reflects the elapsed time even on failure.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
skip_create bool

Skip creating future partitions.

False
skip_detach bool

Skip detaching old partitions.

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures into result.issues instead of aborting the run (see maintain_lifecycle).

False

Returns:

Type Description
MaintenanceResult

MaintenanceResult with counts on success or error set on failure.

Source code in pg_partsmith/aio/maintainer.py
async def run_maintenance_safe(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Run maintenance and always return ``MaintenanceResult``, never raise.

    Scheduler-friendly wrapper around :meth:`run_maintenance`. Any exception
    — including ``asyncio.CancelledError`` — is captured and reported via
    ``result.error``; the ``duration_ms`` field always reflects the elapsed
    time even on failure.

    Args:
        config: Table partitioning configuration.
        skip_create: Skip creating future partitions.
        skip_detach: Skip detaching old partitions.
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures into ``result.issues``
            instead of aborting the run (see ``maintain_lifecycle``).

    Returns:
        ``MaintenanceResult`` with counts on success or ``error`` set on failure.
    """
    start_time = time.perf_counter()
    try:
        return await self.run_maintenance(
            config,
            skip_create=skip_create,
            skip_detach=skip_detach,
            skip_drop=skip_drop,
            continue_on_error=continue_on_error,
        )
    except (asyncio.CancelledError, KeyboardInterrupt, SystemExit) as e:
        return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))
    except Exception as e:
        return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))

Protocols

Implement these to swap in your own storage or locking. The flat pair is all a single-column, unnested config needs; the rest are opt-in and only required when a config actually asks for what they add.

Bases: Protocol

Repository for partition DDL operations.

This protocol is intentionally limited to write operations. All read operations (listing partitions, checking existence) live in PartitionMetadataProvider so that the two concerns can be mocked, swapped, or overridden independently.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class PartitionRepository(Protocol):
    """Repository for partition DDL operations.

    This protocol is intentionally limited to write operations.  All read
    operations (listing partitions, checking existence) live in
    ``PartitionMetadataProvider`` so that the two concerns can be mocked,
    swapped, or overridden independently.
    """

    async def create_partition(
        self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
    ) -> PartitionInfo:
        """Create a new partition table.

        Args:
            config: Table partition configuration.
            partition_name: Name for the new partition table.
            from_value: Start boundary value.
            to_value: End boundary value.

        Returns:
            Created partition info.

        Raises:
            PartitionAlreadyExistsError: If partition already exists.
        """
        ...

    async def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
        """Attach partition to parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            from_value: Start boundary value.
            to_value: End boundary value.
        """
        ...

    async def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
        """Detach partition from parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            concurrent: Use DETACH PARTITION CONCURRENTLY if supported.

        Raises:
            PartitionNotFoundError: If partition doesn't exist.
        """
        ...

    async def drop_partition(self, partition_name: str) -> None:
        """Drop a partition table.

        Args:
            partition_name: Partition table name.

        Raises:
            PartitionAttachedError: If partition is still attached.
        """
        ...

    async def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        from_value: str,
        to_value: str,
    ) -> int:
        """Move rows from DEFAULT partition to target partition for given range.

        Args:
            default_partition_name: Qualified name of DEFAULT partition.
            target_partition_name: Qualified name of target partition.
            partition_column: Column used for partitioning.
            from_value: Range start boundary (inclusive).
            to_value: Range end boundary (exclusive).

        Returns:
            Number of rows moved.

        Raises:
            SQLAlchemyError: On database errors.
        """
        ...

attach_partition(table_name, partition_name, from_value, to_value) async

Attach partition to parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
Source code in pg_partsmith/aio/protocols.py
async def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
    """Attach partition to parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        from_value: Start boundary value.
        to_value: End boundary value.
    """
    ...

create_partition(config, partition_name, from_value, to_value) async

Create a new partition table.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition_name str

Name for the new partition table.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required

Returns:

Type Description
PartitionInfo

Created partition info.

Raises:

Type Description
PartitionAlreadyExistsError

If partition already exists.

Source code in pg_partsmith/aio/protocols.py
async def create_partition(
    self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
) -> PartitionInfo:
    """Create a new partition table.

    Args:
        config: Table partition configuration.
        partition_name: Name for the new partition table.
        from_value: Start boundary value.
        to_value: End boundary value.

    Returns:
        Created partition info.

    Raises:
        PartitionAlreadyExistsError: If partition already exists.
    """
    ...

detach_partition(table_name, partition_name, *, concurrent=True) async

Detach partition from parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
concurrent bool

Use DETACH PARTITION CONCURRENTLY if supported.

True

Raises:

Type Description
PartitionNotFoundError

If partition doesn't exist.

Source code in pg_partsmith/aio/protocols.py
async def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
    """Detach partition from parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        concurrent: Use DETACH PARTITION CONCURRENTLY if supported.

    Raises:
        PartitionNotFoundError: If partition doesn't exist.
    """
    ...

drop_partition(partition_name) async

Drop a partition table.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Raises:

Type Description
PartitionAttachedError

If partition is still attached.

Source code in pg_partsmith/aio/protocols.py
async def drop_partition(self, partition_name: str) -> None:
    """Drop a partition table.

    Args:
        partition_name: Partition table name.

    Raises:
        PartitionAttachedError: If partition is still attached.
    """
    ...

reconcile_default_rows(*, default_partition_name, target_partition_name, partition_column, from_value, to_value) async

Move rows from DEFAULT partition to target partition for given range.

Parameters:

Name Type Description Default
default_partition_name str

Qualified name of DEFAULT partition.

required
target_partition_name str

Qualified name of target partition.

required
partition_column str

Column used for partitioning.

required
from_value str

Range start boundary (inclusive).

required
to_value str

Range end boundary (exclusive).

required

Returns:

Type Description
int

Number of rows moved.

Raises:

Type Description
SQLAlchemyError

On database errors.

Source code in pg_partsmith/aio/protocols.py
async def reconcile_default_rows(
    self,
    *,
    default_partition_name: str,
    target_partition_name: str,
    partition_column: str,
    from_value: str,
    to_value: str,
) -> int:
    """Move rows from DEFAULT partition to target partition for given range.

    Args:
        default_partition_name: Qualified name of DEFAULT partition.
        target_partition_name: Qualified name of target partition.
        partition_column: Column used for partitioning.
        from_value: Range start boundary (inclusive).
        to_value: Range end boundary (exclusive).

    Returns:
        Number of rows moved.

    Raises:
        SQLAlchemyError: On database errors.
    """
    ...

Bases: Protocol

Provider for reading partition metadata from the database catalogue.

This protocol owns all read operations so that the service layer depends on a single injectable read interface. Implement this protocol to support a different database, a caching layer, or a stub for testing.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class PartitionMetadataProvider(Protocol):
    """Provider for reading partition metadata from the database catalogue.

    This protocol owns *all* read operations so that the service layer depends
    on a single injectable read interface.  Implement this protocol to support a
    different database, a caching layer, or a stub for testing.
    """

    async def get_partition_type(self, table_name: str) -> PartitionType | None:
        """Get partition type for a table.

        Args:
            table_name: Table name.

        Returns:
            Partition type or None if table is not partitioned.
        """
        ...

    async def get_partition_column(self, table_name: str) -> str | None:
        """Get partition column for a table.

        Args:
            table_name: Table name.

        Returns:
            Partition column name or None if table is not partitioned.

        Raises:
            ValueError: If the table uses a composite (multi-column) partition key.
        """
        ...

    async def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
        """Get partition boundaries.

        Args:
            partition_name: Partition table name.

        Returns:
            Tuple of (from_value, to_value) or None if not a range partition.
        """
        ...

    async def list_partitions(self, table_name: str) -> list[PartitionInfo]:
        """List all partitions for a table, including orphaned detached ones.

        Orphaned partitions are tables that were detached in a previous
        maintenance run but never dropped.  They are returned with
        ``is_attached=False`` and ``None`` boundaries so that the service can
        schedule them for cleanup on the next run.

        Args:
            table_name: Parent table name.

        Returns:
            List of partition metadata.
        """
        ...

    async def partition_exists(self, partition_name: str) -> bool:
        """Check if a partition table exists in the catalogue.

        Args:
            partition_name: Partition table name.

        Returns:
            True if the table exists.
        """
        ...

    async def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
        """Check if a partition is currently attached to its parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.

        Returns:
            True if the partition is attached via pg_inherits.
        """
        ...

    async def get_default_partition(self, table_name: str) -> PartitionInfo | None:
        """Get DEFAULT partition for a table if it exists and is attached.

        Args:
            table_name: Parent table name.

        Returns:
            PartitionInfo with is_default=True, or None if no default partition exists.
        """
        ...

get_default_partition(table_name) async

Get DEFAULT partition for a table if it exists and is attached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
PartitionInfo | None

PartitionInfo with is_default=True, or None if no default partition exists.

Source code in pg_partsmith/aio/protocols.py
async def get_default_partition(self, table_name: str) -> PartitionInfo | None:
    """Get DEFAULT partition for a table if it exists and is attached.

    Args:
        table_name: Parent table name.

    Returns:
        PartitionInfo with is_default=True, or None if no default partition exists.
    """
    ...

get_partition_boundaries(partition_name) async

Get partition boundaries.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
tuple[str, str] | None

Tuple of (from_value, to_value) or None if not a range partition.

Source code in pg_partsmith/aio/protocols.py
async def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
    """Get partition boundaries.

    Args:
        partition_name: Partition table name.

    Returns:
        Tuple of (from_value, to_value) or None if not a range partition.
    """
    ...

get_partition_column(table_name) async

Get partition column for a table.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
str | None

Partition column name or None if table is not partitioned.

Raises:

Type Description
ValueError

If the table uses a composite (multi-column) partition key.

Source code in pg_partsmith/aio/protocols.py
async def get_partition_column(self, table_name: str) -> str | None:
    """Get partition column for a table.

    Args:
        table_name: Table name.

    Returns:
        Partition column name or None if table is not partitioned.

    Raises:
        ValueError: If the table uses a composite (multi-column) partition key.
    """
    ...

get_partition_type(table_name) async

Get partition type for a table.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
PartitionType | None

Partition type or None if table is not partitioned.

Source code in pg_partsmith/aio/protocols.py
async def get_partition_type(self, table_name: str) -> PartitionType | None:
    """Get partition type for a table.

    Args:
        table_name: Table name.

    Returns:
        Partition type or None if table is not partitioned.
    """
    ...

is_partition_attached(table_name, partition_name) async

Check if a partition is currently attached to its parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the partition is attached via pg_inherits.

Source code in pg_partsmith/aio/protocols.py
async def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
    """Check if a partition is currently attached to its parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.

    Returns:
        True if the partition is attached via pg_inherits.
    """
    ...

list_partitions(table_name) async

List all partitions for a table, including orphaned detached ones.

Orphaned partitions are tables that were detached in a previous maintenance run but never dropped. They are returned with is_attached=False and None boundaries so that the service can schedule them for cleanup on the next run.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
list[PartitionInfo]

List of partition metadata.

Source code in pg_partsmith/aio/protocols.py
async def list_partitions(self, table_name: str) -> list[PartitionInfo]:
    """List all partitions for a table, including orphaned detached ones.

    Orphaned partitions are tables that were detached in a previous
    maintenance run but never dropped.  They are returned with
    ``is_attached=False`` and ``None`` boundaries so that the service can
    schedule them for cleanup on the next run.

    Args:
        table_name: Parent table name.

    Returns:
        List of partition metadata.
    """
    ...

partition_exists(partition_name) async

Check if a partition table exists in the catalogue.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the table exists.

Source code in pg_partsmith/aio/protocols.py
async def partition_exists(self, partition_name: str) -> bool:
    """Check if a partition table exists in the catalogue.

    Args:
        partition_name: Partition table name.

    Returns:
        True if the table exists.
    """
    ...

Bases: Protocol

DDL for partitions that are themselves partitioned tables.

Kept separate from :class:PartitionRepository on purpose: a repository written against the flat protocol keeps satisfying it, and is only required to grow these three methods once a config actually asks for subpartitioning.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class SubpartitionRepository(Protocol):
    """DDL for partitions that are themselves partitioned tables.

    Kept separate from :class:`PartitionRepository` on purpose: a repository
    written against the flat protocol keeps satisfying it, and is only required
    to grow these three methods once a config actually asks for subpartitioning.
    """

    async def create_branch(
        self,
        config: TablePartitionConfig,
        branch_name: str,
        from_value: str,
        to_value: str,
        spec: SubpartitionSpec,
    ) -> PartitionInfo:
        """Create a detached time partition that is itself partitioned.

        Args:
            config: Table partition configuration.
            branch_name: Name for the new branch table.
            from_value: Start boundary value.
            to_value: End boundary value.
            spec: Subpartitioning the branch applies to its own children.

        Returns:
            Info about the created (still detached) branch.

        Raises:
            PartitionAlreadyExistsError: If a relation of that name exists.
        """
        ...

    async def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
        """Create a detached table shaped like ``parent_name``.

        Args:
            parent_name: Relation the table will later be attached to.
            child_name: Name for the new table.
            spec: Subpartitioning the table applies to its own children, or None.

        Raises:
            PartitionAlreadyExistsError: If a relation of that name exists.
        """
        ...

    async def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
        """Attach one subpartition to its parent.

        Args:
            parent_name: Partitioned relation to attach to.
            child_name: Table to attach.
            bounds: What the child owns — a hash bucket, a set of LIST values,
                or DEFAULT.
        """
        ...

attach_subpartition(parent_name, child_name, bounds) async

Attach one subpartition to its parent.

Parameters:

Name Type Description Default
parent_name str

Partitioned relation to attach to.

required
child_name str

Table to attach.

required
bounds SubpartitionBounds

What the child owns — a hash bucket, a set of LIST values, or DEFAULT.

required
Source code in pg_partsmith/aio/protocols.py
async def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
    """Attach one subpartition to its parent.

    Args:
        parent_name: Partitioned relation to attach to.
        child_name: Table to attach.
        bounds: What the child owns — a hash bucket, a set of LIST values,
            or DEFAULT.
    """
    ...

create_branch(config, branch_name, from_value, to_value, spec) async

Create a detached time partition that is itself partitioned.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
branch_name str

Name for the new branch table.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
spec SubpartitionSpec

Subpartitioning the branch applies to its own children.

required

Returns:

Type Description
PartitionInfo

Info about the created (still detached) branch.

Raises:

Type Description
PartitionAlreadyExistsError

If a relation of that name exists.

Source code in pg_partsmith/aio/protocols.py
async def create_branch(
    self,
    config: TablePartitionConfig,
    branch_name: str,
    from_value: str,
    to_value: str,
    spec: SubpartitionSpec,
) -> PartitionInfo:
    """Create a detached time partition that is itself partitioned.

    Args:
        config: Table partition configuration.
        branch_name: Name for the new branch table.
        from_value: Start boundary value.
        to_value: End boundary value.
        spec: Subpartitioning the branch applies to its own children.

    Returns:
        Info about the created (still detached) branch.

    Raises:
        PartitionAlreadyExistsError: If a relation of that name exists.
    """
    ...

create_subpartition_table(parent_name, child_name, spec) async

Create a detached table shaped like parent_name.

Parameters:

Name Type Description Default
parent_name str

Relation the table will later be attached to.

required
child_name str

Name for the new table.

required
spec SubpartitionSpec | None

Subpartitioning the table applies to its own children, or None.

required

Raises:

Type Description
PartitionAlreadyExistsError

If a relation of that name exists.

Source code in pg_partsmith/aio/protocols.py
async def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
    """Create a detached table shaped like ``parent_name``.

    Args:
        parent_name: Relation the table will later be attached to.
        child_name: Name for the new table.
        spec: Subpartitioning the table applies to its own children, or None.

    Raises:
        PartitionAlreadyExistsError: If a relation of that name exists.
    """
    ...

Bases: Protocol

Structural introspection a nested configuration needs.

Also separate from :class:PartitionMetadataProvider so flat setups keep working with providers that predate subpartitioning.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class NestedPartitionMetadata(Protocol):
    """Structural introspection a nested configuration needs.

    Also separate from :class:`PartitionMetadataProvider` so flat setups keep
    working with providers that predate subpartitioning.
    """

    async def get_partition_tree(self, table_name: str) -> PartitionNode | None:
        """Return the whole partition tree rooted at ``table_name``.

        Args:
            table_name: Root of the tree, schema-qualified.

        Returns:
            The root node with its descendants, or None when the relation is
            neither partitioned nor a partition.
        """
        ...

    async def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
        """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            One tuple of column names per constraint.
        """
        ...

get_partition_tree(table_name) async

Return the whole partition tree rooted at table_name.

Parameters:

Name Type Description Default
table_name str

Root of the tree, schema-qualified.

required

Returns:

Type Description
PartitionNode | None

The root node with its descendants, or None when the relation is

PartitionNode | None

neither partitioned nor a partition.

Source code in pg_partsmith/aio/protocols.py
async def get_partition_tree(self, table_name: str) -> PartitionNode | None:
    """Return the whole partition tree rooted at ``table_name``.

    Args:
        table_name: Root of the tree, schema-qualified.

    Returns:
        The root node with its descendants, or None when the relation is
        neither partitioned nor a partition.
    """
    ...

get_unique_constraint_columns(table_name) async

Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[tuple[str, ...], ...]

One tuple of column names per constraint.

Source code in pg_partsmith/aio/protocols.py
async def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
    """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        One tuple of column names per constraint.
    """
    ...

Bases: Protocol

DDL for a parent whose partition key spans several columns.

Separate from :class:PartitionRepository for the same reason as the nested protocols: a repository written before composite keys keeps satisfying the flat one, and is only required to grow this method once a config actually declares a multi-column key.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class CompositeKeyRepository(Protocol):
    """DDL for a parent whose partition key spans several columns.

    Separate from :class:`PartitionRepository` for the same reason as the
    nested protocols: a repository written before composite keys keeps
    satisfying the flat one, and is only required to grow this method once a
    config actually declares a multi-column key.
    """

    async def attach_composite_partition(
        self,
        table_name: str,
        partition_name: str,
        from_value: str,
        to_value: str,
        *,
        key_arity: int,
    ) -> None:
        """Attach a partition, padding the trailing key columns with MINVALUE.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            from_value: Start boundary for the leading column.
            to_value: End boundary for the leading column.
            key_arity: Number of columns in the parent's partition key.
        """
        ...

    async def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        trailing_columns: tuple[str, ...] = (),
        from_value: str,
        to_value: str,
    ) -> int:
        """Move conflicting rows from a DEFAULT partition, honouring the whole key.

        The composite widening of :meth:`PartitionRepository.reconcile_default_rows`.
        It is declared here rather than on the base protocol so an
        implementation written against the single-column signature keeps
        satisfying ``PartitionRepository`` -- for a type checker as much as at
        runtime.

        PostgreSQL adds an IS NOT NULL test for every key column to a range
        partition's constraint, so a row with a NULL trailing key value belongs
        in DEFAULT and has to be left there.

        Args:
            default_partition_name: Qualified name of DEFAULT partition.
            target_partition_name: Qualified name of target partition.
            partition_column: Leading column of the partition key.
            trailing_columns: The remaining key columns, in key order.
            from_value: Range start boundary (inclusive).
            to_value: Range end boundary (exclusive).

        Returns:
            Number of rows moved.
        """
        ...

attach_composite_partition(table_name, partition_name, from_value, to_value, *, key_arity) async

Attach a partition, padding the trailing key columns with MINVALUE.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
from_value str

Start boundary for the leading column.

required
to_value str

End boundary for the leading column.

required
key_arity int

Number of columns in the parent's partition key.

required
Source code in pg_partsmith/aio/protocols.py
async def attach_composite_partition(
    self,
    table_name: str,
    partition_name: str,
    from_value: str,
    to_value: str,
    *,
    key_arity: int,
) -> None:
    """Attach a partition, padding the trailing key columns with MINVALUE.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        from_value: Start boundary for the leading column.
        to_value: End boundary for the leading column.
        key_arity: Number of columns in the parent's partition key.
    """
    ...

reconcile_default_rows(*, default_partition_name, target_partition_name, partition_column, trailing_columns=(), from_value, to_value) async

Move conflicting rows from a DEFAULT partition, honouring the whole key.

The composite widening of :meth:PartitionRepository.reconcile_default_rows. It is declared here rather than on the base protocol so an implementation written against the single-column signature keeps satisfying PartitionRepository -- for a type checker as much as at runtime.

PostgreSQL adds an IS NOT NULL test for every key column to a range partition's constraint, so a row with a NULL trailing key value belongs in DEFAULT and has to be left there.

Parameters:

Name Type Description Default
default_partition_name str

Qualified name of DEFAULT partition.

required
target_partition_name str

Qualified name of target partition.

required
partition_column str

Leading column of the partition key.

required
trailing_columns tuple[str, ...]

The remaining key columns, in key order.

()
from_value str

Range start boundary (inclusive).

required
to_value str

Range end boundary (exclusive).

required

Returns:

Type Description
int

Number of rows moved.

Source code in pg_partsmith/aio/protocols.py
async def reconcile_default_rows(
    self,
    *,
    default_partition_name: str,
    target_partition_name: str,
    partition_column: str,
    trailing_columns: tuple[str, ...] = (),
    from_value: str,
    to_value: str,
) -> int:
    """Move conflicting rows from a DEFAULT partition, honouring the whole key.

    The composite widening of :meth:`PartitionRepository.reconcile_default_rows`.
    It is declared here rather than on the base protocol so an
    implementation written against the single-column signature keeps
    satisfying ``PartitionRepository`` -- for a type checker as much as at
    runtime.

    PostgreSQL adds an IS NOT NULL test for every key column to a range
    partition's constraint, so a row with a NULL trailing key value belongs
    in DEFAULT and has to be left there.

    Args:
        default_partition_name: Qualified name of DEFAULT partition.
        target_partition_name: Qualified name of target partition.
        partition_column: Leading column of the partition key.
        trailing_columns: The remaining key columns, in key order.
        from_value: Range start boundary (inclusive).
        to_value: Range end boundary (exclusive).

    Returns:
        Number of rows moved.
    """
    ...

Bases: Protocol

Introspection of a partition key that spans several columns.

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class CompositeKeyMetadata(Protocol):
    """Introspection of a partition key that spans several columns."""

    async def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
        """Return a table's own partition key columns, in key order.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            The key columns in order; empty when the table is not partitioned.
        """
        ...

get_partition_columns(table_name) async

Return a table's own partition key columns, in key order.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

The key columns in order; empty when the table is not partitioned.

Source code in pg_partsmith/aio/protocols.py
async def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
    """Return a table's own partition key columns, in key order.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        The key columns in order; empty when the table is not partitioned.
    """
    ...

Bases: Protocol

Lock manager for coordinating partition operations.

This protocol defines the interface for acquiring and releasing locks to prevent concurrent partition operations on the same table. Implement this to use a different locking backend (e.g. Zookeeper).

Source code in pg_partsmith/aio/protocols.py
@runtime_checkable
class LockManager(Protocol):
    """Lock manager for coordinating partition operations.

    This protocol defines the interface for acquiring and releasing locks
    to prevent concurrent partition operations on the same table.
    Implement this to use a different locking backend (e.g. Zookeeper).
    """

    def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
        """Acquire lock for partition operations on a table.

        Args:
            table_name: Table name to lock.

        Returns:
            Async context manager for the lock.

        Raises:
            LockAcquisitionError: If unable to acquire lock.
        """
        ...

    async def is_locked(self, table_name: str) -> bool:
        """Check if table is currently locked.

        Args:
            table_name: Table name.

        Returns:
            True if table is locked.
        """
        ...

acquire_lock(table_name)

Acquire lock for partition operations on a table.

Parameters:

Name Type Description Default
table_name str

Table name to lock.

required

Returns:

Type Description
AbstractAsyncContextManager[None]

Async context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If unable to acquire lock.

Source code in pg_partsmith/aio/protocols.py
def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
    """Acquire lock for partition operations on a table.

    Args:
        table_name: Table name to lock.

    Returns:
        Async context manager for the lock.

    Raises:
        LockAcquisitionError: If unable to acquire lock.
    """
    ...

is_locked(table_name) async

Check if table is currently locked.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
bool

True if table is locked.

Source code in pg_partsmith/aio/protocols.py
async def is_locked(self, table_name: str) -> bool:
    """Check if table is currently locked.

    Args:
        table_name: Table name.

    Returns:
        True if table is locked.
    """
    ...

PostgreSQL implementations

PostgreSQL implementation of partition repository.

Facade that delegates to specialized helper classes for improved maintenance and SRP.

Source code in pg_partsmith/aio/repositories/repository.py
class PostgresPartitionRepository:
    """PostgreSQL implementation of partition repository.

    Facade that delegates to specialized helper classes for improved maintenance and SRP.
    """

    def __init__(
        self,
        engine: AsyncEngine,
        *,
        ddl_timezone: str | None = DEFAULT_DDL_TIMEZONE,
        ddl_timeout_seconds: float = DEFAULT_DDL_TIMEOUT_SECONDS,
        marker_prefix: str | None = None,
        drop_allow_unmanaged: bool = False,
        drop_lock_timeout_ms: int = DEFAULT_DROP_LOCK_TIMEOUT_MS,
        drop_max_retries: int = DEFAULT_DROP_MAX_RETRIES,
        drop_retry_delay: float = DEFAULT_DROP_RETRY_DELAY,
        drop_max_backoff: float = DEFAULT_DROP_MAX_BACKOFF,
    ) -> None:
        marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
        ddl_timeout_seconds = validate_ddl_timeout(ddl_timeout_seconds)
        self._ddl_timezone = validate_timezone(ddl_timezone)
        drop_lock_timeout_ms = validate_int(drop_lock_timeout_ms, "drop_lock_timeout_ms", min_val=0)
        drop_max_retries = validate_int(drop_max_retries, "drop_max_retries", min_val=1)
        drop_retry_delay = validate_float(drop_retry_delay, "drop_retry_delay", min_val=0.0)
        drop_max_backoff = validate_float(drop_max_backoff, "drop_max_backoff", min_val=0.0)

        self._resolver = PartitionRelationResolver(engine)
        self._fk_manager = PartitionForeignKeyManager(engine, ddl_timeout_seconds)
        self._creator = PartitionCreator(
            engine=engine,
            ddl_timeout=ddl_timeout_seconds,
            ddl_timezone=self._ddl_timezone,
        )
        self._remover = PartitionRemover(
            engine=engine,
            ddl_timeout=ddl_timeout_seconds,
            drop_lock_timeout_ms=drop_lock_timeout_ms,
            drop_max_retries=drop_max_retries,
            drop_retry_delay=drop_retry_delay,
            drop_max_backoff=drop_max_backoff,
            marker_prefix=marker_prefix,
            resolver=self._resolver,
            fk_manager=self._fk_manager,
            allow_unmanaged=bool(drop_allow_unmanaged),
        )

    @property
    def ddl_timezone(self) -> str | None:
        """Timezone applied via ``SET LOCAL TIME ZONE`` around boundary-sensitive DDL.

        ``None`` means the session timezone is trusted as-is.
        """
        return self._ddl_timezone

    async def create_partition(
        self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
    ) -> PartitionInfo:
        return await self._creator.create(config, partition_name, from_value, to_value)

    async def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
        await self._creator.attach(table_name, partition_name, from_value, to_value)

    async def create_branch(
        self,
        config: TablePartitionConfig,
        branch_name: str,
        from_value: str,
        to_value: str,
        spec: SubpartitionSpec,
    ) -> PartitionInfo:
        """Create a detached time partition that is itself partitioned.

        See :meth:`PartitionCreator.create_branch`. Its buckets are created
        separately and the branch is attached last, so an interrupted run can
        never leave a partially-covering branch reachable from the root.
        """
        return await self._creator.create_branch(config, branch_name, from_value, to_value, spec)

    async def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
        """Create a detached table shaped like ``parent_name``.

        See :meth:`PartitionCreator.create_subpartition_table`.
        """
        await self._creator.create_subpartition_table(parent_name, child_name, spec)

    async def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
        """Attach one subpartition to its parent.

        See :meth:`PartitionCreator.attach_subpartition`.
        """
        await self._creator.attach_subpartition(parent_name, child_name, bounds)

    async def attach_composite_partition(
        self,
        table_name: str,
        partition_name: str,
        from_value: str,
        to_value: str,
        *,
        key_arity: int,
    ) -> None:
        """Attach a partition to a parent with a composite partition key.

        See :meth:`PartitionCreator.attach_composite_partition`.
        """
        await self._creator.attach_composite_partition(
            table_name, partition_name, from_value, to_value, key_arity=key_arity
        )

    async def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
        await self._remover.detach(table_name, partition_name, concurrent=concurrent)

    async def drop_partition(self, partition_name: str) -> None:
        await self._remover.drop(partition_name)

    async def adopt_partition(self, table_name: str, partition_name: str) -> bool:
        """Mark a detached legacy table as owned by this library (orphan marker).

        See :meth:`PartitionRemover.adopt`. Use once when migrating an existing
        partitioner instead of enabling ``drop_allow_unmanaged``.
        """
        return await self._remover.adopt(table_name, partition_name)

    async def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        trailing_columns: tuple[str, ...] = (),
        from_value: str,
        to_value: str,
    ) -> int:
        return await self._creator.reconcile_default_rows(
            default_partition_name=default_partition_name,
            target_partition_name=target_partition_name,
            partition_column=partition_column,
            trailing_columns=trailing_columns,
            from_value=from_value,
            to_value=to_value,
        )

ddl_timezone property

Timezone applied via SET LOCAL TIME ZONE around boundary-sensitive DDL.

None means the session timezone is trusted as-is.

adopt_partition(table_name, partition_name) async

Mark a detached legacy table as owned by this library (orphan marker).

See :meth:PartitionRemover.adopt. Use once when migrating an existing partitioner instead of enabling drop_allow_unmanaged.

Source code in pg_partsmith/aio/repositories/repository.py
async def adopt_partition(self, table_name: str, partition_name: str) -> bool:
    """Mark a detached legacy table as owned by this library (orphan marker).

    See :meth:`PartitionRemover.adopt`. Use once when migrating an existing
    partitioner instead of enabling ``drop_allow_unmanaged``.
    """
    return await self._remover.adopt(table_name, partition_name)

attach_composite_partition(table_name, partition_name, from_value, to_value, *, key_arity) async

Attach a partition to a parent with a composite partition key.

See :meth:PartitionCreator.attach_composite_partition.

Source code in pg_partsmith/aio/repositories/repository.py
async def attach_composite_partition(
    self,
    table_name: str,
    partition_name: str,
    from_value: str,
    to_value: str,
    *,
    key_arity: int,
) -> None:
    """Attach a partition to a parent with a composite partition key.

    See :meth:`PartitionCreator.attach_composite_partition`.
    """
    await self._creator.attach_composite_partition(
        table_name, partition_name, from_value, to_value, key_arity=key_arity
    )

attach_subpartition(parent_name, child_name, bounds) async

Attach one subpartition to its parent.

See :meth:PartitionCreator.attach_subpartition.

Source code in pg_partsmith/aio/repositories/repository.py
async def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
    """Attach one subpartition to its parent.

    See :meth:`PartitionCreator.attach_subpartition`.
    """
    await self._creator.attach_subpartition(parent_name, child_name, bounds)

create_branch(config, branch_name, from_value, to_value, spec) async

Create a detached time partition that is itself partitioned.

See :meth:PartitionCreator.create_branch. Its buckets are created separately and the branch is attached last, so an interrupted run can never leave a partially-covering branch reachable from the root.

Source code in pg_partsmith/aio/repositories/repository.py
async def create_branch(
    self,
    config: TablePartitionConfig,
    branch_name: str,
    from_value: str,
    to_value: str,
    spec: SubpartitionSpec,
) -> PartitionInfo:
    """Create a detached time partition that is itself partitioned.

    See :meth:`PartitionCreator.create_branch`. Its buckets are created
    separately and the branch is attached last, so an interrupted run can
    never leave a partially-covering branch reachable from the root.
    """
    return await self._creator.create_branch(config, branch_name, from_value, to_value, spec)

create_subpartition_table(parent_name, child_name, spec) async

Create a detached table shaped like parent_name.

See :meth:PartitionCreator.create_subpartition_table.

Source code in pg_partsmith/aio/repositories/repository.py
async def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
    """Create a detached table shaped like ``parent_name``.

    See :meth:`PartitionCreator.create_subpartition_table`.
    """
    await self._creator.create_subpartition_table(parent_name, child_name, spec)

Provider for PostgreSQL partition metadata.

Queries pg_catalog to retrieve information about table partitioning. Override any method to customise catalog queries for your schema setup.

Each method opens its own read-only connection from the engine pool so it is safe to call outside any existing transaction.

Source code in pg_partsmith/aio/metadata.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
class PostgresMetadataProvider:
    """Provider for PostgreSQL partition metadata.

    Queries pg_catalog to retrieve information about table partitioning.
    Override any method to customise catalog queries for your schema setup.

    Each method opens its own read-only connection from the engine pool so it
    is safe to call outside any existing transaction.
    """

    def __init__(
        self,
        engine: AsyncEngine,
        *,
        marker_prefix: str | None = None,
        boundary_codec: RangeBoundaryCodec | None = None,
        ddl_timezone: str | None = None,
    ) -> None:
        """Initialize provider.

        Args:
            engine: SQLAlchemy async engine.
            marker_prefix: Optional COMMENT marker prefix for orphaned partitions.
                When None, the library default prefix is used. Pass the same
                value to both repository and metadata provider if you override it.
            ddl_timezone: Session timezone to read naive boundary literals in.
                Pass whatever the repository writes partitions with: a
                ``timestamp``/``date`` key renders its bounds without an offset,
                so reader and writer must agree or a partition is reported
                closed at the wrong moment. A ``timestamptz`` key is unaffected
                -- its literals carry an offset.
            boundary_codec: Codec used to read boundary literals back into
                instants. Required only when the partition key is an encoded
                identifier rather than a timestamp; pass the same codec the
                period calculator was built with.
        """
        self._engine = engine
        self._marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
        self._boundary_codec = boundary_codec
        self._ddl_timezone = ddl_timezone

    async def get_partition_type(self, table_name: str) -> PartitionType | None:
        """Get partition type for a table."""
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(
                    """
                    SELECT partstrat
                    FROM pg_partitioned_table t
                    WHERE t.partrelid = to_regclass(:table_name)
                    """
                ),
                {"table_name": to_regclass_argument(table_name)},
            )
            strat = coerce_str(result.scalar(), encoding="ascii")

        return PartitionType.from_partstrat(strat)

    async def get_partition_column(self, table_name: str) -> str | None:
        """Get partition column for a table.

        Raises:
            ValueError: If the table uses a composite (multi-column) partition
                key.  Only single-column keys are supported by this library.
        """
        key = await self._read_partition_key(table_name)
        if not key:
            return None

        if len(key) > 1:
            msg = (
                f"Table {table_name!r} uses a composite partition key {list(key)!r}. "
                "Only single-column partition keys are supported."
            )
            raise ValueError(msg)

        if key[0] is None:
            raise ValueError(_expression_key_message(table_name, 1))

        return key[0]

    async def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
        """Return a table's own partition key columns, in key order.

        Unlike :meth:`get_partition_column`, which predates composite keys and
        refuses them, this reports the whole key. Key order is not column
        order, so it comes from ``partattrs``' own ordering.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            The key columns in order; empty when the table is not partitioned.

        Raises:
            InvalidPartitionConfigError: If any key position is an expression
                rather than a column, which this library cannot address.
        """
        key = await self._read_partition_key(table_name)
        for position, column in enumerate(key, start=1):
            if column is None:
                raise InvalidPartitionConfigError(_expression_key_message(table_name, position))

        return tuple(column for column in key if column is not None)

    async def _read_partition_key(self, table_name: str) -> tuple[str | None, ...]:
        """Read a table's partition key in key order, expressions included as None."""
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(PARTITION_COLUMNS_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        return tuple(coerce_str(row[0]) for row in rows)

    async def list_partitions(self, table_name: str) -> list[PartitionInfo]:
        """List all partitions for a table, including orphaned detached ones.

        Orphaned partitions are detached-but-not-dropped tables previously
        detached by this library. They are detected by a COMMENT marker set on
        successful detach and returned with ``is_attached=False`` and ``None``
        boundaries.

        Partition names are always schema-qualified with the child's catalog
        schema — a partition may live in a different schema than its parent,
        and a bare name could resolve to an unrelated table via ``search_path``.
        """
        async with self._engine.connect() as conn:
            parent_info_result = await conn.execute(
                text(
                    """
                    SELECT
                        pt.partstrat,
                        ns.nspname || '.' || c.relname AS qualified_name
                    FROM pg_class c
                    JOIN pg_namespace ns ON c.relnamespace = ns.oid
                    LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
                    WHERE c.oid = to_regclass(:name)
                    """
                ),
                {"name": to_regclass_argument(table_name)},
            )
            parent_row = parent_info_result.fetchone()
            if not parent_row:
                return []

            strat = coerce_str(parent_row[0], encoding="ascii")
            parent_qualified = coerce_str(parent_row[1]) or table_name

            partition_type = PartitionType.from_partstrat(strat)
            if not partition_type:
                return []

            attached_result = await conn.execute(
                text(
                    """
                    SELECT
                        ns.nspname AS partition_schema,
                        child.relname AS partition_name,
                        pg_get_expr(child.relpartbound, child.oid) AS boundaries,
                        child.relispartition AS is_attached,
                        child_pt.partstrat AS subpartstrat
                    FROM pg_inherits inh
                    JOIN pg_class child ON inh.inhrelid = child.oid
                    JOIN pg_namespace ns ON child.relnamespace = ns.oid
                    LEFT JOIN pg_partitioned_table child_pt ON child_pt.partrelid = child.oid
                    WHERE inh.inhparent = to_regclass(:table_name)
                    ORDER BY ns.nspname, child.relname
                    """
                ),
                {"table_name": to_regclass_argument(table_name)},
            )
            attached_rows = attached_result.fetchall()

            orphan_result = await conn.execute(
                text(
                    """
                    SELECT
                        ns.nspname AS partition_schema,
                        c.relname AS partition_name
                    FROM pg_class c
                    JOIN pg_namespace ns ON c.relnamespace = ns.oid
                    JOIN pg_description d
                      ON d.objoid = c.oid
                     AND d.classoid = 'pg_class'::regclass
                     AND d.objsubid = 0
                    WHERE c.relkind IN ('r', 'p')
                      AND c.relispartition = false
                      AND split_part(d.description, E'\\n', 1) = :marker
                       AND NOT EXISTS (
                           SELECT 1
                           FROM pg_inherits inh
                           WHERE inh.inhrelid = c.oid
                       )
                    ORDER BY ns.nspname, c.relname
                    """
                ),
                {
                    "marker": orphan_table_comment(parent_qualified, marker_prefix=self._marker_prefix),
                },
            )
            orphan_rows = orphan_result.fetchall()

        partitions: list[PartitionInfo] = []

        for row in attached_rows:
            relname = coerce_str(row.partition_name) or ""
            part_schema = coerce_str(row.partition_schema) or ""

            if not is_addressable(part_schema, relname):
                continue

            name = qualify(part_schema, relname)

            boundaries_str = coerce_str(row.boundaries) or ""
            is_default = boundaries_str.strip().upper() == "DEFAULT"
            from_val, to_val = (None, None) if is_default else self._parse_boundaries(boundaries_str)
            partitions.append(
                PartitionInfo(
                    name=name,
                    partition_type=partition_type,
                    from_value=from_val,
                    to_value=to_val,
                    boundaries_expr=boundaries_str if boundaries_str else None,
                    bounds=parse_partition_bounds(boundaries_str),
                    is_attached=row.is_attached,
                    is_default=is_default,
                    subpartition_type=PartitionType.from_partstrat(coerce_str(row.subpartstrat, encoding="ascii")),
                    parent_table=table_name,
                )
            )

        for row in orphan_rows:
            relname = coerce_str(row.partition_name) or ""
            part_schema = coerce_str(row.partition_schema) or ""

            if not is_addressable(part_schema, relname):
                continue

            name = qualify(part_schema, relname)
            partitions.append(
                PartitionInfo(
                    name=name,
                    partition_type=partition_type,
                    from_value=None,
                    to_value=None,
                    is_attached=False,
                    parent_table=table_name,
                )
            )

        return partitions

    async def partition_exists(self, partition_name: str) -> bool:
        """Check if a partition table exists in pg_class.

        Args:
            partition_name: Partition table name.

        Returns:
            True if the table exists as a regular or partitioned table
            (a partition may itself be subpartitioned).
        """
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(RELATION_EXISTS_SQL),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            return bool(result.scalar())

    async def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
        """Check if a partition is currently attached to its parent via pg_inherits.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.

        Returns:
            True if the partition is attached.
        """
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(PARTITION_IS_ATTACHED_SQL),
                {
                    "table_name": to_regclass_argument(table_name),
                    "partition_name": to_regclass_argument(partition_name),
                },
            )
            return bool(result.scalar())

    async def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
        """Get partition boundaries.

        Args:
            partition_name: Partition table name.

        Returns:
            Tuple of (from_value, to_value) or None if not a range partition.
        """
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(
                    """
                    SELECT pg_get_expr(relpartbound, oid)
                    FROM pg_class
                    WHERE oid = to_regclass(:partition_name)
                    """
                ),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            boundaries_expr = coerce_str(result.scalar())

        if not boundaries_expr:
            return None

        from_val, to_val = self._parse_boundaries(boundaries_expr)
        if from_val is not None and to_val is not None:
            return from_val, to_val

        return None

    def _parse_boundaries(self, boundaries_expr: str | None) -> tuple[str | None, str | None]:
        """Delegate to :func:`pg_partsmith.partition_bounds.parse_range_boundaries`; override to customise parsing."""
        return parse_range_boundaries(boundaries_expr)

    async def is_partition_closed(self, partition_name: str, *, settle_seconds: int = 0) -> bool:
        """True when the partition's upper bound (+ settle buffer) has passed.

        ``now()`` is evaluated on the server rather than on the client, so the
        answer tolerates app-clock skew. Useful for export/archive pipelines
        that must only finalize partitions which can no longer receive
        in-range rows.

        Works for a subpartitioned branch exactly as for a plain leaf: what is
        read is the branch's own RANGE bound in the root table, and its whole
        subtree closes with it.

        A naive bound -- which is what a ``timestamp`` or ``date`` key produces
        -- is resolved under this provider's ``ddl_timezone``. Configure it with
        the same value the repository writes partitions with, or the two
        disagree about when the bound falls.

        Args:
            partition_name: Attached partition table name.
            settle_seconds: Extra buffer after the upper bound for late writers
                still holding open transactions.

        Returns:
            True when ``now() >= upper_bound + settle_seconds``. False for the
            DEFAULT partition, non-RANGE partitions, unbounded upper bounds
            (MAXVALUE / infinity), detached tables, unresolvable names, and
            boundaries that carry no instant this provider can read.
        """
        async with self._engine.connect() as conn:
            if self._ddl_timezone is not None:
                # A naive bound is resolved by the session timezone, so this has
                # to be the one the partition was written with. Without it the
                # server default decides, and the two need not agree.
                await conn.execute(text(f"SET LOCAL TIME ZONE {quote_literal(self._ddl_timezone)}"))

            bound_result = await conn.execute(
                text(PARTITION_UPPER_BOUND_SQL),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            raw_bound = coerce_str(bound_result.scalar())
            if raw_bound is None:
                # No upper bound to read: DEFAULT, non-RANGE, detached, or unknown.
                return False

            if self._boundary_codec is not None:
                instant = self._boundary_codec.decode(raw_bound)
                if instant is None:
                    self._warn_unreadable_bound(partition_name, raw_bound)
                    return False
                query = INSTANT_HAS_PASSED_SQL
                upper_bound: datetime | str = instant
            else:
                query = TEXT_INSTANT_HAS_PASSED_SQL
                upper_bound = raw_bound

            try:
                result = await conn.execute(
                    text(query),
                    {"upper_bound": upper_bound, "settle_seconds": settle_seconds},
                )
            except DBAPIError:
                # A bound can look like a date and still not be one -- a
                # sortable identifier with a date-like prefix, say. Reporting
                # "not closed" is the documented answer; raising out of a
                # predicate is not.
                self._warn_unreadable_bound(partition_name, raw_bound)
                return False

            return bool(result.scalar())

    def _warn_unreadable_bound(self, partition_name: str, raw_bound: str) -> None:
        """Explain a partition that can never report as closed.

        The answer is always False while the bound cannot be read, so an export
        pipeline gated on this would wait forever with nothing to show for it.
        """
        logger.warning(
            "Partition has an upper bound this provider cannot read, so it never reports as closed; "
            "pass the boundary_codec its partitions were created with",
            extra={"partition_name": partition_name, "upper_bound": raw_bound},
        )

    async def get_default_partition(self, table_name: str) -> PartitionInfo | None:
        """Get DEFAULT partition for a table if it exists and is attached.

        Args:
            table_name: Parent table name.

        Returns:
            PartitionInfo with is_default=True, or None if no default partition exists.
        """
        all_partitions = await self.list_partitions(table_name)
        defaults = [p for p in all_partitions if p.is_default and p.is_attached]
        return defaults[0] if defaults else None

    async def get_partition_tree(self, table_name: str) -> PartitionNode | None:
        """Return the whole partition tree rooted at ``table_name``.

        Unlike :meth:`list_partitions`, which reports the direct children a
        lifecycle acts on, this walks the hierarchy to the leaves — the shape
        subpartition reconciliation needs to know which buckets exist. One
        round-trip regardless of depth.

        Detached partitions are absent by construction: a detached branch is no
        longer part of its parent's tree. Query it by name to inspect it.

        Args:
            table_name: Root of the tree, schema-qualified.

        Returns:
            The root node with its descendants, or None when ``table_name`` is
            not partitioned and is not itself a partition.
        """
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(PARTITION_TREE_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        tree_rows: list[PartitionTreeRow] = []
        unaddressable_parents: set[str] = set()
        for row in rows:
            schema = coerce_str(row.partition_schema) or ""
            relname = coerce_str(row.partition_name) or ""
            parent_schema_raw = coerce_str(row.parent_schema)
            parent_relname_raw = coerce_str(row.parent_name)
            if not is_addressable(schema, relname):
                # The parent keeps a child the tree cannot show. Recording that
                # is what keeps the planner from reading the shortened child set
                # as a set of gaps to fill.
                if parent_schema_raw and parent_relname_raw:
                    unaddressable_parents.add(qualify(parent_schema_raw, parent_relname_raw))
                continue

            parent_schema = coerce_str(row.parent_schema)
            parent_relname = coerce_str(row.parent_name)
            parent_name = qualify(parent_schema, parent_relname) if parent_schema and parent_relname else None

            columns = row.partition_columns or ()
            named = tuple(str(c) for c in columns if c is not None)
            tree_rows.append(
                PartitionTreeRow(
                    level=row.level,
                    name=qualify(schema, relname),
                    parent_name=parent_name,
                    bounds=parse_partition_bounds(coerce_str(row.boundaries)),
                    is_attached=bool(row.is_attached),
                    partition_type=PartitionType.from_partstrat(coerce_str(row.partstrat, encoding="ascii")),
                    partition_columns=named,
                    # An expression key position comes back as NULL and has no
                    # name to report; what matters is that the key is wider than
                    # the names, so nothing compares it as if it were complete.
                    has_expression_key=len(named) != (row.key_arity or len(named)),
                )
            )

        return build_partition_tree(tree_rows, unaddressable_parents)

    async def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
        """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

        PostgreSQL requires such a constraint on a partitioned table to contain
        all of its partition-key columns. Reading them lets a subpartitioning
        config be refused with an explanation before any DDL is attempted,
        instead of failing halfway through a maintenance run.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            One tuple of column names per constraint; empty when the table has
            no unique constraints at all.
        """
        async with self._engine.connect() as conn:
            result = await conn.execute(
                text(UNIQUE_CONSTRAINT_COLUMNS_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        return tuple(tuple(str(c) for c in (row.columns or ())) for row in rows)

__init__(engine, *, marker_prefix=None, boundary_codec=None, ddl_timezone=None)

Initialize provider.

Parameters:

Name Type Description Default
engine AsyncEngine

SQLAlchemy async engine.

required
marker_prefix str | None

Optional COMMENT marker prefix for orphaned partitions. When None, the library default prefix is used. Pass the same value to both repository and metadata provider if you override it.

None
ddl_timezone str | None

Session timezone to read naive boundary literals in. Pass whatever the repository writes partitions with: a timestamp/date key renders its bounds without an offset, so reader and writer must agree or a partition is reported closed at the wrong moment. A timestamptz key is unaffected -- its literals carry an offset.

None
boundary_codec RangeBoundaryCodec | None

Codec used to read boundary literals back into instants. Required only when the partition key is an encoded identifier rather than a timestamp; pass the same codec the period calculator was built with.

None
Source code in pg_partsmith/aio/metadata.py
def __init__(
    self,
    engine: AsyncEngine,
    *,
    marker_prefix: str | None = None,
    boundary_codec: RangeBoundaryCodec | None = None,
    ddl_timezone: str | None = None,
) -> None:
    """Initialize provider.

    Args:
        engine: SQLAlchemy async engine.
        marker_prefix: Optional COMMENT marker prefix for orphaned partitions.
            When None, the library default prefix is used. Pass the same
            value to both repository and metadata provider if you override it.
        ddl_timezone: Session timezone to read naive boundary literals in.
            Pass whatever the repository writes partitions with: a
            ``timestamp``/``date`` key renders its bounds without an offset,
            so reader and writer must agree or a partition is reported
            closed at the wrong moment. A ``timestamptz`` key is unaffected
            -- its literals carry an offset.
        boundary_codec: Codec used to read boundary literals back into
            instants. Required only when the partition key is an encoded
            identifier rather than a timestamp; pass the same codec the
            period calculator was built with.
    """
    self._engine = engine
    self._marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
    self._boundary_codec = boundary_codec
    self._ddl_timezone = ddl_timezone

get_default_partition(table_name) async

Get DEFAULT partition for a table if it exists and is attached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
PartitionInfo | None

PartitionInfo with is_default=True, or None if no default partition exists.

Source code in pg_partsmith/aio/metadata.py
async def get_default_partition(self, table_name: str) -> PartitionInfo | None:
    """Get DEFAULT partition for a table if it exists and is attached.

    Args:
        table_name: Parent table name.

    Returns:
        PartitionInfo with is_default=True, or None if no default partition exists.
    """
    all_partitions = await self.list_partitions(table_name)
    defaults = [p for p in all_partitions if p.is_default and p.is_attached]
    return defaults[0] if defaults else None

get_partition_boundaries(partition_name) async

Get partition boundaries.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
tuple[str, str] | None

Tuple of (from_value, to_value) or None if not a range partition.

Source code in pg_partsmith/aio/metadata.py
async def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
    """Get partition boundaries.

    Args:
        partition_name: Partition table name.

    Returns:
        Tuple of (from_value, to_value) or None if not a range partition.
    """
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(
                """
                SELECT pg_get_expr(relpartbound, oid)
                FROM pg_class
                WHERE oid = to_regclass(:partition_name)
                """
            ),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        boundaries_expr = coerce_str(result.scalar())

    if not boundaries_expr:
        return None

    from_val, to_val = self._parse_boundaries(boundaries_expr)
    if from_val is not None and to_val is not None:
        return from_val, to_val

    return None

get_partition_column(table_name) async

Get partition column for a table.

Raises:

Type Description
ValueError

If the table uses a composite (multi-column) partition key. Only single-column keys are supported by this library.

Source code in pg_partsmith/aio/metadata.py
async def get_partition_column(self, table_name: str) -> str | None:
    """Get partition column for a table.

    Raises:
        ValueError: If the table uses a composite (multi-column) partition
            key.  Only single-column keys are supported by this library.
    """
    key = await self._read_partition_key(table_name)
    if not key:
        return None

    if len(key) > 1:
        msg = (
            f"Table {table_name!r} uses a composite partition key {list(key)!r}. "
            "Only single-column partition keys are supported."
        )
        raise ValueError(msg)

    if key[0] is None:
        raise ValueError(_expression_key_message(table_name, 1))

    return key[0]

get_partition_columns(table_name) async

Return a table's own partition key columns, in key order.

Unlike :meth:get_partition_column, which predates composite keys and refuses them, this reports the whole key. Key order is not column order, so it comes from partattrs' own ordering.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

The key columns in order; empty when the table is not partitioned.

Raises:

Type Description
InvalidPartitionConfigError

If any key position is an expression rather than a column, which this library cannot address.

Source code in pg_partsmith/aio/metadata.py
async def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
    """Return a table's own partition key columns, in key order.

    Unlike :meth:`get_partition_column`, which predates composite keys and
    refuses them, this reports the whole key. Key order is not column
    order, so it comes from ``partattrs``' own ordering.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        The key columns in order; empty when the table is not partitioned.

    Raises:
        InvalidPartitionConfigError: If any key position is an expression
            rather than a column, which this library cannot address.
    """
    key = await self._read_partition_key(table_name)
    for position, column in enumerate(key, start=1):
        if column is None:
            raise InvalidPartitionConfigError(_expression_key_message(table_name, position))

    return tuple(column for column in key if column is not None)

get_partition_tree(table_name) async

Return the whole partition tree rooted at table_name.

Unlike :meth:list_partitions, which reports the direct children a lifecycle acts on, this walks the hierarchy to the leaves — the shape subpartition reconciliation needs to know which buckets exist. One round-trip regardless of depth.

Detached partitions are absent by construction: a detached branch is no longer part of its parent's tree. Query it by name to inspect it.

Parameters:

Name Type Description Default
table_name str

Root of the tree, schema-qualified.

required

Returns:

Type Description
PartitionNode | None

The root node with its descendants, or None when table_name is

PartitionNode | None

not partitioned and is not itself a partition.

Source code in pg_partsmith/aio/metadata.py
async def get_partition_tree(self, table_name: str) -> PartitionNode | None:
    """Return the whole partition tree rooted at ``table_name``.

    Unlike :meth:`list_partitions`, which reports the direct children a
    lifecycle acts on, this walks the hierarchy to the leaves — the shape
    subpartition reconciliation needs to know which buckets exist. One
    round-trip regardless of depth.

    Detached partitions are absent by construction: a detached branch is no
    longer part of its parent's tree. Query it by name to inspect it.

    Args:
        table_name: Root of the tree, schema-qualified.

    Returns:
        The root node with its descendants, or None when ``table_name`` is
        not partitioned and is not itself a partition.
    """
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(PARTITION_TREE_SQL),
            {"table_name": to_regclass_argument(table_name)},
        )
        rows = result.fetchall()

    tree_rows: list[PartitionTreeRow] = []
    unaddressable_parents: set[str] = set()
    for row in rows:
        schema = coerce_str(row.partition_schema) or ""
        relname = coerce_str(row.partition_name) or ""
        parent_schema_raw = coerce_str(row.parent_schema)
        parent_relname_raw = coerce_str(row.parent_name)
        if not is_addressable(schema, relname):
            # The parent keeps a child the tree cannot show. Recording that
            # is what keeps the planner from reading the shortened child set
            # as a set of gaps to fill.
            if parent_schema_raw and parent_relname_raw:
                unaddressable_parents.add(qualify(parent_schema_raw, parent_relname_raw))
            continue

        parent_schema = coerce_str(row.parent_schema)
        parent_relname = coerce_str(row.parent_name)
        parent_name = qualify(parent_schema, parent_relname) if parent_schema and parent_relname else None

        columns = row.partition_columns or ()
        named = tuple(str(c) for c in columns if c is not None)
        tree_rows.append(
            PartitionTreeRow(
                level=row.level,
                name=qualify(schema, relname),
                parent_name=parent_name,
                bounds=parse_partition_bounds(coerce_str(row.boundaries)),
                is_attached=bool(row.is_attached),
                partition_type=PartitionType.from_partstrat(coerce_str(row.partstrat, encoding="ascii")),
                partition_columns=named,
                # An expression key position comes back as NULL and has no
                # name to report; what matters is that the key is wider than
                # the names, so nothing compares it as if it were complete.
                has_expression_key=len(named) != (row.key_arity or len(named)),
            )
        )

    return build_partition_tree(tree_rows, unaddressable_parents)

get_partition_type(table_name) async

Get partition type for a table.

Source code in pg_partsmith/aio/metadata.py
async def get_partition_type(self, table_name: str) -> PartitionType | None:
    """Get partition type for a table."""
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(
                """
                SELECT partstrat
                FROM pg_partitioned_table t
                WHERE t.partrelid = to_regclass(:table_name)
                """
            ),
            {"table_name": to_regclass_argument(table_name)},
        )
        strat = coerce_str(result.scalar(), encoding="ascii")

    return PartitionType.from_partstrat(strat)

get_unique_constraint_columns(table_name) async

Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

PostgreSQL requires such a constraint on a partitioned table to contain all of its partition-key columns. Reading them lets a subpartitioning config be refused with an explanation before any DDL is attempted, instead of failing halfway through a maintenance run.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

One tuple of column names per constraint; empty when the table has

...

no unique constraints at all.

Source code in pg_partsmith/aio/metadata.py
async def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
    """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

    PostgreSQL requires such a constraint on a partitioned table to contain
    all of its partition-key columns. Reading them lets a subpartitioning
    config be refused with an explanation before any DDL is attempted,
    instead of failing halfway through a maintenance run.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        One tuple of column names per constraint; empty when the table has
        no unique constraints at all.
    """
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(UNIQUE_CONSTRAINT_COLUMNS_SQL),
            {"table_name": to_regclass_argument(table_name)},
        )
        rows = result.fetchall()

    return tuple(tuple(str(c) for c in (row.columns or ())) for row in rows)

is_partition_attached(table_name, partition_name) async

Check if a partition is currently attached to its parent via pg_inherits.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the partition is attached.

Source code in pg_partsmith/aio/metadata.py
async def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
    """Check if a partition is currently attached to its parent via pg_inherits.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.

    Returns:
        True if the partition is attached.
    """
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(PARTITION_IS_ATTACHED_SQL),
            {
                "table_name": to_regclass_argument(table_name),
                "partition_name": to_regclass_argument(partition_name),
            },
        )
        return bool(result.scalar())

is_partition_closed(partition_name, *, settle_seconds=0) async

True when the partition's upper bound (+ settle buffer) has passed.

now() is evaluated on the server rather than on the client, so the answer tolerates app-clock skew. Useful for export/archive pipelines that must only finalize partitions which can no longer receive in-range rows.

Works for a subpartitioned branch exactly as for a plain leaf: what is read is the branch's own RANGE bound in the root table, and its whole subtree closes with it.

A naive bound -- which is what a timestamp or date key produces -- is resolved under this provider's ddl_timezone. Configure it with the same value the repository writes partitions with, or the two disagree about when the bound falls.

Parameters:

Name Type Description Default
partition_name str

Attached partition table name.

required
settle_seconds int

Extra buffer after the upper bound for late writers still holding open transactions.

0

Returns:

Type Description
bool

True when now() >= upper_bound + settle_seconds. False for the

bool

DEFAULT partition, non-RANGE partitions, unbounded upper bounds

bool

(MAXVALUE / infinity), detached tables, unresolvable names, and

bool

boundaries that carry no instant this provider can read.

Source code in pg_partsmith/aio/metadata.py
async def is_partition_closed(self, partition_name: str, *, settle_seconds: int = 0) -> bool:
    """True when the partition's upper bound (+ settle buffer) has passed.

    ``now()`` is evaluated on the server rather than on the client, so the
    answer tolerates app-clock skew. Useful for export/archive pipelines
    that must only finalize partitions which can no longer receive
    in-range rows.

    Works for a subpartitioned branch exactly as for a plain leaf: what is
    read is the branch's own RANGE bound in the root table, and its whole
    subtree closes with it.

    A naive bound -- which is what a ``timestamp`` or ``date`` key produces
    -- is resolved under this provider's ``ddl_timezone``. Configure it with
    the same value the repository writes partitions with, or the two
    disagree about when the bound falls.

    Args:
        partition_name: Attached partition table name.
        settle_seconds: Extra buffer after the upper bound for late writers
            still holding open transactions.

    Returns:
        True when ``now() >= upper_bound + settle_seconds``. False for the
        DEFAULT partition, non-RANGE partitions, unbounded upper bounds
        (MAXVALUE / infinity), detached tables, unresolvable names, and
        boundaries that carry no instant this provider can read.
    """
    async with self._engine.connect() as conn:
        if self._ddl_timezone is not None:
            # A naive bound is resolved by the session timezone, so this has
            # to be the one the partition was written with. Without it the
            # server default decides, and the two need not agree.
            await conn.execute(text(f"SET LOCAL TIME ZONE {quote_literal(self._ddl_timezone)}"))

        bound_result = await conn.execute(
            text(PARTITION_UPPER_BOUND_SQL),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        raw_bound = coerce_str(bound_result.scalar())
        if raw_bound is None:
            # No upper bound to read: DEFAULT, non-RANGE, detached, or unknown.
            return False

        if self._boundary_codec is not None:
            instant = self._boundary_codec.decode(raw_bound)
            if instant is None:
                self._warn_unreadable_bound(partition_name, raw_bound)
                return False
            query = INSTANT_HAS_PASSED_SQL
            upper_bound: datetime | str = instant
        else:
            query = TEXT_INSTANT_HAS_PASSED_SQL
            upper_bound = raw_bound

        try:
            result = await conn.execute(
                text(query),
                {"upper_bound": upper_bound, "settle_seconds": settle_seconds},
            )
        except DBAPIError:
            # A bound can look like a date and still not be one -- a
            # sortable identifier with a date-like prefix, say. Reporting
            # "not closed" is the documented answer; raising out of a
            # predicate is not.
            self._warn_unreadable_bound(partition_name, raw_bound)
            return False

        return bool(result.scalar())

list_partitions(table_name) async

List all partitions for a table, including orphaned detached ones.

Orphaned partitions are detached-but-not-dropped tables previously detached by this library. They are detected by a COMMENT marker set on successful detach and returned with is_attached=False and None boundaries.

Partition names are always schema-qualified with the child's catalog schema — a partition may live in a different schema than its parent, and a bare name could resolve to an unrelated table via search_path.

Source code in pg_partsmith/aio/metadata.py
async def list_partitions(self, table_name: str) -> list[PartitionInfo]:
    """List all partitions for a table, including orphaned detached ones.

    Orphaned partitions are detached-but-not-dropped tables previously
    detached by this library. They are detected by a COMMENT marker set on
    successful detach and returned with ``is_attached=False`` and ``None``
    boundaries.

    Partition names are always schema-qualified with the child's catalog
    schema — a partition may live in a different schema than its parent,
    and a bare name could resolve to an unrelated table via ``search_path``.
    """
    async with self._engine.connect() as conn:
        parent_info_result = await conn.execute(
            text(
                """
                SELECT
                    pt.partstrat,
                    ns.nspname || '.' || c.relname AS qualified_name
                FROM pg_class c
                JOIN pg_namespace ns ON c.relnamespace = ns.oid
                LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
                WHERE c.oid = to_regclass(:name)
                """
            ),
            {"name": to_regclass_argument(table_name)},
        )
        parent_row = parent_info_result.fetchone()
        if not parent_row:
            return []

        strat = coerce_str(parent_row[0], encoding="ascii")
        parent_qualified = coerce_str(parent_row[1]) or table_name

        partition_type = PartitionType.from_partstrat(strat)
        if not partition_type:
            return []

        attached_result = await conn.execute(
            text(
                """
                SELECT
                    ns.nspname AS partition_schema,
                    child.relname AS partition_name,
                    pg_get_expr(child.relpartbound, child.oid) AS boundaries,
                    child.relispartition AS is_attached,
                    child_pt.partstrat AS subpartstrat
                FROM pg_inherits inh
                JOIN pg_class child ON inh.inhrelid = child.oid
                JOIN pg_namespace ns ON child.relnamespace = ns.oid
                LEFT JOIN pg_partitioned_table child_pt ON child_pt.partrelid = child.oid
                WHERE inh.inhparent = to_regclass(:table_name)
                ORDER BY ns.nspname, child.relname
                """
            ),
            {"table_name": to_regclass_argument(table_name)},
        )
        attached_rows = attached_result.fetchall()

        orphan_result = await conn.execute(
            text(
                """
                SELECT
                    ns.nspname AS partition_schema,
                    c.relname AS partition_name
                FROM pg_class c
                JOIN pg_namespace ns ON c.relnamespace = ns.oid
                JOIN pg_description d
                  ON d.objoid = c.oid
                 AND d.classoid = 'pg_class'::regclass
                 AND d.objsubid = 0
                WHERE c.relkind IN ('r', 'p')
                  AND c.relispartition = false
                  AND split_part(d.description, E'\\n', 1) = :marker
                   AND NOT EXISTS (
                       SELECT 1
                       FROM pg_inherits inh
                       WHERE inh.inhrelid = c.oid
                   )
                ORDER BY ns.nspname, c.relname
                """
            ),
            {
                "marker": orphan_table_comment(parent_qualified, marker_prefix=self._marker_prefix),
            },
        )
        orphan_rows = orphan_result.fetchall()

    partitions: list[PartitionInfo] = []

    for row in attached_rows:
        relname = coerce_str(row.partition_name) or ""
        part_schema = coerce_str(row.partition_schema) or ""

        if not is_addressable(part_schema, relname):
            continue

        name = qualify(part_schema, relname)

        boundaries_str = coerce_str(row.boundaries) or ""
        is_default = boundaries_str.strip().upper() == "DEFAULT"
        from_val, to_val = (None, None) if is_default else self._parse_boundaries(boundaries_str)
        partitions.append(
            PartitionInfo(
                name=name,
                partition_type=partition_type,
                from_value=from_val,
                to_value=to_val,
                boundaries_expr=boundaries_str if boundaries_str else None,
                bounds=parse_partition_bounds(boundaries_str),
                is_attached=row.is_attached,
                is_default=is_default,
                subpartition_type=PartitionType.from_partstrat(coerce_str(row.subpartstrat, encoding="ascii")),
                parent_table=table_name,
            )
        )

    for row in orphan_rows:
        relname = coerce_str(row.partition_name) or ""
        part_schema = coerce_str(row.partition_schema) or ""

        if not is_addressable(part_schema, relname):
            continue

        name = qualify(part_schema, relname)
        partitions.append(
            PartitionInfo(
                name=name,
                partition_type=partition_type,
                from_value=None,
                to_value=None,
                is_attached=False,
                parent_table=table_name,
            )
        )

    return partitions

partition_exists(partition_name) async

Check if a partition table exists in pg_class.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the table exists as a regular or partitioned table

bool

(a partition may itself be subpartitioned).

Source code in pg_partsmith/aio/metadata.py
async def partition_exists(self, partition_name: str) -> bool:
    """Check if a partition table exists in pg_class.

    Args:
        partition_name: Partition table name.

    Returns:
        True if the table exists as a regular or partitioned table
        (a partition may itself be subpartitioned).
    """
    async with self._engine.connect() as conn:
        result = await conn.execute(
            text(RELATION_EXISTS_SQL),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        return bool(result.scalar())

Lock managers

Lock manager using PostgreSQL advisory locks.

Holds the advisory lock on a dedicated AUTOCOMMIT connection from the engine pool. This guarantees the lock survives any number of commits or rollbacks on the caller's session, which is required when the caller needs to commit DDL (e.g. ATTACH PARTITION) before running DETACH PARTITION CONCURRENTLY.

Override _compute_lock_id to customise the lock ID derivation.

Source code in pg_partsmith/aio/lock/postgres.py
class PostgresAdvisoryLockManager:
    """Lock manager using PostgreSQL advisory locks.

    Holds the advisory lock on a dedicated AUTOCOMMIT connection from the
    engine pool. This guarantees the lock survives any number of commits or
    rollbacks on the caller's session, which is required when the caller
    needs to commit DDL (e.g. ATTACH PARTITION) before running
    DETACH PARTITION CONCURRENTLY.

    Override `_compute_lock_id` to customise the lock ID derivation.
    """

    def __init__(
        self,
        engine: AsyncEngine,
        prefix: str = DEFAULT_LOCK_PREFIX,
        acquire_min_interval_seconds: float = 0.0,
    ) -> None:
        """Initialize lock manager.

        Args:
            engine: SQLAlchemy async engine used to open a dedicated connection
                for the advisory lock.
            prefix: Prefix for lock key generation.
            acquire_min_interval_seconds: Minimum seconds between acquire attempts
                per table (rate limiting). 0 disables.
        """
        self._engine = engine
        self._prefix = prefix
        self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
        self._last_acquire_time: dict[str, float] = {}
        self._rate_limit_lock = asyncio.Lock()

    def _compute_lock_id(self, table_name: str) -> int:
        """Compute the advisory lock ID for a table name.

        Override this method to customise the ID derivation strategy.

        Args:
            table_name: Table name to lock.

        Returns:
            Advisory lock ID.
        """
        return calculate_lock_id(table_name, prefix=self._prefix)

    def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
        """Acquire advisory lock for a table.

        Opens a dedicated AUTOCOMMIT connection from the engine pool and
        acquires a session-level advisory lock on it. The lock is released
        when the context manager exits, with cancellation-safe cleanup.

        Args:
            table_name: Table name to lock.

        Returns:
            Async context manager for the lock.

        Raises:
            LockAcquisitionError: If the lock cannot be acquired.
        """
        return self._lock_scope(table_name)

    @asynccontextmanager
    async def _lock_scope(self, table_name: str) -> AsyncIterator[None]:
        """Internal acquire/release flow for a single advisory lock."""
        await self._respect_rate_limit(table_name)
        lock_id = self._compute_lock_id(table_name)

        async with self._engine.connect() as base_conn:
            conn = await base_conn.execution_options(isolation_level="AUTOCOMMIT")
            await self._try_acquire(conn, lock_id, table_name)

            body_exc: BaseException | None = None
            try:
                yield
            except BaseException as exc:
                body_exc = exc
                raise
            finally:
                await self._release_safely(conn, lock_id, table_name, body_exc)

    async def _respect_rate_limit(self, table_name: str) -> None:
        """Sleep enough to enforce the configured min-interval between acquires.

        The per-table slot is reserved under the mutex; the sleep itself happens
        outside it so one table's owed delay never blocks acquires for other tables.
        """
        if self._acquire_min_interval <= 0:
            return
        async with self._rate_limit_lock:
            now = time.monotonic()
            last = self._last_acquire_time.get(table_name)
            slot = now if last is None else max(now, last + self._acquire_min_interval)
            self._last_acquire_time[table_name] = slot
        delay = slot - now
        if delay > 0:
            await asyncio.sleep(delay)

    async def _try_acquire(self, conn: AsyncConnection, lock_id: int, table_name: str) -> None:
        """Run ``pg_try_advisory_lock`` and raise if not granted."""
        result = await conn.execute(text("SELECT pg_try_advisory_lock(:lock_id)"), {"lock_id": lock_id})
        if not result.scalar():
            raise LockAcquisitionError(table_name, "advisory lock unavailable")

    async def _release_safely(
        self,
        conn: AsyncConnection,
        lock_id: int,
        table_name: str,
        body_exc: BaseException | None,
    ) -> None:
        """Release the lock; a body exception takes precedence over unlock failures.

        Shielded so cancellation cannot leak a held lock.
        """
        try:
            await asyncio.shield(self._unlock(conn, lock_id, table_name))
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            # Defensively invalidate so the connection is not returned to the pool with a dangling lock.
            with contextlib.suppress(Exception):
                await asyncio.shield(conn.invalidate())
            raise
        except Exception:
            # Body exception takes precedence; otherwise propagate the unlock failure.
            if body_exc is None:
                raise
            logger.warning(
                "Failed to release advisory lock",
                extra={"table_name": table_name, "lock_id": lock_id},
            )

    async def _unlock(self, conn: AsyncConnection, lock_id: int, table_name: str) -> None:
        """Run ``pg_advisory_unlock``; invalidate the connection on any failure."""
        try:
            await conn.execute(text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id})
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except (SQLAlchemyError, OSError) as e:
            logger.warning(
                "Failed to release advisory lock (recoverable)",
                extra={"table_name": table_name, "lock_id": lock_id, "error": str(e)},
            )
            with contextlib.suppress(Exception):
                await conn.invalidate()
            raise
        except Exception:
            logger.exception(
                "Unexpected error while releasing advisory lock",
                extra={"table_name": table_name, "lock_id": lock_id},
            )
            with contextlib.suppress(Exception):
                await conn.invalidate()
            raise

    async def is_locked(self, table_name: str) -> bool:
        """Check if lock is held by any session.

        Args:
            table_name: Table name.

        Returns:
            True if the advisory lock for the given table is currently held.
        """
        lock_id = self._compute_lock_id(table_name)
        # Split 64-bit lock_id into classid and objid as stored in pg_locks for int8 advisory locks (objsubid=1).
        class_id = (lock_id >> 32) & 0xFFFFFFFF
        if class_id > 0x7FFFFFFF:
            class_id -= 0x100000000
        obj_id = lock_id & 0xFFFFFFFF
        if obj_id > 0x7FFFFFFF:
            obj_id -= 0x100000000

        async with self._engine.connect() as base_conn:
            conn = await base_conn.execution_options(isolation_level="AUTOCOMMIT")
            result = await conn.execute(
                text(
                    """
                    SELECT count(*)
                    FROM pg_locks
                    WHERE locktype = 'advisory'
                      AND granted = true
                      AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
                      AND classid = CAST(:class_id AS int4)
                      AND objid = CAST(:obj_id AS int4)
                      AND objsubid = 1
                    """
                ),
                {"class_id": class_id, "obj_id": obj_id},
            )
            count = result.scalar()
        return bool(count is not None and count > 0)

__init__(engine, prefix=DEFAULT_LOCK_PREFIX, acquire_min_interval_seconds=0.0)

Initialize lock manager.

Parameters:

Name Type Description Default
engine AsyncEngine

SQLAlchemy async engine used to open a dedicated connection for the advisory lock.

required
prefix str

Prefix for lock key generation.

DEFAULT_LOCK_PREFIX
acquire_min_interval_seconds float

Minimum seconds between acquire attempts per table (rate limiting). 0 disables.

0.0
Source code in pg_partsmith/aio/lock/postgres.py
def __init__(
    self,
    engine: AsyncEngine,
    prefix: str = DEFAULT_LOCK_PREFIX,
    acquire_min_interval_seconds: float = 0.0,
) -> None:
    """Initialize lock manager.

    Args:
        engine: SQLAlchemy async engine used to open a dedicated connection
            for the advisory lock.
        prefix: Prefix for lock key generation.
        acquire_min_interval_seconds: Minimum seconds between acquire attempts
            per table (rate limiting). 0 disables.
    """
    self._engine = engine
    self._prefix = prefix
    self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
    self._last_acquire_time: dict[str, float] = {}
    self._rate_limit_lock = asyncio.Lock()

acquire_lock(table_name)

Acquire advisory lock for a table.

Opens a dedicated AUTOCOMMIT connection from the engine pool and acquires a session-level advisory lock on it. The lock is released when the context manager exits, with cancellation-safe cleanup.

Parameters:

Name Type Description Default
table_name str

Table name to lock.

required

Returns:

Type Description
AbstractAsyncContextManager[None]

Async context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If the lock cannot be acquired.

Source code in pg_partsmith/aio/lock/postgres.py
def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
    """Acquire advisory lock for a table.

    Opens a dedicated AUTOCOMMIT connection from the engine pool and
    acquires a session-level advisory lock on it. The lock is released
    when the context manager exits, with cancellation-safe cleanup.

    Args:
        table_name: Table name to lock.

    Returns:
        Async context manager for the lock.

    Raises:
        LockAcquisitionError: If the lock cannot be acquired.
    """
    return self._lock_scope(table_name)

is_locked(table_name) async

Check if lock is held by any session.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
bool

True if the advisory lock for the given table is currently held.

Source code in pg_partsmith/aio/lock/postgres.py
async def is_locked(self, table_name: str) -> bool:
    """Check if lock is held by any session.

    Args:
        table_name: Table name.

    Returns:
        True if the advisory lock for the given table is currently held.
    """
    lock_id = self._compute_lock_id(table_name)
    # Split 64-bit lock_id into classid and objid as stored in pg_locks for int8 advisory locks (objsubid=1).
    class_id = (lock_id >> 32) & 0xFFFFFFFF
    if class_id > 0x7FFFFFFF:
        class_id -= 0x100000000
    obj_id = lock_id & 0xFFFFFFFF
    if obj_id > 0x7FFFFFFF:
        obj_id -= 0x100000000

    async with self._engine.connect() as base_conn:
        conn = await base_conn.execution_options(isolation_level="AUTOCOMMIT")
        result = await conn.execute(
            text(
                """
                SELECT count(*)
                FROM pg_locks
                WHERE locktype = 'advisory'
                  AND granted = true
                  AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
                  AND classid = CAST(:class_id AS int4)
                  AND objid = CAST(:obj_id AS int4)
                  AND objsubid = 1
                """
            ),
            {"class_id": class_id, "obj_id": obj_id},
        )
        count = result.scalar()
    return bool(count is not None and count > 0)

Lock manager using Redis for distributed coordination.

Uses SET NX EX to acquire the lock and a background renewal task to extend the TTL while the lock is held, preventing expiry during long DDL operations (e.g. DETACH PARTITION CONCURRENTLY). The lock is released atomically via a Lua script that checks the ownership token, so it is safe even if Redis restarts during the renewal window.

The renewal interval is ttl_seconds // 3 (with random jitter to avoid thundering herds). If renewal fails — e.g. Redis is unreachable or another holder takes over — the watchdog logs a warning and cancels the holder task, forcing the maintenance run to stop (fail-safe).

For production use you may want to subclass and override acquire_lock to use Redlock or another algorithm with stronger guarantees.

Raises:

Type Description
ImportError

If the redis-locks optional dependency is not installed.

Source code in pg_partsmith/aio/lock/redis.py
class RedisDistributedLockManager:
    """Lock manager using Redis for distributed coordination.

    Uses ``SET NX EX`` to acquire the lock and a background renewal task to
    extend the TTL while the lock is held, preventing expiry during long DDL
    operations (e.g. ``DETACH PARTITION CONCURRENTLY``). The lock is released
    atomically via a Lua script that checks the ownership token, so it is safe
    even if Redis restarts during the renewal window.

    The renewal interval is ``ttl_seconds // 3`` (with random jitter to avoid
    thundering herds). If renewal fails — e.g. Redis is unreachable or another
    holder takes over — the watchdog logs a warning and cancels the holder
    task, forcing the maintenance run to stop (fail-safe).

    For production use you may want to subclass and override ``acquire_lock``
    to use Redlock or another algorithm with stronger guarantees.

    Raises:
        ImportError: If the ``redis-locks`` optional dependency is not installed.
    """

    def __init__(
        self,
        redis_client: RedisClientProtocol,
        prefix: str = _DEFAULT_REDIS_LOCK_PREFIX,
        ttl_seconds: int = 300,
        acquire_min_interval_seconds: float = 0.0,
    ) -> None:
        """Initialize lock manager.

        Args:
            redis_client: Redis client instance.
            prefix: Prefix for Redis keys.
            ttl_seconds: Lock time-to-live in seconds. The lock is automatically
                renewed every ``ttl_seconds // 3`` seconds so that it does not
                expire during long DDL operations.
            acquire_min_interval_seconds: Minimum seconds between acquire attempts
                per table (rate limiting). 0 disables.

        Raises:
            ImportError: If ``redis-py`` is not installed.
            ValueError: If ``ttl_seconds`` is below the minimum.
        """
        if not _redis_available:
            msg = (
                "redis-py is required for RedisDistributedLockManager. "
                "Install it with: pip install pg-partsmith[redis-locks]"
            )
            raise ImportError(msg)

        if ttl_seconds < _MIN_TTL_SECONDS:
            msg = f"ttl_seconds must be >= {_MIN_TTL_SECONDS}, got {ttl_seconds!r}"
            raise ValueError(msg)

        self._redis = redis_client
        self._prefix = prefix
        self._ttl = ttl_seconds
        self._renew_interval = max(1, ttl_seconds // 3)

        self._unlock_script = self._redis.register_script(_UNLOCK_LUA)
        self._renew_script = self._redis.register_script(_RENEW_LUA)
        self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
        self._last_acquire_time: dict[str, float] = {}
        self._rate_limit_lock = asyncio.Lock()

    def _get_lock_key(self, table_name: str) -> str:
        return f"{self._prefix}:{table_name}"

    def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
        """Acquire Redis lock with automatic TTL renewal.

        Args:
            table_name: Table name.

        Returns:
            Async context manager for the lock.

        Raises:
            LockAcquisitionError: If the lock is already held.
        """
        return self._lock_scope(table_name)

    @asynccontextmanager
    async def _lock_scope(self, table_name: str) -> AsyncIterator[None]:
        """Internal acquire/release flow for a single Redis lock."""
        await self._respect_rate_limit(table_name)

        key = self._get_lock_key(table_name)
        token = secrets.token_hex(16)

        try:
            acquired = await self._redis.set(key, token, ex=self._ttl, nx=True)
        except asyncio.CancelledError:
            # The SET may have been applied server-side before the cancellation
            # landed; the unlock script checks the token, so this is a safe no-op
            # when it was not.
            await asyncio.shield(self._release_safely(key, token, table_name))
            raise

        if not acquired:
            raise LockAcquisitionError(table_name, "Redis lock unavailable")

        # From here on the key is held: any failure must release it rather than leak it until TTL.
        watchdog: asyncio.Task[None] | None = None
        try:
            holder_task = asyncio.current_task()
            if holder_task is None:
                raise RuntimeError("Could not determine current asyncio task")

            watchdog = asyncio.create_task(
                self._renewal_watchdog(key, token, table_name, holder_task),
                name=f"redis-lock-watchdog:{key}",
            )
            yield
        finally:
            try:
                if watchdog is not None:
                    await self._cancel_watchdog(watchdog)
            finally:
                await asyncio.shield(self._release_safely(key, token, table_name))

    async def _respect_rate_limit(self, table_name: str) -> None:
        """Sleep enough to enforce the configured min-interval between acquires.

        The per-table slot is reserved under the mutex; the sleep itself happens
        outside it so one table's owed delay never blocks acquires for other tables.
        """
        if self._acquire_min_interval <= 0:
            return
        async with self._rate_limit_lock:
            now = time.monotonic()
            last = self._last_acquire_time.get(table_name)
            slot = now if last is None else max(now, last + self._acquire_min_interval)
            self._last_acquire_time[table_name] = slot
        delay = slot - now
        if delay > 0:
            await asyncio.sleep(delay)

    async def _renewal_watchdog(
        self,
        key: str,
        token: str,
        table_name: str,
        holder_task: asyncio.Task[Any],
    ) -> None:
        """Periodically extend the lock TTL until cancelled or renewal fails."""
        while True:
            jitter = random.uniform(*_RENEW_JITTER_RANGE)  # noqa: S311
            await asyncio.sleep(self._renew_interval * jitter)
            try:
                renewed = await self._renew_script(keys=[key], args=[token, str(self._ttl)])
            except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
                raise
            except (OSError, ConnectionError, TimeoutError, RuntimeError) as exc:
                self._fail_holder(holder_task, key, table_name, "recoverable error", exc)
                return
            except Exception as exc:
                self._fail_holder(holder_task, key, table_name, "unexpected exception", exc)
                return

            if not renewed:
                self._fail_holder(holder_task, key, table_name, "lock lost", None)
                return

    def _fail_holder(
        self,
        holder_task: asyncio.Task[Any],
        key: str,
        table_name: str,
        reason: str,
        exc: Exception | None,
    ) -> None:
        """Log the renewal failure and cancel the holder task (fail-safe)."""
        extra: dict[str, str] = {"table_name": table_name, "key": key, "reason": reason}
        if exc is not None:
            extra["error"] = str(exc)
            logger.warning(
                f"Redis lock renewal failed: {reason}; cancelling maintenance task",
                extra=extra,
                exc_info=True,
            )
        else:
            logger.warning(
                f"Redis lock renewal failed: {reason}; cancelling maintenance task",
                extra=extra,
            )
        if not holder_task.done():
            holder_task.cancel()

    async def _cancel_watchdog(self, watchdog: asyncio.Task[None]) -> None:
        """Cancel the renewal watchdog and absorb its CancelledError.

        If the holder task itself is being cancelled while awaiting the watchdog
        (e.g. app shutdown calls ``task.cancel()``), the cancellation must
        propagate — only the watchdog's own cancellation is swallowed.
        """
        watchdog.cancel()
        try:
            await watchdog
        except asyncio.CancelledError:
            if (cur := asyncio.current_task()) is not None and cur.cancelling() > 0:
                raise

    async def _release_safely(self, key: str, token: str, table_name: str) -> None:
        """Release the lock; failures are logged but do not propagate.

        If unlock fails, the TTL will eventually expire the lock.
        """
        try:
            await self._unlock_script(keys=[key], args=[token])
        except (asyncio.CancelledError, KeyboardInterrupt, SystemExit):
            raise
        except (OSError, ConnectionError, TimeoutError, RuntimeError) as exc:
            logger.warning(
                "Failed to release Redis lock (recoverable); TTL will expire it eventually",
                extra={
                    "table_name": table_name,
                    "key": key,
                    "error": str(exc),
                    "error_type": type(exc).__name__,
                },
            )
        except Exception:
            logger.exception(
                "Unexpected failure while releasing Redis lock",
                extra={"table_name": table_name, "key": key},
            )

    async def is_locked(self, table_name: str) -> bool:
        """Return True if the Redis lock for ``table_name`` is currently held."""
        key = self._get_lock_key(table_name)
        return bool(await self._redis.exists(key))

__init__(redis_client, prefix=_DEFAULT_REDIS_LOCK_PREFIX, ttl_seconds=300, acquire_min_interval_seconds=0.0)

Initialize lock manager.

Parameters:

Name Type Description Default
redis_client RedisClientProtocol

Redis client instance.

required
prefix str

Prefix for Redis keys.

_DEFAULT_REDIS_LOCK_PREFIX
ttl_seconds int

Lock time-to-live in seconds. The lock is automatically renewed every ttl_seconds // 3 seconds so that it does not expire during long DDL operations.

300
acquire_min_interval_seconds float

Minimum seconds between acquire attempts per table (rate limiting). 0 disables.

0.0

Raises:

Type Description
ImportError

If redis-py is not installed.

ValueError

If ttl_seconds is below the minimum.

Source code in pg_partsmith/aio/lock/redis.py
def __init__(
    self,
    redis_client: RedisClientProtocol,
    prefix: str = _DEFAULT_REDIS_LOCK_PREFIX,
    ttl_seconds: int = 300,
    acquire_min_interval_seconds: float = 0.0,
) -> None:
    """Initialize lock manager.

    Args:
        redis_client: Redis client instance.
        prefix: Prefix for Redis keys.
        ttl_seconds: Lock time-to-live in seconds. The lock is automatically
            renewed every ``ttl_seconds // 3`` seconds so that it does not
            expire during long DDL operations.
        acquire_min_interval_seconds: Minimum seconds between acquire attempts
            per table (rate limiting). 0 disables.

    Raises:
        ImportError: If ``redis-py`` is not installed.
        ValueError: If ``ttl_seconds`` is below the minimum.
    """
    if not _redis_available:
        msg = (
            "redis-py is required for RedisDistributedLockManager. "
            "Install it with: pip install pg-partsmith[redis-locks]"
        )
        raise ImportError(msg)

    if ttl_seconds < _MIN_TTL_SECONDS:
        msg = f"ttl_seconds must be >= {_MIN_TTL_SECONDS}, got {ttl_seconds!r}"
        raise ValueError(msg)

    self._redis = redis_client
    self._prefix = prefix
    self._ttl = ttl_seconds
    self._renew_interval = max(1, ttl_seconds // 3)

    self._unlock_script = self._redis.register_script(_UNLOCK_LUA)
    self._renew_script = self._redis.register_script(_RENEW_LUA)
    self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
    self._last_acquire_time: dict[str, float] = {}
    self._rate_limit_lock = asyncio.Lock()

acquire_lock(table_name)

Acquire Redis lock with automatic TTL renewal.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
AbstractAsyncContextManager[None]

Async context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If the lock is already held.

Source code in pg_partsmith/aio/lock/redis.py
def acquire_lock(self, table_name: str) -> AbstractAsyncContextManager[None]:
    """Acquire Redis lock with automatic TTL renewal.

    Args:
        table_name: Table name.

    Returns:
        Async context manager for the lock.

    Raises:
        LockAcquisitionError: If the lock is already held.
    """
    return self._lock_scope(table_name)

is_locked(table_name) async

Return True if the Redis lock for table_name is currently held.

Source code in pg_partsmith/aio/lock/redis.py
async def is_locked(self, table_name: str) -> bool:
    """Return True if the Redis lock for ``table_name`` is currently held."""
    key = self._get_lock_key(table_name)
    return bool(await self._redis.exists(key))

Hooks

No-op base implementation of partition lifecycle hooks.

Subclass and override only the methods you need. All methods are no-ops by default so you can selectively add behaviour without implementing every step.

Source code in pg_partsmith/aio/hooks.py
class BasePartitionLifecycleHooks:
    """No-op base implementation of partition lifecycle hooks.

    Subclass and override only the methods you need.
    All methods are no-ops by default so you can selectively add behaviour
    without implementing every step.
    """

    async def before_create(
        self,
        config: TablePartitionConfig,
        partition_name: str,
        from_value: str,
        to_value: str,
    ) -> None:
        """Called before a partition is created.

        Args:
            config: Table partition configuration.
            partition_name: Name the new partition will be given.
            from_value: Start boundary value.
            to_value: End boundary value.
        """

    async def after_create(
        self,
        config: TablePartitionConfig,
        partition: PartitionInfo,
    ) -> None:
        """Called after a partition has been created (and optionally attached).

        Args:
            config: Table partition configuration.
            partition: Info about the newly created partition.
        """

    async def before_detach(
        self,
        table_name: str,
        partition: PartitionInfo,
    ) -> None:
        """Called before a partition is detached from its parent table.

        This is a good place to export or archive data while the partition
        is still accessible via the parent table's indexes and constraints.

        Args:
            table_name: Parent table name.
            partition: Info about the partition being detached.
        """

    async def after_detach(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called after a partition has been detached.

        Args:
            table_name: Parent table name.
            partition_name: Name of the detached partition.
        """

    async def before_drop(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called before a partition table is dropped.

        This is the last chance to read or export data from the partition
        before it is permanently destroyed.

        Args:
            table_name: Parent table name.
            partition_name: Name of the partition about to be dropped.
        """

    async def after_drop(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called after a partition table has been dropped.

        Args:
            table_name: Parent table name.
            partition_name: Name of the dropped partition.
        """

after_create(config, partition) async

Called after a partition has been created (and optionally attached).

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition PartitionInfo

Info about the newly created partition.

required
Source code in pg_partsmith/aio/hooks.py
async def after_create(
    self,
    config: TablePartitionConfig,
    partition: PartitionInfo,
) -> None:
    """Called after a partition has been created (and optionally attached).

    Args:
        config: Table partition configuration.
        partition: Info about the newly created partition.
    """

after_detach(table_name, partition_name) async

Called after a partition has been detached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the detached partition.

required
Source code in pg_partsmith/aio/hooks.py
async def after_detach(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called after a partition has been detached.

    Args:
        table_name: Parent table name.
        partition_name: Name of the detached partition.
    """

after_drop(table_name, partition_name) async

Called after a partition table has been dropped.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the dropped partition.

required
Source code in pg_partsmith/aio/hooks.py
async def after_drop(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called after a partition table has been dropped.

    Args:
        table_name: Parent table name.
        partition_name: Name of the dropped partition.
    """

before_create(config, partition_name, from_value, to_value) async

Called before a partition is created.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition_name str

Name the new partition will be given.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
Source code in pg_partsmith/aio/hooks.py
async def before_create(
    self,
    config: TablePartitionConfig,
    partition_name: str,
    from_value: str,
    to_value: str,
) -> None:
    """Called before a partition is created.

    Args:
        config: Table partition configuration.
        partition_name: Name the new partition will be given.
        from_value: Start boundary value.
        to_value: End boundary value.
    """

before_detach(table_name, partition) async

Called before a partition is detached from its parent table.

This is a good place to export or archive data while the partition is still accessible via the parent table's indexes and constraints.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition PartitionInfo

Info about the partition being detached.

required
Source code in pg_partsmith/aio/hooks.py
async def before_detach(
    self,
    table_name: str,
    partition: PartitionInfo,
) -> None:
    """Called before a partition is detached from its parent table.

    This is a good place to export or archive data while the partition
    is still accessible via the parent table's indexes and constraints.

    Args:
        table_name: Parent table name.
        partition: Info about the partition being detached.
    """

before_drop(table_name, partition_name) async

Called before a partition table is dropped.

This is the last chance to read or export data from the partition before it is permanently destroyed.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the partition about to be dropped.

required
Source code in pg_partsmith/aio/hooks.py
async def before_drop(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called before a partition table is dropped.

    This is the last chance to read or export data from the partition
    before it is permanently destroyed.

    Args:
        table_name: Parent table name.
        partition_name: Name of the partition about to be dropped.
    """

pg_partsmith.sync

Synchronous mirror of pg_partsmith.aio: same class names and API, plain methods built on the sync SQLAlchemy Engine.

Service and maintainer

Service for managing the full partition lifecycle.

Orchestrates partition creation, detachment, and deletion by delegating to specialized component services.

Source code in pg_partsmith/sync/service.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
class PartitionLifecycleService:
    """Service for managing the full partition lifecycle.

    Orchestrates partition creation, detachment, and deletion by delegating
    to specialized component services.
    """

    def __init__(
        self,
        repo: PartitionRepository,
        metadata: PartitionMetadataProvider,
        locks: LockManager,
        period_calculator: PeriodCalculator[Period] | None = None,
        hooks: list[PartitionLifecycleHooks] | None = None,
    ) -> None:
        """Initialize the partition lifecycle service.

        Args:
            repo: DDL operations on partitions (create / attach / detach / drop).
            metadata: Read-only access to PostgreSQL catalog data.
            locks: Distributed lock manager preventing concurrent maintenance runs.
            period_calculator: Strategy for determining partition names and
                boundaries. Required for a TIME_BASED table and meaningless for
                a static HASH_BASED / VALUE_BASED one, which has no periods.
            hooks: Optional list of lifecycle hooks called around each step.
        """
        validate_timezone_alignment(repo, period_calculator)

        self._locks = locks
        self._metadata = metadata

        # Component services
        self._validation_service = PartitionValidationService(metadata)
        # Subpartitioning is optional, so the collaborators are typed loosely
        # here and checked against the nested protocols only when a config
        # actually asks for it — a flat setup with a custom repository or
        # metadata provider keeps working unchanged.
        self._subpartition_service = PartitionSubpartitionService(
            cast("SubpartitionRepository", repo),
            cast("NestedPartitionMetadata", metadata),
        )
        # The period-driven services exist only when there are periods.
        self._creation_service = (
            PartitionCreationService(repo, metadata, period_calculator, hooks, subpartitions=self._subpartition_service)
            if period_calculator is not None
            else None
        )
        self._pruning_service = (
            PartitionPruningService(metadata, period_calculator) if period_calculator is not None else None
        )
        self._detachment_service = PartitionDetachmentService(repo, hooks)
        self._deletion_service = PartitionDeletionService(repo, hooks)

    def create_future_partitions(self, config: TablePartitionConfig) -> list[PartitionInfo]:
        """Create partitions for future periods.

        Ensures partitions exist for the next ``config.create_ahead_count`` periods
        starting from the current period (inclusive). Idempotent: existing partitions
        are skipped.

        Args:
            config: Table partitioning configuration.

        Returns:
            List of newly created partitions (empty if all already existed).

        Raises:
            PartitionAlreadyExistsError: If a partition exists with conflicting boundaries.
            InvalidPartitionConfigError: If ``config`` is incompatible with the parent table.
        """
        return self._require_periods().create_future_partitions(config)

    def ensure_partition(self, config: TablePartitionConfig, period: Period) -> PartitionInfo | None:
        """Create and attach the partition for one specific period (idempotent).

        Unlike :meth:`create_future_partitions`, targets exactly ``period`` —
        useful for writers that must guarantee a partition exists before an
        insert (e.g. an hourly outbox buffer). Runs the same DEFAULT
        reconciliation and attach-race handling as the create-ahead path.

        Args:
            config: Table partitioning configuration.
            period: The period the partition must cover.

        Returns:
            The created partition, or None when it already existed (an existing
            detached partition is re-attached when ``auto_attach_after_create``).

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return self._require_periods().ensure_partition(config, period)

    def ensure_partitions(
        self,
        config: TablePartitionConfig,
        periods: Iterable[Period],
    ) -> list[PartitionInfo]:
        """Create and attach partitions for an explicit set of periods (idempotent).

        The backfill counterpart of :meth:`create_future_partitions`: the caller
        chooses the periods, so data that already sits in the table can be given
        partitions without waiting for create-ahead to reach it.

        Args:
            config: Table partitioning configuration.
            periods: Periods that must have a partition. Duplicates are ignored;
                order is preserved.

        Returns:
            The partitions created by this call; periods that already had one
            are absent from the list.

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return self._require_periods().ensure_partitions(config, periods)

    def get_partitions_for_pruning(self, config: TablePartitionConfig) -> list[PartitionInfo]:
        """Return partitions older than ``config.retention_count`` periods.

        Args:
            config: Table partitioning configuration.

        Returns:
            Partitions that are eligible for detach + drop, sorted oldest first.

        Raises:
            InvalidPartitionConfigError: If the service was built without a
                period calculator, which every period-driven call needs.
        """
        return self._require_pruning().get_partitions_for_pruning(config)

    def detach_old_partitions(
        self,
        table_name: str,
        partitions: list[PartitionInfo],
    ) -> list[str]:
        """Detach attached partitions from their parent table.

        Args:
            table_name: Qualified parent table name.
            partitions: Attached partitions to detach.

        Returns:
            Names of successfully detached partitions.

        Raises:
            PartitionDetachInProgressError: If a concurrent detach is in progress.
        """
        return self._detachment_service.detach_old_partitions(table_name, partitions)

    def drop_detached_partitions(
        self,
        table_name: str,
        partition_names: list[str],
    ) -> int:
        """Drop previously detached, marker-tagged partitions.

        Attached partitions are skipped with a warning (they raise
        ``PartitionAttachedError`` internally). Unmanaged tables are refused
        unless the underlying repository is configured otherwise.

        Args:
            table_name: Qualified parent table name (used for hook context).
            partition_names: Names of partitions to drop.

        Returns:
            Number of partitions actually dropped.
        """
        return self._deletion_service.drop_detached_partitions(table_name, partition_names)

    def reconcile_subpartitions(
        self,
        config: TablePartitionConfig,
        *,
        exclude: Collection[str] = (),
    ) -> SubpartitionReconcileResult:
        """Converge the subtree of every attached partition towards the config.

        Idempotent and safe to call on its own: it creates only the buckets a
        branch is genuinely missing, and reports rather than "repairs" any
        branch whose shape it cannot converge without risk.

        It takes **no distributed lock of its own** -- unlike
        :meth:`maintain_lifecycle`, which runs its whole sequence under one.
        Two workers calling this concurrently is safe: a lost race on a bucket
        is recognised by its bounds and reported, not retried into a failure.
        But calling it while a maintainer is mid-run means both are converging
        the same tree, and the wasted work is yours to weigh. Wrap it in your
        own lock if you would rather they queued.

        Args:
            config: Table partitioning configuration. Without a subpartition
                spec this is a no-op returning an empty result.
            exclude: Schema-qualified partition names to skip.

        Returns:
            The subpartitions created and the divergences left alone.

        Raises:
            UnsupportedCapabilityError: If the repository or metadata provider
                cannot serve a nested configuration.
        """
        return self._subpartition_service.reconcile(config, exclude=exclude)

    def _maintain_static_root(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool,
        continue_on_error: bool,
    ) -> MaintenanceResult:
        """Converge a HASH_BASED / VALUE_BASED table's own partition set.

        ``created_count`` reports every partition this call created, at any
        level: a static root has no lifecycle stages to attribute them to.
        Detach and drop are absent rather than skipped -- there is no retention
        window without periods -- but ``skip_create`` and ``continue_on_error``
        mean here exactly what they mean everywhere else.
        """
        if skip_create:
            return MaintenanceResult()

        try:
            reconciled = self._subpartition_service.reconcile(config)
        except (KeyboardInterrupt, SystemExit):
            raise
        except Exception as exc:
            if not continue_on_error:
                raise
            error = describe_exception(exc)
            logger.warning(
                "Maintenance step failed; continuing with the remaining steps",
                extra={
                    "table_name": qualify(config.db_schema, config.table_name),
                    "step": MaintenanceIssueStep.RECONCILE.value,
                    "error": error,
                },
            )
            return MaintenanceResult(issues=(MaintenanceIssue(step=MaintenanceIssueStep.RECONCILE, error=error),))

        issues = tuple(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)
        return MaintenanceResult(created_count=reconciled.created_count, issues=issues)

    def _require_periods(self) -> PartitionCreationService:
        """Return the creation service, or explain that this wiring has no periods."""
        if self._creation_service is None:
            raise InvalidPartitionConfigError(_NO_CALCULATOR_MESSAGE)
        return self._creation_service

    def _require_pruning(self) -> PartitionPruningService:
        """Return the pruning service, or explain that this wiring has no periods."""
        if self._pruning_service is None:
            raise InvalidPartitionConfigError(_NO_CALCULATOR_MESSAGE)
        return self._pruning_service

    def maintain_lifecycle(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Run create + detach + drop in a single locked maintenance window.

        The whole sequence runs under a single distributed lock acquired through
        the configured :class:`LockManager`, so concurrent maintainers do not
        race on the same parent table.

        Args:
            config: Table partitioning configuration.
            skip_create: Skip the create-ahead step.
            skip_detach: Skip detaching old partitions (orphans are still dropped).
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures instead of aborting the run:
                a failed create still prunes (which may free the space create
                needs), a failed detach still drops existing orphans. Failures
                are collected into ``MaintenanceResult.issues``. Validation and
                lock failures are always fatal.

        Subpartitioned configs additionally reconcile each branch's bucket set
        between create and detach; branches whose shape cannot be converged
        safely are reported through ``MaintenanceResult.issues`` regardless of
        ``continue_on_error``, since leaving them silent would hide writes that
        PostgreSQL is rejecting.

        Returns:
            ``MaintenanceResult`` with the per-step counters; ``error`` is unset
            because exceptions propagate from this method (the maintainer is
            responsible for catching them).

        Raises:
            LockAcquisitionError: If the table-level maintenance lock is unavailable.
            InvalidPartitionConfigError: If ``config`` does not match the parent table.
        """
        qualified_parent = qualify(config.db_schema, config.table_name)

        created_count = 0
        repaired_count = 0
        detached_count = 0
        dropped_count = 0
        issues: list[MaintenanceIssue] = []

        def _record_issue(step: MaintenanceIssueStep, exc: Exception) -> None:
            error = describe_exception(exc)
            issues.append(MaintenanceIssue(step=step, error=error))
            logger.warning(
                "Maintenance step failed; continuing with the remaining steps",
                extra={"table_name": qualified_parent, "step": step.value, "error": error},
            )

        with self._locks.acquire_lock(qualified_parent):
            self._validation_service.validate_config(config)

            if not config.is_time_based:
                # A static root has no periods: nothing is created ahead and
                # nothing ages out, so converging its partition set is the
                # whole of maintenance.
                return self._maintain_static_root(config, skip_create=skip_create, continue_on_error=continue_on_error)

            # Optimization: fetch all partitions once
            all_partitions = self._metadata.list_partitions(qualified_parent)

            # Finishing a branch an earlier run left half-built happens during
            # CREATE, before the reconcile stage that would otherwise count it.
            converged: list[SubpartitionReconcileResult] = []

            if not skip_create:
                try:
                    created = self._require_periods().create_future_partitions(
                        config, existing_partitions=all_partitions, converged=converged
                    )
                except (KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.CREATE, e)
                else:
                    created_count = len(created)
                    if created:
                        all_partitions.extend(created)

            # Buckets built while completing a half-built branch are repairs of
            # a pre-existing branch, which is exactly what repaired_count means.
            repaired_count += sum(result.created_count for result in converged)
            issues.extend(
                to_maintenance_issue(finding)
                for result in converged
                for finding in result.findings
                if finding.is_actionable
            )

            try:
                partitions_to_prune = self._require_pruning().identify_partitions_to_prune(config, all_partitions)
            except (KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                # Deciding *what* to prune is as failable as pruning it --
                # a boundary this run cannot read is enough. Left outside
                # continue_on_error it would abort the run after create and
                # reconcile had already committed their DDL.
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.DETACH, e)
                partitions_to_prune = []

            # Reconcile before pruning so a branch that is on its way out is not
            # repaired just to be dropped moments later.
            if config.subpartition is not None:
                try:
                    reconciled = self._subpartition_service.reconcile(
                        config, exclude={p.name for p in partitions_to_prune}
                    )
                except (KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.RECONCILE, e)
                else:
                    repaired_count += reconciled.created_count
                    issues.extend(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)

            if not partitions_to_prune:
                return MaintenanceResult(
                    created_count=created_count,
                    repaired_count=repaired_count,
                    issues=tuple(issues),
                )

            attached_to_detach = [p for p in partitions_to_prune if p.is_attached]
            orphan_names = [p.name for p in partitions_to_prune if not p.is_attached]

            names_to_drop = orphan_names
            if not skip_detach:
                try:
                    detached_names = self._detachment_service.detach_old_partitions(
                        qualified_parent,
                        attached_to_detach,
                    )
                except (KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    # Partially detached partitions carry the orphan marker and
                    # are collected as orphans on the next run.
                    _record_issue(MaintenanceIssueStep.DETACH, e)
                else:
                    detached_count = len(detached_names)
                    names_to_drop = orphan_names + detached_names

            if not skip_drop and names_to_drop:
                try:
                    dropped_count = self._deletion_service.drop_detached_partitions(
                        qualified_parent,
                        names_to_drop,
                    )
                except (KeyboardInterrupt, SystemExit):
                    raise
                except Exception as e:
                    if not continue_on_error:
                        raise
                    _record_issue(MaintenanceIssueStep.DROP, e)

        return MaintenanceResult(
            created_count=created_count,
            repaired_count=repaired_count,
            detached_count=detached_count,
            dropped_count=dropped_count,
            issues=tuple(issues),
        )

__init__(repo, metadata, locks, period_calculator=None, hooks=None)

Initialize the partition lifecycle service.

Parameters:

Name Type Description Default
repo PartitionRepository

DDL operations on partitions (create / attach / detach / drop).

required
metadata PartitionMetadataProvider

Read-only access to PostgreSQL catalog data.

required
locks LockManager

Distributed lock manager preventing concurrent maintenance runs.

required
period_calculator PeriodCalculator[Period] | None

Strategy for determining partition names and boundaries. Required for a TIME_BASED table and meaningless for a static HASH_BASED / VALUE_BASED one, which has no periods.

None
hooks list[PartitionLifecycleHooks] | None

Optional list of lifecycle hooks called around each step.

None
Source code in pg_partsmith/sync/service.py
def __init__(
    self,
    repo: PartitionRepository,
    metadata: PartitionMetadataProvider,
    locks: LockManager,
    period_calculator: PeriodCalculator[Period] | None = None,
    hooks: list[PartitionLifecycleHooks] | None = None,
) -> None:
    """Initialize the partition lifecycle service.

    Args:
        repo: DDL operations on partitions (create / attach / detach / drop).
        metadata: Read-only access to PostgreSQL catalog data.
        locks: Distributed lock manager preventing concurrent maintenance runs.
        period_calculator: Strategy for determining partition names and
            boundaries. Required for a TIME_BASED table and meaningless for
            a static HASH_BASED / VALUE_BASED one, which has no periods.
        hooks: Optional list of lifecycle hooks called around each step.
    """
    validate_timezone_alignment(repo, period_calculator)

    self._locks = locks
    self._metadata = metadata

    # Component services
    self._validation_service = PartitionValidationService(metadata)
    # Subpartitioning is optional, so the collaborators are typed loosely
    # here and checked against the nested protocols only when a config
    # actually asks for it — a flat setup with a custom repository or
    # metadata provider keeps working unchanged.
    self._subpartition_service = PartitionSubpartitionService(
        cast("SubpartitionRepository", repo),
        cast("NestedPartitionMetadata", metadata),
    )
    # The period-driven services exist only when there are periods.
    self._creation_service = (
        PartitionCreationService(repo, metadata, period_calculator, hooks, subpartitions=self._subpartition_service)
        if period_calculator is not None
        else None
    )
    self._pruning_service = (
        PartitionPruningService(metadata, period_calculator) if period_calculator is not None else None
    )
    self._detachment_service = PartitionDetachmentService(repo, hooks)
    self._deletion_service = PartitionDeletionService(repo, hooks)

create_future_partitions(config)

Create partitions for future periods.

Ensures partitions exist for the next config.create_ahead_count periods starting from the current period (inclusive). Idempotent: existing partitions are skipped.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required

Returns:

Type Description
list[PartitionInfo]

List of newly created partitions (empty if all already existed).

Raises:

Type Description
PartitionAlreadyExistsError

If a partition exists with conflicting boundaries.

InvalidPartitionConfigError

If config is incompatible with the parent table.

Source code in pg_partsmith/sync/service.py
def create_future_partitions(self, config: TablePartitionConfig) -> list[PartitionInfo]:
    """Create partitions for future periods.

    Ensures partitions exist for the next ``config.create_ahead_count`` periods
    starting from the current period (inclusive). Idempotent: existing partitions
    are skipped.

    Args:
        config: Table partitioning configuration.

    Returns:
        List of newly created partitions (empty if all already existed).

    Raises:
        PartitionAlreadyExistsError: If a partition exists with conflicting boundaries.
        InvalidPartitionConfigError: If ``config`` is incompatible with the parent table.
    """
    return self._require_periods().create_future_partitions(config)

detach_old_partitions(table_name, partitions)

Detach attached partitions from their parent table.

Parameters:

Name Type Description Default
table_name str

Qualified parent table name.

required
partitions list[PartitionInfo]

Attached partitions to detach.

required

Returns:

Type Description
list[str]

Names of successfully detached partitions.

Raises:

Type Description
PartitionDetachInProgressError

If a concurrent detach is in progress.

Source code in pg_partsmith/sync/service.py
def detach_old_partitions(
    self,
    table_name: str,
    partitions: list[PartitionInfo],
) -> list[str]:
    """Detach attached partitions from their parent table.

    Args:
        table_name: Qualified parent table name.
        partitions: Attached partitions to detach.

    Returns:
        Names of successfully detached partitions.

    Raises:
        PartitionDetachInProgressError: If a concurrent detach is in progress.
    """
    return self._detachment_service.detach_old_partitions(table_name, partitions)

drop_detached_partitions(table_name, partition_names)

Drop previously detached, marker-tagged partitions.

Attached partitions are skipped with a warning (they raise PartitionAttachedError internally). Unmanaged tables are refused unless the underlying repository is configured otherwise.

Parameters:

Name Type Description Default
table_name str

Qualified parent table name (used for hook context).

required
partition_names list[str]

Names of partitions to drop.

required

Returns:

Type Description
int

Number of partitions actually dropped.

Source code in pg_partsmith/sync/service.py
def drop_detached_partitions(
    self,
    table_name: str,
    partition_names: list[str],
) -> int:
    """Drop previously detached, marker-tagged partitions.

    Attached partitions are skipped with a warning (they raise
    ``PartitionAttachedError`` internally). Unmanaged tables are refused
    unless the underlying repository is configured otherwise.

    Args:
        table_name: Qualified parent table name (used for hook context).
        partition_names: Names of partitions to drop.

    Returns:
        Number of partitions actually dropped.
    """
    return self._deletion_service.drop_detached_partitions(table_name, partition_names)

ensure_partition(config, period)

Create and attach the partition for one specific period (idempotent).

Unlike :meth:create_future_partitions, targets exactly period — useful for writers that must guarantee a partition exists before an insert (e.g. an hourly outbox buffer). Runs the same DEFAULT reconciliation and attach-race handling as the create-ahead path.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
period Period

The period the partition must cover.

required

Returns:

Type Description
PartitionInfo | None

The created partition, or None when it already existed (an existing

PartitionInfo | None

detached partition is re-attached when auto_attach_after_create).

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/sync/service.py
def ensure_partition(self, config: TablePartitionConfig, period: Period) -> PartitionInfo | None:
    """Create and attach the partition for one specific period (idempotent).

    Unlike :meth:`create_future_partitions`, targets exactly ``period`` —
    useful for writers that must guarantee a partition exists before an
    insert (e.g. an hourly outbox buffer). Runs the same DEFAULT
    reconciliation and attach-race handling as the create-ahead path.

    Args:
        config: Table partitioning configuration.
        period: The period the partition must cover.

    Returns:
        The created partition, or None when it already existed (an existing
        detached partition is re-attached when ``auto_attach_after_create``).

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return self._require_periods().ensure_partition(config, period)

ensure_partitions(config, periods)

Create and attach partitions for an explicit set of periods (idempotent).

The backfill counterpart of :meth:create_future_partitions: the caller chooses the periods, so data that already sits in the table can be given partitions without waiting for create-ahead to reach it.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
periods Iterable[Period]

Periods that must have a partition. Duplicates are ignored; order is preserved.

required

Returns:

Type Description
list[PartitionInfo]

The partitions created by this call; periods that already had one

list[PartitionInfo]

are absent from the list.

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/sync/service.py
def ensure_partitions(
    self,
    config: TablePartitionConfig,
    periods: Iterable[Period],
) -> list[PartitionInfo]:
    """Create and attach partitions for an explicit set of periods (idempotent).

    The backfill counterpart of :meth:`create_future_partitions`: the caller
    chooses the periods, so data that already sits in the table can be given
    partitions without waiting for create-ahead to reach it.

    Args:
        config: Table partitioning configuration.
        periods: Periods that must have a partition. Duplicates are ignored;
            order is preserved.

    Returns:
        The partitions created by this call; periods that already had one
        are absent from the list.

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return self._require_periods().ensure_partitions(config, periods)

get_partitions_for_pruning(config)

Return partitions older than config.retention_count periods.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required

Returns:

Type Description
list[PartitionInfo]

Partitions that are eligible for detach + drop, sorted oldest first.

Raises:

Type Description
InvalidPartitionConfigError

If the service was built without a period calculator, which every period-driven call needs.

Source code in pg_partsmith/sync/service.py
def get_partitions_for_pruning(self, config: TablePartitionConfig) -> list[PartitionInfo]:
    """Return partitions older than ``config.retention_count`` periods.

    Args:
        config: Table partitioning configuration.

    Returns:
        Partitions that are eligible for detach + drop, sorted oldest first.

    Raises:
        InvalidPartitionConfigError: If the service was built without a
            period calculator, which every period-driven call needs.
    """
    return self._require_pruning().get_partitions_for_pruning(config)

maintain_lifecycle(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False)

Run create + detach + drop in a single locked maintenance window.

The whole sequence runs under a single distributed lock acquired through the configured :class:LockManager, so concurrent maintainers do not race on the same parent table.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
skip_create bool

Skip the create-ahead step.

False
skip_detach bool

Skip detaching old partitions (orphans are still dropped).

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures instead of aborting the run: a failed create still prunes (which may free the space create needs), a failed detach still drops existing orphans. Failures are collected into MaintenanceResult.issues. Validation and lock failures are always fatal.

False

Subpartitioned configs additionally reconcile each branch's bucket set between create and detach; branches whose shape cannot be converged safely are reported through MaintenanceResult.issues regardless of continue_on_error, since leaving them silent would hide writes that PostgreSQL is rejecting.

Returns:

Type Description
MaintenanceResult

MaintenanceResult with the per-step counters; error is unset

MaintenanceResult

because exceptions propagate from this method (the maintainer is

MaintenanceResult

responsible for catching them).

Raises:

Type Description
LockAcquisitionError

If the table-level maintenance lock is unavailable.

InvalidPartitionConfigError

If config does not match the parent table.

Source code in pg_partsmith/sync/service.py
def maintain_lifecycle(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Run create + detach + drop in a single locked maintenance window.

    The whole sequence runs under a single distributed lock acquired through
    the configured :class:`LockManager`, so concurrent maintainers do not
    race on the same parent table.

    Args:
        config: Table partitioning configuration.
        skip_create: Skip the create-ahead step.
        skip_detach: Skip detaching old partitions (orphans are still dropped).
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures instead of aborting the run:
            a failed create still prunes (which may free the space create
            needs), a failed detach still drops existing orphans. Failures
            are collected into ``MaintenanceResult.issues``. Validation and
            lock failures are always fatal.

    Subpartitioned configs additionally reconcile each branch's bucket set
    between create and detach; branches whose shape cannot be converged
    safely are reported through ``MaintenanceResult.issues`` regardless of
    ``continue_on_error``, since leaving them silent would hide writes that
    PostgreSQL is rejecting.

    Returns:
        ``MaintenanceResult`` with the per-step counters; ``error`` is unset
        because exceptions propagate from this method (the maintainer is
        responsible for catching them).

    Raises:
        LockAcquisitionError: If the table-level maintenance lock is unavailable.
        InvalidPartitionConfigError: If ``config`` does not match the parent table.
    """
    qualified_parent = qualify(config.db_schema, config.table_name)

    created_count = 0
    repaired_count = 0
    detached_count = 0
    dropped_count = 0
    issues: list[MaintenanceIssue] = []

    def _record_issue(step: MaintenanceIssueStep, exc: Exception) -> None:
        error = describe_exception(exc)
        issues.append(MaintenanceIssue(step=step, error=error))
        logger.warning(
            "Maintenance step failed; continuing with the remaining steps",
            extra={"table_name": qualified_parent, "step": step.value, "error": error},
        )

    with self._locks.acquire_lock(qualified_parent):
        self._validation_service.validate_config(config)

        if not config.is_time_based:
            # A static root has no periods: nothing is created ahead and
            # nothing ages out, so converging its partition set is the
            # whole of maintenance.
            return self._maintain_static_root(config, skip_create=skip_create, continue_on_error=continue_on_error)

        # Optimization: fetch all partitions once
        all_partitions = self._metadata.list_partitions(qualified_parent)

        # Finishing a branch an earlier run left half-built happens during
        # CREATE, before the reconcile stage that would otherwise count it.
        converged: list[SubpartitionReconcileResult] = []

        if not skip_create:
            try:
                created = self._require_periods().create_future_partitions(
                    config, existing_partitions=all_partitions, converged=converged
                )
            except (KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.CREATE, e)
            else:
                created_count = len(created)
                if created:
                    all_partitions.extend(created)

        # Buckets built while completing a half-built branch are repairs of
        # a pre-existing branch, which is exactly what repaired_count means.
        repaired_count += sum(result.created_count for result in converged)
        issues.extend(
            to_maintenance_issue(finding)
            for result in converged
            for finding in result.findings
            if finding.is_actionable
        )

        try:
            partitions_to_prune = self._require_pruning().identify_partitions_to_prune(config, all_partitions)
        except (KeyboardInterrupt, SystemExit):
            raise
        except Exception as e:
            # Deciding *what* to prune is as failable as pruning it --
            # a boundary this run cannot read is enough. Left outside
            # continue_on_error it would abort the run after create and
            # reconcile had already committed their DDL.
            if not continue_on_error:
                raise
            _record_issue(MaintenanceIssueStep.DETACH, e)
            partitions_to_prune = []

        # Reconcile before pruning so a branch that is on its way out is not
        # repaired just to be dropped moments later.
        if config.subpartition is not None:
            try:
                reconciled = self._subpartition_service.reconcile(
                    config, exclude={p.name for p in partitions_to_prune}
                )
            except (KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.RECONCILE, e)
            else:
                repaired_count += reconciled.created_count
                issues.extend(to_maintenance_issue(f) for f in reconciled.findings if f.is_actionable)

        if not partitions_to_prune:
            return MaintenanceResult(
                created_count=created_count,
                repaired_count=repaired_count,
                issues=tuple(issues),
            )

        attached_to_detach = [p for p in partitions_to_prune if p.is_attached]
        orphan_names = [p.name for p in partitions_to_prune if not p.is_attached]

        names_to_drop = orphan_names
        if not skip_detach:
            try:
                detached_names = self._detachment_service.detach_old_partitions(
                    qualified_parent,
                    attached_to_detach,
                )
            except (KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                # Partially detached partitions carry the orphan marker and
                # are collected as orphans on the next run.
                _record_issue(MaintenanceIssueStep.DETACH, e)
            else:
                detached_count = len(detached_names)
                names_to_drop = orphan_names + detached_names

        if not skip_drop and names_to_drop:
            try:
                dropped_count = self._deletion_service.drop_detached_partitions(
                    qualified_parent,
                    names_to_drop,
                )
            except (KeyboardInterrupt, SystemExit):
                raise
            except Exception as e:
                if not continue_on_error:
                    raise
                _record_issue(MaintenanceIssueStep.DROP, e)

    return MaintenanceResult(
        created_count=created_count,
        repaired_count=repaired_count,
        detached_count=detached_count,
        dropped_count=dropped_count,
        issues=tuple(issues),
    )

reconcile_subpartitions(config, *, exclude=())

Converge the subtree of every attached partition towards the config.

Idempotent and safe to call on its own: it creates only the buckets a branch is genuinely missing, and reports rather than "repairs" any branch whose shape it cannot converge without risk.

It takes no distributed lock of its own -- unlike :meth:maintain_lifecycle, which runs its whole sequence under one. Two workers calling this concurrently is safe: a lost race on a bucket is recognised by its bounds and reported, not retried into a failure. But calling it while a maintainer is mid-run means both are converging the same tree, and the wasted work is yours to weigh. Wrap it in your own lock if you would rather they queued.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration. Without a subpartition spec this is a no-op returning an empty result.

required
exclude Collection[str]

Schema-qualified partition names to skip.

()

Returns:

Type Description
SubpartitionReconcileResult

The subpartitions created and the divergences left alone.

Raises:

Type Description
UnsupportedCapabilityError

If the repository or metadata provider cannot serve a nested configuration.

Source code in pg_partsmith/sync/service.py
def reconcile_subpartitions(
    self,
    config: TablePartitionConfig,
    *,
    exclude: Collection[str] = (),
) -> SubpartitionReconcileResult:
    """Converge the subtree of every attached partition towards the config.

    Idempotent and safe to call on its own: it creates only the buckets a
    branch is genuinely missing, and reports rather than "repairs" any
    branch whose shape it cannot converge without risk.

    It takes **no distributed lock of its own** -- unlike
    :meth:`maintain_lifecycle`, which runs its whole sequence under one.
    Two workers calling this concurrently is safe: a lost race on a bucket
    is recognised by its bounds and reported, not retried into a failure.
    But calling it while a maintainer is mid-run means both are converging
    the same tree, and the wasted work is yours to weigh. Wrap it in your
    own lock if you would rather they queued.

    Args:
        config: Table partitioning configuration. Without a subpartition
            spec this is a no-op returning an empty result.
        exclude: Schema-qualified partition names to skip.

    Returns:
        The subpartitions created and the divergences left alone.

    Raises:
        UnsupportedCapabilityError: If the repository or metadata provider
            cannot serve a nested configuration.
    """
    return self._subpartition_service.reconcile(config, exclude=exclude)

Orchestrator for partition lifecycle maintenance.

Wraps a lifecycle service with timing, logging, and error handling. Operational failures are logged and re-raised by run_maintenance. Use run_maintenance_safe (or the maintain_partitions helper) when you need a scheduler-friendly API that always returns MaintenanceResult.

Source code in pg_partsmith/sync/maintainer.py
class PartitionMaintainer:
    """Orchestrator for partition lifecycle maintenance.

    Wraps a lifecycle service with timing, logging, and error handling.
    Operational failures are logged and re-raised by ``run_maintenance``.
    Use ``run_maintenance_safe`` (or the ``maintain_partitions`` helper) when
    you need a scheduler-friendly API that always returns ``MaintenanceResult``.
    """

    def __init__(
        self,
        lifecycle_service: PartitionLifecycle,
    ) -> None:
        """Initialize maintainer.

        Args:
            lifecycle_service: Partition lifecycle service.
        """
        self._service = lifecycle_service

    def run_maintenance(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Execute full partition lifecycle maintenance.

        Args:
            config: Table partition configuration.
            skip_create: Skip creating future partitions.
            skip_detach: Skip detaching old partitions.
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures into ``result.issues``
                instead of aborting the run (see ``maintain_lifecycle``).

        Returns:
            Maintenance result with counts and duration.

        Raises:
            KeyboardInterrupt: Propagated after being logged.
            Exception: Propagated after being logged.
        """
        start_time = time.perf_counter()
        qualified_table = qualify(config.db_schema, config.table_name)

        logger.info(
            "Starting partition maintenance",
            extra={
                "table_name": qualified_table,
                "skip_create": skip_create,
                "skip_detach": skip_detach,
                "skip_drop": skip_drop,
            },
        )

        try:
            result = self._service.maintain_lifecycle(
                config,
                skip_create=skip_create,
                skip_detach=skip_detach,
                skip_drop=skip_drop,
                continue_on_error=continue_on_error,
            )

            duration_ms = elapsed_ms(start_time)
            result = result.model_copy(update={"duration_ms": duration_ms})

            logger.info(
                "Partition maintenance completed successfully",
                extra={
                    "table_name": qualified_table,
                    "created_count": result.created_count,
                    "detached_count": result.detached_count,
                    "dropped_count": result.dropped_count,
                    "duration_ms": duration_ms,
                    "duration": format_duration_ms(duration_ms),
                },
            )
        except (KeyboardInterrupt, SystemExit):
            duration_ms = elapsed_ms(start_time)

            logger.info(
                "Partition maintenance was interrupted by system signal",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                },
            )
            raise
        except PartitionError as e:
            duration_ms = elapsed_ms(start_time)
            logger.warning(
                "Partition maintenance failed (operational error)",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                    "error": str(e),
                    "error_type": type(e).__name__,
                },
            )
            raise
        except (ValueError, TypeError, RuntimeError) as e:
            duration_ms = elapsed_ms(start_time)
            logger.warning(
                "Partition maintenance failed",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                    "error": str(e),
                    "error_type": type(e).__name__,
                },
            )
            raise
        except Exception:
            duration_ms = elapsed_ms(start_time)

            logger.exception(
                "Partition maintenance raised unexpected exception",
                extra={
                    "table_name": qualified_table,
                    "duration_ms": duration_ms,
                },
            )
            raise
        else:
            return result

    def run_maintenance_safe(
        self,
        config: TablePartitionConfig,
        *,
        skip_create: bool = False,
        skip_detach: bool = False,
        skip_drop: bool = False,
        continue_on_error: bool = False,
    ) -> MaintenanceResult:
        """Run maintenance and always return ``MaintenanceResult``, never raise.

        Scheduler-friendly wrapper around :meth:`run_maintenance`. Any exception
        — including ``KeyboardInterrupt`` — is captured and reported via
        ``result.error``; the ``duration_ms`` field always reflects the elapsed
        time even on failure.

        Args:
            config: Table partitioning configuration.
            skip_create: Skip creating future partitions.
            skip_detach: Skip detaching old partitions.
            skip_drop: Skip dropping detached partitions.
            continue_on_error: Isolate step failures into ``result.issues``
                instead of aborting the run (see ``maintain_lifecycle``).

        Returns:
            ``MaintenanceResult`` with counts on success or ``error`` set on failure.
        """
        start_time = time.perf_counter()
        try:
            return self.run_maintenance(
                config,
                skip_create=skip_create,
                skip_detach=skip_detach,
                skip_drop=skip_drop,
                continue_on_error=continue_on_error,
            )
        except (KeyboardInterrupt, SystemExit) as e:
            return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))
        except Exception as e:
            return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))

run_maintenance(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False)

Execute full partition lifecycle maintenance.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
skip_create bool

Skip creating future partitions.

False
skip_detach bool

Skip detaching old partitions.

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures into result.issues instead of aborting the run (see maintain_lifecycle).

False

Returns:

Type Description
MaintenanceResult

Maintenance result with counts and duration.

Raises:

Type Description
KeyboardInterrupt

Propagated after being logged.

Exception

Propagated after being logged.

Source code in pg_partsmith/sync/maintainer.py
def run_maintenance(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Execute full partition lifecycle maintenance.

    Args:
        config: Table partition configuration.
        skip_create: Skip creating future partitions.
        skip_detach: Skip detaching old partitions.
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures into ``result.issues``
            instead of aborting the run (see ``maintain_lifecycle``).

    Returns:
        Maintenance result with counts and duration.

    Raises:
        KeyboardInterrupt: Propagated after being logged.
        Exception: Propagated after being logged.
    """
    start_time = time.perf_counter()
    qualified_table = qualify(config.db_schema, config.table_name)

    logger.info(
        "Starting partition maintenance",
        extra={
            "table_name": qualified_table,
            "skip_create": skip_create,
            "skip_detach": skip_detach,
            "skip_drop": skip_drop,
        },
    )

    try:
        result = self._service.maintain_lifecycle(
            config,
            skip_create=skip_create,
            skip_detach=skip_detach,
            skip_drop=skip_drop,
            continue_on_error=continue_on_error,
        )

        duration_ms = elapsed_ms(start_time)
        result = result.model_copy(update={"duration_ms": duration_ms})

        logger.info(
            "Partition maintenance completed successfully",
            extra={
                "table_name": qualified_table,
                "created_count": result.created_count,
                "detached_count": result.detached_count,
                "dropped_count": result.dropped_count,
                "duration_ms": duration_ms,
                "duration": format_duration_ms(duration_ms),
            },
        )
    except (KeyboardInterrupt, SystemExit):
        duration_ms = elapsed_ms(start_time)

        logger.info(
            "Partition maintenance was interrupted by system signal",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
            },
        )
        raise
    except PartitionError as e:
        duration_ms = elapsed_ms(start_time)
        logger.warning(
            "Partition maintenance failed (operational error)",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
                "error": str(e),
                "error_type": type(e).__name__,
            },
        )
        raise
    except (ValueError, TypeError, RuntimeError) as e:
        duration_ms = elapsed_ms(start_time)
        logger.warning(
            "Partition maintenance failed",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
                "error": str(e),
                "error_type": type(e).__name__,
            },
        )
        raise
    except Exception:
        duration_ms = elapsed_ms(start_time)

        logger.exception(
            "Partition maintenance raised unexpected exception",
            extra={
                "table_name": qualified_table,
                "duration_ms": duration_ms,
            },
        )
        raise
    else:
        return result

run_maintenance_safe(config, *, skip_create=False, skip_detach=False, skip_drop=False, continue_on_error=False)

Run maintenance and always return MaintenanceResult, never raise.

Scheduler-friendly wrapper around :meth:run_maintenance. Any exception — including KeyboardInterrupt — is captured and reported via result.error; the duration_ms field always reflects the elapsed time even on failure.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partitioning configuration.

required
skip_create bool

Skip creating future partitions.

False
skip_detach bool

Skip detaching old partitions.

False
skip_drop bool

Skip dropping detached partitions.

False
continue_on_error bool

Isolate step failures into result.issues instead of aborting the run (see maintain_lifecycle).

False

Returns:

Type Description
MaintenanceResult

MaintenanceResult with counts on success or error set on failure.

Source code in pg_partsmith/sync/maintainer.py
def run_maintenance_safe(
    self,
    config: TablePartitionConfig,
    *,
    skip_create: bool = False,
    skip_detach: bool = False,
    skip_drop: bool = False,
    continue_on_error: bool = False,
) -> MaintenanceResult:
    """Run maintenance and always return ``MaintenanceResult``, never raise.

    Scheduler-friendly wrapper around :meth:`run_maintenance`. Any exception
    — including ``KeyboardInterrupt`` — is captured and reported via
    ``result.error``; the ``duration_ms`` field always reflects the elapsed
    time even on failure.

    Args:
        config: Table partitioning configuration.
        skip_create: Skip creating future partitions.
        skip_detach: Skip detaching old partitions.
        skip_drop: Skip dropping detached partitions.
        continue_on_error: Isolate step failures into ``result.issues``
            instead of aborting the run (see ``maintain_lifecycle``).

    Returns:
        ``MaintenanceResult`` with counts on success or ``error`` set on failure.
    """
    start_time = time.perf_counter()
    try:
        return self.run_maintenance(
            config,
            skip_create=skip_create,
            skip_detach=skip_detach,
            skip_drop=skip_drop,
            continue_on_error=continue_on_error,
        )
    except (KeyboardInterrupt, SystemExit) as e:
        return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))
    except Exception as e:
        return MaintenanceResult(duration_ms=elapsed_ms(start_time), error=describe_exception(e))

Protocols

Implement these to swap in your own storage or locking. The flat pair is all a single-column, unnested config needs; the rest are opt-in and only required when a config actually asks for what they add.

Bases: Protocol

Repository for partition DDL operations.

This protocol is intentionally limited to write operations. All read operations (listing partitions, checking existence) live in PartitionMetadataProvider so that the two concerns can be mocked, swapped, or overridden independently.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class PartitionRepository(Protocol):
    """Repository for partition DDL operations.

    This protocol is intentionally limited to write operations.  All read
    operations (listing partitions, checking existence) live in
    ``PartitionMetadataProvider`` so that the two concerns can be mocked,
    swapped, or overridden independently.
    """

    def create_partition(
        self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
    ) -> PartitionInfo:
        """Create a new partition table.

        Args:
            config: Table partition configuration.
            partition_name: Name for the new partition table.
            from_value: Start boundary value.
            to_value: End boundary value.

        Returns:
            Created partition info.

        Raises:
            PartitionAlreadyExistsError: If partition already exists.
        """
        ...

    def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
        """Attach partition to parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            from_value: Start boundary value.
            to_value: End boundary value.
        """
        ...

    def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
        """Detach partition from parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            concurrent: Use DETACH PARTITION CONCURRENTLY if supported.

        Raises:
            PartitionNotFoundError: If partition doesn't exist.
        """
        ...

    def drop_partition(self, partition_name: str) -> None:
        """Drop a partition table.

        Args:
            partition_name: Partition table name.

        Raises:
            PartitionAttachedError: If partition is still attached.
        """
        ...

    def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        from_value: str,
        to_value: str,
    ) -> int:
        """Move rows from DEFAULT partition to target partition for given range.

        Args:
            default_partition_name: Qualified name of DEFAULT partition.
            target_partition_name: Qualified name of target partition.
            partition_column: Column used for partitioning.
            from_value: Range start boundary (inclusive).
            to_value: Range end boundary (exclusive).

        Returns:
            Number of rows moved.

        Raises:
            SQLAlchemyError: On database errors.
        """
        ...

attach_partition(table_name, partition_name, from_value, to_value)

Attach partition to parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
Source code in pg_partsmith/sync/protocols.py
def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
    """Attach partition to parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        from_value: Start boundary value.
        to_value: End boundary value.
    """
    ...

create_partition(config, partition_name, from_value, to_value)

Create a new partition table.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition_name str

Name for the new partition table.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required

Returns:

Type Description
PartitionInfo

Created partition info.

Raises:

Type Description
PartitionAlreadyExistsError

If partition already exists.

Source code in pg_partsmith/sync/protocols.py
def create_partition(
    self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
) -> PartitionInfo:
    """Create a new partition table.

    Args:
        config: Table partition configuration.
        partition_name: Name for the new partition table.
        from_value: Start boundary value.
        to_value: End boundary value.

    Returns:
        Created partition info.

    Raises:
        PartitionAlreadyExistsError: If partition already exists.
    """
    ...

detach_partition(table_name, partition_name, *, concurrent=True)

Detach partition from parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
concurrent bool

Use DETACH PARTITION CONCURRENTLY if supported.

True

Raises:

Type Description
PartitionNotFoundError

If partition doesn't exist.

Source code in pg_partsmith/sync/protocols.py
def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
    """Detach partition from parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        concurrent: Use DETACH PARTITION CONCURRENTLY if supported.

    Raises:
        PartitionNotFoundError: If partition doesn't exist.
    """
    ...

drop_partition(partition_name)

Drop a partition table.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Raises:

Type Description
PartitionAttachedError

If partition is still attached.

Source code in pg_partsmith/sync/protocols.py
def drop_partition(self, partition_name: str) -> None:
    """Drop a partition table.

    Args:
        partition_name: Partition table name.

    Raises:
        PartitionAttachedError: If partition is still attached.
    """
    ...

reconcile_default_rows(*, default_partition_name, target_partition_name, partition_column, from_value, to_value)

Move rows from DEFAULT partition to target partition for given range.

Parameters:

Name Type Description Default
default_partition_name str

Qualified name of DEFAULT partition.

required
target_partition_name str

Qualified name of target partition.

required
partition_column str

Column used for partitioning.

required
from_value str

Range start boundary (inclusive).

required
to_value str

Range end boundary (exclusive).

required

Returns:

Type Description
int

Number of rows moved.

Raises:

Type Description
SQLAlchemyError

On database errors.

Source code in pg_partsmith/sync/protocols.py
def reconcile_default_rows(
    self,
    *,
    default_partition_name: str,
    target_partition_name: str,
    partition_column: str,
    from_value: str,
    to_value: str,
) -> int:
    """Move rows from DEFAULT partition to target partition for given range.

    Args:
        default_partition_name: Qualified name of DEFAULT partition.
        target_partition_name: Qualified name of target partition.
        partition_column: Column used for partitioning.
        from_value: Range start boundary (inclusive).
        to_value: Range end boundary (exclusive).

    Returns:
        Number of rows moved.

    Raises:
        SQLAlchemyError: On database errors.
    """
    ...

Bases: Protocol

Provider for reading partition metadata from the database catalogue.

This protocol owns all read operations so that the service layer depends on a single injectable read interface. Implement this protocol to support a different database, a caching layer, or a stub for testing.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class PartitionMetadataProvider(Protocol):
    """Provider for reading partition metadata from the database catalogue.

    This protocol owns *all* read operations so that the service layer depends
    on a single injectable read interface.  Implement this protocol to support a
    different database, a caching layer, or a stub for testing.
    """

    def get_partition_type(self, table_name: str) -> PartitionType | None:
        """Get partition type for a table.

        Args:
            table_name: Table name.

        Returns:
            Partition type or None if table is not partitioned.
        """
        ...

    def get_partition_column(self, table_name: str) -> str | None:
        """Get partition column for a table.

        Args:
            table_name: Table name.

        Returns:
            Partition column name or None if table is not partitioned.

        Raises:
            ValueError: If the table uses a composite (multi-column) partition key.
        """
        ...

    def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
        """Get partition boundaries.

        Args:
            partition_name: Partition table name.

        Returns:
            Tuple of (from_value, to_value) or None if not a range partition.
        """
        ...

    def list_partitions(self, table_name: str) -> list[PartitionInfo]:
        """List all partitions for a table, including orphaned detached ones.

        Orphaned partitions are tables that were detached in a previous
        maintenance run but never dropped.  They are returned with
        ``is_attached=False`` and ``None`` boundaries so that the service can
        schedule them for cleanup on the next run.

        Args:
            table_name: Parent table name.

        Returns:
            List of partition metadata.
        """
        ...

    def partition_exists(self, partition_name: str) -> bool:
        """Check if a partition table exists in the catalogue.

        Args:
            partition_name: Partition table name.

        Returns:
            True if the table exists.
        """
        ...

    def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
        """Check if a partition is currently attached to its parent table.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.

        Returns:
            True if the partition is attached via pg_inherits.
        """
        ...

    def get_default_partition(self, table_name: str) -> PartitionInfo | None:
        """Get DEFAULT partition for a table if it exists and is attached.

        Args:
            table_name: Parent table name.

        Returns:
            PartitionInfo with is_default=True, or None if no default partition exists.
        """
        ...

get_default_partition(table_name)

Get DEFAULT partition for a table if it exists and is attached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
PartitionInfo | None

PartitionInfo with is_default=True, or None if no default partition exists.

Source code in pg_partsmith/sync/protocols.py
def get_default_partition(self, table_name: str) -> PartitionInfo | None:
    """Get DEFAULT partition for a table if it exists and is attached.

    Args:
        table_name: Parent table name.

    Returns:
        PartitionInfo with is_default=True, or None if no default partition exists.
    """
    ...

get_partition_boundaries(partition_name)

Get partition boundaries.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
tuple[str, str] | None

Tuple of (from_value, to_value) or None if not a range partition.

Source code in pg_partsmith/sync/protocols.py
def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
    """Get partition boundaries.

    Args:
        partition_name: Partition table name.

    Returns:
        Tuple of (from_value, to_value) or None if not a range partition.
    """
    ...

get_partition_column(table_name)

Get partition column for a table.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
str | None

Partition column name or None if table is not partitioned.

Raises:

Type Description
ValueError

If the table uses a composite (multi-column) partition key.

Source code in pg_partsmith/sync/protocols.py
def get_partition_column(self, table_name: str) -> str | None:
    """Get partition column for a table.

    Args:
        table_name: Table name.

    Returns:
        Partition column name or None if table is not partitioned.

    Raises:
        ValueError: If the table uses a composite (multi-column) partition key.
    """
    ...

get_partition_type(table_name)

Get partition type for a table.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
PartitionType | None

Partition type or None if table is not partitioned.

Source code in pg_partsmith/sync/protocols.py
def get_partition_type(self, table_name: str) -> PartitionType | None:
    """Get partition type for a table.

    Args:
        table_name: Table name.

    Returns:
        Partition type or None if table is not partitioned.
    """
    ...

is_partition_attached(table_name, partition_name)

Check if a partition is currently attached to its parent table.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the partition is attached via pg_inherits.

Source code in pg_partsmith/sync/protocols.py
def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
    """Check if a partition is currently attached to its parent table.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.

    Returns:
        True if the partition is attached via pg_inherits.
    """
    ...

list_partitions(table_name)

List all partitions for a table, including orphaned detached ones.

Orphaned partitions are tables that were detached in a previous maintenance run but never dropped. They are returned with is_attached=False and None boundaries so that the service can schedule them for cleanup on the next run.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
list[PartitionInfo]

List of partition metadata.

Source code in pg_partsmith/sync/protocols.py
def list_partitions(self, table_name: str) -> list[PartitionInfo]:
    """List all partitions for a table, including orphaned detached ones.

    Orphaned partitions are tables that were detached in a previous
    maintenance run but never dropped.  They are returned with
    ``is_attached=False`` and ``None`` boundaries so that the service can
    schedule them for cleanup on the next run.

    Args:
        table_name: Parent table name.

    Returns:
        List of partition metadata.
    """
    ...

partition_exists(partition_name)

Check if a partition table exists in the catalogue.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the table exists.

Source code in pg_partsmith/sync/protocols.py
def partition_exists(self, partition_name: str) -> bool:
    """Check if a partition table exists in the catalogue.

    Args:
        partition_name: Partition table name.

    Returns:
        True if the table exists.
    """
    ...

Bases: Protocol

DDL for partitions that are themselves partitioned tables.

Kept separate from :class:PartitionRepository on purpose: a repository written against the flat protocol keeps satisfying it, and is only required to grow these three methods once a config actually asks for subpartitioning.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class SubpartitionRepository(Protocol):
    """DDL for partitions that are themselves partitioned tables.

    Kept separate from :class:`PartitionRepository` on purpose: a repository
    written against the flat protocol keeps satisfying it, and is only required
    to grow these three methods once a config actually asks for subpartitioning.
    """

    def create_branch(
        self,
        config: TablePartitionConfig,
        branch_name: str,
        from_value: str,
        to_value: str,
        spec: SubpartitionSpec,
    ) -> PartitionInfo:
        """Create a detached time partition that is itself partitioned.

        Args:
            config: Table partition configuration.
            branch_name: Name for the new branch table.
            from_value: Start boundary value.
            to_value: End boundary value.
            spec: Subpartitioning the branch applies to its own children.

        Returns:
            Info about the created (still detached) branch.

        Raises:
            PartitionAlreadyExistsError: If a relation of that name exists.
        """
        ...

    def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
        """Create a detached table shaped like ``parent_name``.

        Args:
            parent_name: Relation the table will later be attached to.
            child_name: Name for the new table.
            spec: Subpartitioning the table applies to its own children, or None.

        Raises:
            PartitionAlreadyExistsError: If a relation of that name exists.
        """
        ...

    def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
        """Attach one subpartition to its parent.

        Args:
            parent_name: Partitioned relation to attach to.
            child_name: Table to attach.
            bounds: What the child owns — a hash bucket, a set of LIST values,
                or DEFAULT.
        """
        ...

attach_subpartition(parent_name, child_name, bounds)

Attach one subpartition to its parent.

Parameters:

Name Type Description Default
parent_name str

Partitioned relation to attach to.

required
child_name str

Table to attach.

required
bounds SubpartitionBounds

What the child owns — a hash bucket, a set of LIST values, or DEFAULT.

required
Source code in pg_partsmith/sync/protocols.py
def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
    """Attach one subpartition to its parent.

    Args:
        parent_name: Partitioned relation to attach to.
        child_name: Table to attach.
        bounds: What the child owns — a hash bucket, a set of LIST values,
            or DEFAULT.
    """
    ...

create_branch(config, branch_name, from_value, to_value, spec)

Create a detached time partition that is itself partitioned.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
branch_name str

Name for the new branch table.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
spec SubpartitionSpec

Subpartitioning the branch applies to its own children.

required

Returns:

Type Description
PartitionInfo

Info about the created (still detached) branch.

Raises:

Type Description
PartitionAlreadyExistsError

If a relation of that name exists.

Source code in pg_partsmith/sync/protocols.py
def create_branch(
    self,
    config: TablePartitionConfig,
    branch_name: str,
    from_value: str,
    to_value: str,
    spec: SubpartitionSpec,
) -> PartitionInfo:
    """Create a detached time partition that is itself partitioned.

    Args:
        config: Table partition configuration.
        branch_name: Name for the new branch table.
        from_value: Start boundary value.
        to_value: End boundary value.
        spec: Subpartitioning the branch applies to its own children.

    Returns:
        Info about the created (still detached) branch.

    Raises:
        PartitionAlreadyExistsError: If a relation of that name exists.
    """
    ...

create_subpartition_table(parent_name, child_name, spec)

Create a detached table shaped like parent_name.

Parameters:

Name Type Description Default
parent_name str

Relation the table will later be attached to.

required
child_name str

Name for the new table.

required
spec SubpartitionSpec | None

Subpartitioning the table applies to its own children, or None.

required

Raises:

Type Description
PartitionAlreadyExistsError

If a relation of that name exists.

Source code in pg_partsmith/sync/protocols.py
def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
    """Create a detached table shaped like ``parent_name``.

    Args:
        parent_name: Relation the table will later be attached to.
        child_name: Name for the new table.
        spec: Subpartitioning the table applies to its own children, or None.

    Raises:
        PartitionAlreadyExistsError: If a relation of that name exists.
    """
    ...

Bases: Protocol

Structural introspection a nested configuration needs.

Also separate from :class:PartitionMetadataProvider so flat setups keep working with providers that predate subpartitioning.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class NestedPartitionMetadata(Protocol):
    """Structural introspection a nested configuration needs.

    Also separate from :class:`PartitionMetadataProvider` so flat setups keep
    working with providers that predate subpartitioning.
    """

    def get_partition_tree(self, table_name: str) -> PartitionNode | None:
        """Return the whole partition tree rooted at ``table_name``.

        Args:
            table_name: Root of the tree, schema-qualified.

        Returns:
            The root node with its descendants, or None when the relation is
            neither partitioned nor a partition.
        """
        ...

    def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
        """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            One tuple of column names per constraint.
        """
        ...

get_partition_tree(table_name)

Return the whole partition tree rooted at table_name.

Parameters:

Name Type Description Default
table_name str

Root of the tree, schema-qualified.

required

Returns:

Type Description
PartitionNode | None

The root node with its descendants, or None when the relation is

PartitionNode | None

neither partitioned nor a partition.

Source code in pg_partsmith/sync/protocols.py
def get_partition_tree(self, table_name: str) -> PartitionNode | None:
    """Return the whole partition tree rooted at ``table_name``.

    Args:
        table_name: Root of the tree, schema-qualified.

    Returns:
        The root node with its descendants, or None when the relation is
        neither partitioned nor a partition.
    """
    ...

get_unique_constraint_columns(table_name)

Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[tuple[str, ...], ...]

One tuple of column names per constraint.

Source code in pg_partsmith/sync/protocols.py
def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
    """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        One tuple of column names per constraint.
    """
    ...

Bases: Protocol

DDL for a parent whose partition key spans several columns.

Separate from :class:PartitionRepository for the same reason as the nested protocols: a repository written before composite keys keeps satisfying the flat one, and is only required to grow this method once a config actually declares a multi-column key.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class CompositeKeyRepository(Protocol):
    """DDL for a parent whose partition key spans several columns.

    Separate from :class:`PartitionRepository` for the same reason as the
    nested protocols: a repository written before composite keys keeps
    satisfying the flat one, and is only required to grow this method once a
    config actually declares a multi-column key.
    """

    def attach_composite_partition(
        self,
        table_name: str,
        partition_name: str,
        from_value: str,
        to_value: str,
        *,
        key_arity: int,
    ) -> None:
        """Attach a partition, padding the trailing key columns with MINVALUE.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.
            from_value: Start boundary for the leading column.
            to_value: End boundary for the leading column.
            key_arity: Number of columns in the parent's partition key.
        """
        ...

    def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        trailing_columns: tuple[str, ...] = (),
        from_value: str,
        to_value: str,
    ) -> int:
        """Move conflicting rows from a DEFAULT partition, honouring the whole key.

        The composite widening of :meth:`PartitionRepository.reconcile_default_rows`.
        It is declared here rather than on the base protocol so an
        implementation written against the single-column signature keeps
        satisfying ``PartitionRepository`` -- for a type checker as much as at
        runtime.

        PostgreSQL adds an IS NOT NULL test for every key column to a range
        partition's constraint, so a row with a NULL trailing key value belongs
        in DEFAULT and has to be left there.

        Args:
            default_partition_name: Qualified name of DEFAULT partition.
            target_partition_name: Qualified name of target partition.
            partition_column: Leading column of the partition key.
            trailing_columns: The remaining key columns, in key order.
            from_value: Range start boundary (inclusive).
            to_value: Range end boundary (exclusive).

        Returns:
            Number of rows moved.
        """
        ...

attach_composite_partition(table_name, partition_name, from_value, to_value, *, key_arity)

Attach a partition, padding the trailing key columns with MINVALUE.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required
from_value str

Start boundary for the leading column.

required
to_value str

End boundary for the leading column.

required
key_arity int

Number of columns in the parent's partition key.

required
Source code in pg_partsmith/sync/protocols.py
def attach_composite_partition(
    self,
    table_name: str,
    partition_name: str,
    from_value: str,
    to_value: str,
    *,
    key_arity: int,
) -> None:
    """Attach a partition, padding the trailing key columns with MINVALUE.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.
        from_value: Start boundary for the leading column.
        to_value: End boundary for the leading column.
        key_arity: Number of columns in the parent's partition key.
    """
    ...

reconcile_default_rows(*, default_partition_name, target_partition_name, partition_column, trailing_columns=(), from_value, to_value)

Move conflicting rows from a DEFAULT partition, honouring the whole key.

The composite widening of :meth:PartitionRepository.reconcile_default_rows. It is declared here rather than on the base protocol so an implementation written against the single-column signature keeps satisfying PartitionRepository -- for a type checker as much as at runtime.

PostgreSQL adds an IS NOT NULL test for every key column to a range partition's constraint, so a row with a NULL trailing key value belongs in DEFAULT and has to be left there.

Parameters:

Name Type Description Default
default_partition_name str

Qualified name of DEFAULT partition.

required
target_partition_name str

Qualified name of target partition.

required
partition_column str

Leading column of the partition key.

required
trailing_columns tuple[str, ...]

The remaining key columns, in key order.

()
from_value str

Range start boundary (inclusive).

required
to_value str

Range end boundary (exclusive).

required

Returns:

Type Description
int

Number of rows moved.

Source code in pg_partsmith/sync/protocols.py
def reconcile_default_rows(
    self,
    *,
    default_partition_name: str,
    target_partition_name: str,
    partition_column: str,
    trailing_columns: tuple[str, ...] = (),
    from_value: str,
    to_value: str,
) -> int:
    """Move conflicting rows from a DEFAULT partition, honouring the whole key.

    The composite widening of :meth:`PartitionRepository.reconcile_default_rows`.
    It is declared here rather than on the base protocol so an
    implementation written against the single-column signature keeps
    satisfying ``PartitionRepository`` -- for a type checker as much as at
    runtime.

    PostgreSQL adds an IS NOT NULL test for every key column to a range
    partition's constraint, so a row with a NULL trailing key value belongs
    in DEFAULT and has to be left there.

    Args:
        default_partition_name: Qualified name of DEFAULT partition.
        target_partition_name: Qualified name of target partition.
        partition_column: Leading column of the partition key.
        trailing_columns: The remaining key columns, in key order.
        from_value: Range start boundary (inclusive).
        to_value: Range end boundary (exclusive).

    Returns:
        Number of rows moved.
    """
    ...

Bases: Protocol

Introspection of a partition key that spans several columns.

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class CompositeKeyMetadata(Protocol):
    """Introspection of a partition key that spans several columns."""

    def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
        """Return a table's own partition key columns, in key order.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            The key columns in order; empty when the table is not partitioned.
        """
        ...

get_partition_columns(table_name)

Return a table's own partition key columns, in key order.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

The key columns in order; empty when the table is not partitioned.

Source code in pg_partsmith/sync/protocols.py
def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
    """Return a table's own partition key columns, in key order.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        The key columns in order; empty when the table is not partitioned.
    """
    ...

Bases: Protocol

Lock manager for coordinating partition operations.

This protocol defines the interface for acquiring and releasing locks to prevent concurrent partition operations on the same table. Implement this to use a different locking backend (e.g. Zookeeper).

Source code in pg_partsmith/sync/protocols.py
@runtime_checkable
class LockManager(Protocol):
    """Lock manager for coordinating partition operations.

    This protocol defines the interface for acquiring and releasing locks
    to prevent concurrent partition operations on the same table.
    Implement this to use a different locking backend (e.g. Zookeeper).
    """

    def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
        """Acquire lock for partition operations on a table.

        Args:
            table_name: Table name to lock.

        Returns:
            Context manager for the lock.

        Raises:
            LockAcquisitionError: If unable to acquire lock.
        """
        ...

    def is_locked(self, table_name: str) -> bool:
        """Check if table is currently locked.

        Args:
            table_name: Table name.

        Returns:
            True if table is locked.
        """
        ...

acquire_lock(table_name)

Acquire lock for partition operations on a table.

Parameters:

Name Type Description Default
table_name str

Table name to lock.

required

Returns:

Type Description
AbstractContextManager[None]

Context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If unable to acquire lock.

Source code in pg_partsmith/sync/protocols.py
def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
    """Acquire lock for partition operations on a table.

    Args:
        table_name: Table name to lock.

    Returns:
        Context manager for the lock.

    Raises:
        LockAcquisitionError: If unable to acquire lock.
    """
    ...

is_locked(table_name)

Check if table is currently locked.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
bool

True if table is locked.

Source code in pg_partsmith/sync/protocols.py
def is_locked(self, table_name: str) -> bool:
    """Check if table is currently locked.

    Args:
        table_name: Table name.

    Returns:
        True if table is locked.
    """
    ...

PostgreSQL implementations

PostgreSQL implementation of partition repository.

Facade that delegates to specialized helper classes for improved maintenance and SRP.

Unlike the async implementation, ddl_timeout_seconds is enforced server-side via PostgreSQL statement_timeout (per statement) rather than client-side around the whole operation.

Source code in pg_partsmith/sync/repositories/repository.py
class PostgresPartitionRepository:
    """PostgreSQL implementation of partition repository.

    Facade that delegates to specialized helper classes for improved maintenance and SRP.

    Unlike the async implementation, ``ddl_timeout_seconds`` is enforced
    server-side via PostgreSQL ``statement_timeout`` (per statement) rather
    than client-side around the whole operation.
    """

    def __init__(
        self,
        engine: Engine,
        *,
        ddl_timezone: str | None = DEFAULT_DDL_TIMEZONE,
        ddl_timeout_seconds: float = DEFAULT_DDL_TIMEOUT_SECONDS,
        marker_prefix: str | None = None,
        drop_allow_unmanaged: bool = False,
        drop_lock_timeout_ms: int = DEFAULT_DROP_LOCK_TIMEOUT_MS,
        drop_max_retries: int = DEFAULT_DROP_MAX_RETRIES,
        drop_retry_delay: float = DEFAULT_DROP_RETRY_DELAY,
        drop_max_backoff: float = DEFAULT_DROP_MAX_BACKOFF,
    ) -> None:
        marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
        ddl_timeout_seconds = validate_ddl_timeout(ddl_timeout_seconds)
        self._ddl_timezone = validate_timezone(ddl_timezone)
        drop_lock_timeout_ms = validate_int(drop_lock_timeout_ms, "drop_lock_timeout_ms", min_val=0)
        drop_max_retries = validate_int(drop_max_retries, "drop_max_retries", min_val=1)
        drop_retry_delay = validate_float(drop_retry_delay, "drop_retry_delay", min_val=0.0)
        drop_max_backoff = validate_float(drop_max_backoff, "drop_max_backoff", min_val=0.0)

        self._resolver = PartitionRelationResolver(engine)
        self._fk_manager = PartitionForeignKeyManager(engine, ddl_timeout_seconds)
        self._creator = PartitionCreator(
            engine=engine,
            ddl_timeout=ddl_timeout_seconds,
            ddl_timezone=self._ddl_timezone,
        )
        self._remover = PartitionRemover(
            engine=engine,
            ddl_timeout=ddl_timeout_seconds,
            drop_lock_timeout_ms=drop_lock_timeout_ms,
            drop_max_retries=drop_max_retries,
            drop_retry_delay=drop_retry_delay,
            drop_max_backoff=drop_max_backoff,
            marker_prefix=marker_prefix,
            resolver=self._resolver,
            fk_manager=self._fk_manager,
            allow_unmanaged=bool(drop_allow_unmanaged),
        )

    @property
    def ddl_timezone(self) -> str | None:
        """Timezone applied via ``SET LOCAL TIME ZONE`` around boundary-sensitive DDL.

        ``None`` means the session timezone is trusted as-is.
        """
        return self._ddl_timezone

    def create_partition(
        self, config: TablePartitionConfig, partition_name: str, from_value: str, to_value: str
    ) -> PartitionInfo:
        return self._creator.create(config, partition_name, from_value, to_value)

    def attach_partition(self, table_name: str, partition_name: str, from_value: str, to_value: str) -> None:
        self._creator.attach(table_name, partition_name, from_value, to_value)

    def create_branch(
        self,
        config: TablePartitionConfig,
        branch_name: str,
        from_value: str,
        to_value: str,
        spec: SubpartitionSpec,
    ) -> PartitionInfo:
        """Create a detached time partition that is itself partitioned.

        See :meth:`PartitionCreator.create_branch`. Its buckets are created
        separately and the branch is attached last, so an interrupted run can
        never leave a partially-covering branch reachable from the root.
        """
        return self._creator.create_branch(config, branch_name, from_value, to_value, spec)

    def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
        """Create a detached table shaped like ``parent_name``.

        See :meth:`PartitionCreator.create_subpartition_table`.
        """
        self._creator.create_subpartition_table(parent_name, child_name, spec)

    def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
        """Attach one subpartition to its parent.

        See :meth:`PartitionCreator.attach_subpartition`.
        """
        self._creator.attach_subpartition(parent_name, child_name, bounds)

    def attach_composite_partition(
        self,
        table_name: str,
        partition_name: str,
        from_value: str,
        to_value: str,
        *,
        key_arity: int,
    ) -> None:
        """Attach a partition to a parent with a composite partition key.

        See :meth:`PartitionCreator.attach_composite_partition`.
        """
        self._creator.attach_composite_partition(table_name, partition_name, from_value, to_value, key_arity=key_arity)

    def detach_partition(self, table_name: str, partition_name: str, *, concurrent: bool = True) -> None:
        self._remover.detach(table_name, partition_name, concurrent=concurrent)

    def drop_partition(self, partition_name: str) -> None:
        self._remover.drop(partition_name)

    def adopt_partition(self, table_name: str, partition_name: str) -> bool:
        """Mark a detached legacy table as owned by this library (orphan marker).

        See :meth:`PartitionRemover.adopt`. Use once when migrating an existing
        partitioner instead of enabling ``drop_allow_unmanaged``.
        """
        return self._remover.adopt(table_name, partition_name)

    def reconcile_default_rows(
        self,
        *,
        default_partition_name: str,
        target_partition_name: str,
        partition_column: str,
        trailing_columns: tuple[str, ...] = (),
        from_value: str,
        to_value: str,
    ) -> int:
        return self._creator.reconcile_default_rows(
            default_partition_name=default_partition_name,
            target_partition_name=target_partition_name,
            partition_column=partition_column,
            trailing_columns=trailing_columns,
            from_value=from_value,
            to_value=to_value,
        )

ddl_timezone property

Timezone applied via SET LOCAL TIME ZONE around boundary-sensitive DDL.

None means the session timezone is trusted as-is.

adopt_partition(table_name, partition_name)

Mark a detached legacy table as owned by this library (orphan marker).

See :meth:PartitionRemover.adopt. Use once when migrating an existing partitioner instead of enabling drop_allow_unmanaged.

Source code in pg_partsmith/sync/repositories/repository.py
def adopt_partition(self, table_name: str, partition_name: str) -> bool:
    """Mark a detached legacy table as owned by this library (orphan marker).

    See :meth:`PartitionRemover.adopt`. Use once when migrating an existing
    partitioner instead of enabling ``drop_allow_unmanaged``.
    """
    return self._remover.adopt(table_name, partition_name)

attach_composite_partition(table_name, partition_name, from_value, to_value, *, key_arity)

Attach a partition to a parent with a composite partition key.

See :meth:PartitionCreator.attach_composite_partition.

Source code in pg_partsmith/sync/repositories/repository.py
def attach_composite_partition(
    self,
    table_name: str,
    partition_name: str,
    from_value: str,
    to_value: str,
    *,
    key_arity: int,
) -> None:
    """Attach a partition to a parent with a composite partition key.

    See :meth:`PartitionCreator.attach_composite_partition`.
    """
    self._creator.attach_composite_partition(table_name, partition_name, from_value, to_value, key_arity=key_arity)

attach_subpartition(parent_name, child_name, bounds)

Attach one subpartition to its parent.

See :meth:PartitionCreator.attach_subpartition.

Source code in pg_partsmith/sync/repositories/repository.py
def attach_subpartition(self, parent_name: str, child_name: str, bounds: SubpartitionBounds) -> None:
    """Attach one subpartition to its parent.

    See :meth:`PartitionCreator.attach_subpartition`.
    """
    self._creator.attach_subpartition(parent_name, child_name, bounds)

create_branch(config, branch_name, from_value, to_value, spec)

Create a detached time partition that is itself partitioned.

See :meth:PartitionCreator.create_branch. Its buckets are created separately and the branch is attached last, so an interrupted run can never leave a partially-covering branch reachable from the root.

Source code in pg_partsmith/sync/repositories/repository.py
def create_branch(
    self,
    config: TablePartitionConfig,
    branch_name: str,
    from_value: str,
    to_value: str,
    spec: SubpartitionSpec,
) -> PartitionInfo:
    """Create a detached time partition that is itself partitioned.

    See :meth:`PartitionCreator.create_branch`. Its buckets are created
    separately and the branch is attached last, so an interrupted run can
    never leave a partially-covering branch reachable from the root.
    """
    return self._creator.create_branch(config, branch_name, from_value, to_value, spec)

create_subpartition_table(parent_name, child_name, spec)

Create a detached table shaped like parent_name.

See :meth:PartitionCreator.create_subpartition_table.

Source code in pg_partsmith/sync/repositories/repository.py
def create_subpartition_table(self, parent_name: str, child_name: str, spec: SubpartitionSpec | None) -> None:
    """Create a detached table shaped like ``parent_name``.

    See :meth:`PartitionCreator.create_subpartition_table`.
    """
    self._creator.create_subpartition_table(parent_name, child_name, spec)

Provider for PostgreSQL partition metadata.

Queries pg_catalog to retrieve information about table partitioning. Override any method to customise catalog queries for your schema setup.

Each method opens its own read-only connection from the engine pool so it is safe to call outside any existing transaction.

Source code in pg_partsmith/sync/metadata.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
class PostgresMetadataProvider:
    """Provider for PostgreSQL partition metadata.

    Queries pg_catalog to retrieve information about table partitioning.
    Override any method to customise catalog queries for your schema setup.

    Each method opens its own read-only connection from the engine pool so it
    is safe to call outside any existing transaction.
    """

    def __init__(
        self,
        engine: Engine,
        *,
        marker_prefix: str | None = None,
        boundary_codec: RangeBoundaryCodec | None = None,
        ddl_timezone: str | None = None,
    ) -> None:
        """Initialize provider.

        Args:
            engine: SQLAlchemy engine.
            marker_prefix: Optional COMMENT marker prefix for orphaned partitions.
                When None, the library default prefix is used. Pass the same
                value to both repository and metadata provider if you override it.
            ddl_timezone: Session timezone to read naive boundary literals in.
                Pass whatever the repository writes partitions with: a
                ``timestamp``/``date`` key renders its bounds without an offset,
                so reader and writer must agree or a partition is reported
                closed at the wrong moment. A ``timestamptz`` key is unaffected
                -- its literals carry an offset.
            boundary_codec: Codec used to read boundary literals back into
                instants. Required only when the partition key is an encoded
                identifier rather than a timestamp; pass the same codec the
                period calculator was built with.
        """
        self._engine = engine
        self._marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
        self._boundary_codec = boundary_codec
        self._ddl_timezone = ddl_timezone

    def get_partition_type(self, table_name: str) -> PartitionType | None:
        """Get partition type for a table."""
        with self._engine.connect() as conn:
            result = conn.execute(
                text(
                    """
                    SELECT partstrat
                    FROM pg_partitioned_table t
                    WHERE t.partrelid = to_regclass(:table_name)
                    """
                ),
                {"table_name": to_regclass_argument(table_name)},
            )
            strat = coerce_str(result.scalar(), encoding="ascii")

        return PartitionType.from_partstrat(strat)

    def get_partition_column(self, table_name: str) -> str | None:
        """Get partition column for a table.

        Raises:
            ValueError: If the table uses a composite (multi-column) partition
                key.  Only single-column keys are supported by this library.
        """
        key = self._read_partition_key(table_name)
        if not key:
            return None

        if len(key) > 1:
            msg = (
                f"Table {table_name!r} uses a composite partition key {list(key)!r}. "
                "Only single-column partition keys are supported."
            )
            raise ValueError(msg)

        if key[0] is None:
            raise ValueError(_expression_key_message(table_name, 1))

        return key[0]

    def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
        """Return a table's own partition key columns, in key order.

        Unlike :meth:`get_partition_column`, which predates composite keys and
        refuses them, this reports the whole key. Key order is not column
        order, so it comes from ``partattrs``' own ordering.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            The key columns in order; empty when the table is not partitioned.

        Raises:
            InvalidPartitionConfigError: If any key position is an expression
                rather than a column, which this library cannot address.
        """
        key = self._read_partition_key(table_name)
        for position, column in enumerate(key, start=1):
            if column is None:
                raise InvalidPartitionConfigError(_expression_key_message(table_name, position))

        return tuple(column for column in key if column is not None)

    def _read_partition_key(self, table_name: str) -> tuple[str | None, ...]:
        """Read a table's partition key in key order, expressions included as None."""
        with self._engine.connect() as conn:
            result = conn.execute(
                text(PARTITION_COLUMNS_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        return tuple(coerce_str(row[0]) for row in rows)

    def list_partitions(self, table_name: str) -> list[PartitionInfo]:
        """List all partitions for a table, including orphaned detached ones.

        Orphaned partitions are detached-but-not-dropped tables previously
        detached by this library. They are detected by a COMMENT marker set on
        successful detach and returned with ``is_attached=False`` and ``None``
        boundaries.

        Partition names are always schema-qualified with the child's catalog
        schema — a partition may live in a different schema than its parent,
        and a bare name could resolve to an unrelated table via ``search_path``.
        """
        with self._engine.connect() as conn:
            parent_info_result = conn.execute(
                text(
                    """
                    SELECT
                        pt.partstrat,
                        ns.nspname || '.' || c.relname AS qualified_name
                    FROM pg_class c
                    JOIN pg_namespace ns ON c.relnamespace = ns.oid
                    LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
                    WHERE c.oid = to_regclass(:name)
                    """
                ),
                {"name": to_regclass_argument(table_name)},
            )
            parent_row = parent_info_result.fetchone()
            if not parent_row:
                return []

            strat = coerce_str(parent_row[0], encoding="ascii")
            parent_qualified = coerce_str(parent_row[1]) or table_name

            partition_type = PartitionType.from_partstrat(strat)
            if not partition_type:
                return []

            attached_result = conn.execute(
                text(
                    """
                    SELECT
                        ns.nspname AS partition_schema,
                        child.relname AS partition_name,
                        pg_get_expr(child.relpartbound, child.oid) AS boundaries,
                        child.relispartition AS is_attached,
                        child_pt.partstrat AS subpartstrat
                    FROM pg_inherits inh
                    JOIN pg_class child ON inh.inhrelid = child.oid
                    JOIN pg_namespace ns ON child.relnamespace = ns.oid
                    LEFT JOIN pg_partitioned_table child_pt ON child_pt.partrelid = child.oid
                    WHERE inh.inhparent = to_regclass(:table_name)
                    ORDER BY ns.nspname, child.relname
                    """
                ),
                {"table_name": to_regclass_argument(table_name)},
            )
            attached_rows = attached_result.fetchall()

            orphan_result = conn.execute(
                text(
                    """
                    SELECT
                        ns.nspname AS partition_schema,
                        c.relname AS partition_name
                    FROM pg_class c
                    JOIN pg_namespace ns ON c.relnamespace = ns.oid
                    JOIN pg_description d
                      ON d.objoid = c.oid
                     AND d.classoid = 'pg_class'::regclass
                     AND d.objsubid = 0
                    WHERE c.relkind IN ('r', 'p')
                      AND c.relispartition = false
                      AND split_part(d.description, E'\\n', 1) = :marker
                       AND NOT EXISTS (
                           SELECT 1
                           FROM pg_inherits inh
                           WHERE inh.inhrelid = c.oid
                       )
                    ORDER BY ns.nspname, c.relname
                    """
                ),
                {
                    "marker": orphan_table_comment(parent_qualified, marker_prefix=self._marker_prefix),
                },
            )
            orphan_rows = orphan_result.fetchall()

        partitions: list[PartitionInfo] = []

        for row in attached_rows:
            relname = coerce_str(row.partition_name) or ""
            part_schema = coerce_str(row.partition_schema) or ""

            if not is_addressable(part_schema, relname):
                continue

            name = qualify(part_schema, relname)

            boundaries_str = coerce_str(row.boundaries) or ""
            is_default = boundaries_str.strip().upper() == "DEFAULT"
            from_val, to_val = (None, None) if is_default else self._parse_boundaries(boundaries_str)
            partitions.append(
                PartitionInfo(
                    name=name,
                    partition_type=partition_type,
                    from_value=from_val,
                    to_value=to_val,
                    boundaries_expr=boundaries_str if boundaries_str else None,
                    bounds=parse_partition_bounds(boundaries_str),
                    is_attached=row.is_attached,
                    is_default=is_default,
                    subpartition_type=PartitionType.from_partstrat(coerce_str(row.subpartstrat, encoding="ascii")),
                    parent_table=table_name,
                )
            )

        for row in orphan_rows:
            relname = coerce_str(row.partition_name) or ""
            part_schema = coerce_str(row.partition_schema) or ""

            if not is_addressable(part_schema, relname):
                continue

            name = qualify(part_schema, relname)
            partitions.append(
                PartitionInfo(
                    name=name,
                    partition_type=partition_type,
                    from_value=None,
                    to_value=None,
                    is_attached=False,
                    parent_table=table_name,
                )
            )

        return partitions

    def partition_exists(self, partition_name: str) -> bool:
        """Check if a partition table exists in pg_class.

        Args:
            partition_name: Partition table name.

        Returns:
            True if the table exists as a regular or partitioned table
            (a partition may itself be subpartitioned).
        """
        with self._engine.connect() as conn:
            result = conn.execute(
                text(RELATION_EXISTS_SQL),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            return bool(result.scalar())

    def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
        """Check if a partition is currently attached to its parent via pg_inherits.

        Args:
            table_name: Parent table name.
            partition_name: Partition table name.

        Returns:
            True if the partition is attached.
        """
        with self._engine.connect() as conn:
            result = conn.execute(
                text(PARTITION_IS_ATTACHED_SQL),
                {
                    "table_name": to_regclass_argument(table_name),
                    "partition_name": to_regclass_argument(partition_name),
                },
            )
            return bool(result.scalar())

    def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
        """Get partition boundaries.

        Args:
            partition_name: Partition table name.

        Returns:
            Tuple of (from_value, to_value) or None if not a range partition.
        """
        with self._engine.connect() as conn:
            result = conn.execute(
                text(
                    """
                    SELECT pg_get_expr(relpartbound, oid)
                    FROM pg_class
                    WHERE oid = to_regclass(:partition_name)
                    """
                ),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            boundaries_expr = coerce_str(result.scalar())

        if not boundaries_expr:
            return None

        from_val, to_val = self._parse_boundaries(boundaries_expr)
        if from_val is not None and to_val is not None:
            return from_val, to_val

        return None

    def _parse_boundaries(self, boundaries_expr: str | None) -> tuple[str | None, str | None]:
        """Delegate to :func:`pg_partsmith.partition_bounds.parse_range_boundaries`; override to customise parsing."""
        return parse_range_boundaries(boundaries_expr)

    def is_partition_closed(self, partition_name: str, *, settle_seconds: int = 0) -> bool:
        """True when the partition's upper bound (+ settle buffer) has passed.

        ``now()`` is evaluated on the server rather than on the client, so the
        answer tolerates app-clock skew. Useful for export/archive pipelines
        that must only finalize partitions which can no longer receive
        in-range rows.

        Works for a subpartitioned branch exactly as for a plain leaf: what is
        read is the branch's own RANGE bound in the root table, and its whole
        subtree closes with it.

        A naive bound -- which is what a ``timestamp`` or ``date`` key produces
        -- is resolved under this provider's ``ddl_timezone``. Configure it with
        the same value the repository writes partitions with, or the two
        disagree about when the bound falls.

        Args:
            partition_name: Attached partition table name.
            settle_seconds: Extra buffer after the upper bound for late writers
                still holding open transactions.

        Returns:
            True when ``now() >= upper_bound + settle_seconds``. False for the
            DEFAULT partition, non-RANGE partitions, unbounded upper bounds
            (MAXVALUE / infinity), detached tables, unresolvable names, and
            boundaries that carry no instant this provider can read.
        """
        with self._engine.connect() as conn:
            if self._ddl_timezone is not None:
                # A naive bound is resolved by the session timezone, so this has
                # to be the one the partition was written with. Without it the
                # server default decides, and the two need not agree.
                conn.execute(text(f"SET LOCAL TIME ZONE {quote_literal(self._ddl_timezone)}"))

            bound_result = conn.execute(
                text(PARTITION_UPPER_BOUND_SQL),
                {"partition_name": to_regclass_argument(partition_name)},
            )
            raw_bound = coerce_str(bound_result.scalar())
            if raw_bound is None:
                # No upper bound to read: DEFAULT, non-RANGE, detached, or unknown.
                return False

            if self._boundary_codec is not None:
                instant = self._boundary_codec.decode(raw_bound)
                if instant is None:
                    self._warn_unreadable_bound(partition_name, raw_bound)
                    return False
                query = INSTANT_HAS_PASSED_SQL
                upper_bound: datetime | str = instant
            else:
                query = TEXT_INSTANT_HAS_PASSED_SQL
                upper_bound = raw_bound

            try:
                result = conn.execute(
                    text(query),
                    {"upper_bound": upper_bound, "settle_seconds": settle_seconds},
                )
            except DBAPIError:
                # A bound can look like a date and still not be one -- a
                # sortable identifier with a date-like prefix, say. Reporting
                # "not closed" is the documented answer; raising out of a
                # predicate is not.
                self._warn_unreadable_bound(partition_name, raw_bound)
                return False

            return bool(result.scalar())

    def _warn_unreadable_bound(self, partition_name: str, raw_bound: str) -> None:
        """Explain a partition that can never report as closed.

        The answer is always False while the bound cannot be read, so an export
        pipeline gated on this would wait forever with nothing to show for it.
        """
        logger.warning(
            "Partition has an upper bound this provider cannot read, so it never reports as closed; "
            "pass the boundary_codec its partitions were created with",
            extra={"partition_name": partition_name, "upper_bound": raw_bound},
        )

    def get_default_partition(self, table_name: str) -> PartitionInfo | None:
        """Get DEFAULT partition for a table if it exists and is attached.

        Args:
            table_name: Parent table name.

        Returns:
            PartitionInfo with is_default=True, or None if no default partition exists.
        """
        all_partitions = self.list_partitions(table_name)
        defaults = [p for p in all_partitions if p.is_default and p.is_attached]
        return defaults[0] if defaults else None

    def get_partition_tree(self, table_name: str) -> PartitionNode | None:
        """Return the whole partition tree rooted at ``table_name``.

        Unlike :meth:`list_partitions`, which reports the direct children a
        lifecycle acts on, this walks the hierarchy to the leaves — the shape
        subpartition reconciliation needs to know which buckets exist. One
        round-trip regardless of depth.

        Detached partitions are absent by construction: a detached branch is no
        longer part of its parent's tree. Query it by name to inspect it.

        Args:
            table_name: Root of the tree, schema-qualified.

        Returns:
            The root node with its descendants, or None when ``table_name`` is
            not partitioned and is not itself a partition.
        """
        with self._engine.connect() as conn:
            result = conn.execute(
                text(PARTITION_TREE_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        tree_rows: list[PartitionTreeRow] = []
        unaddressable_parents: set[str] = set()
        for row in rows:
            schema = coerce_str(row.partition_schema) or ""
            relname = coerce_str(row.partition_name) or ""
            parent_schema_raw = coerce_str(row.parent_schema)
            parent_relname_raw = coerce_str(row.parent_name)
            if not is_addressable(schema, relname):
                # The parent keeps a child the tree cannot show. Recording that
                # is what keeps the planner from reading the shortened child set
                # as a set of gaps to fill.
                if parent_schema_raw and parent_relname_raw:
                    unaddressable_parents.add(qualify(parent_schema_raw, parent_relname_raw))
                continue

            parent_schema = coerce_str(row.parent_schema)
            parent_relname = coerce_str(row.parent_name)
            parent_name = qualify(parent_schema, parent_relname) if parent_schema and parent_relname else None

            columns = row.partition_columns or ()
            named = tuple(str(c) for c in columns if c is not None)
            tree_rows.append(
                PartitionTreeRow(
                    level=row.level,
                    name=qualify(schema, relname),
                    parent_name=parent_name,
                    bounds=parse_partition_bounds(coerce_str(row.boundaries)),
                    is_attached=bool(row.is_attached),
                    partition_type=PartitionType.from_partstrat(coerce_str(row.partstrat, encoding="ascii")),
                    partition_columns=named,
                    # An expression key position comes back as NULL and has no
                    # name to report; what matters is that the key is wider than
                    # the names, so nothing compares it as if it were complete.
                    has_expression_key=len(named) != (row.key_arity or len(named)),
                )
            )

        return build_partition_tree(tree_rows, unaddressable_parents)

    def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
        """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

        PostgreSQL requires such a constraint on a partitioned table to contain
        all of its partition-key columns. Reading them lets a subpartitioning
        config be refused with an explanation before any DDL is attempted,
        instead of failing halfway through a maintenance run.

        Args:
            table_name: Table to inspect, schema-qualified.

        Returns:
            One tuple of column names per constraint; empty when the table has
            no unique constraints at all.
        """
        with self._engine.connect() as conn:
            result = conn.execute(
                text(UNIQUE_CONSTRAINT_COLUMNS_SQL),
                {"table_name": to_regclass_argument(table_name)},
            )
            rows = result.fetchall()

        return tuple(tuple(str(c) for c in (row.columns or ())) for row in rows)

__init__(engine, *, marker_prefix=None, boundary_codec=None, ddl_timezone=None)

Initialize provider.

Parameters:

Name Type Description Default
engine Engine

SQLAlchemy engine.

required
marker_prefix str | None

Optional COMMENT marker prefix for orphaned partitions. When None, the library default prefix is used. Pass the same value to both repository and metadata provider if you override it.

None
ddl_timezone str | None

Session timezone to read naive boundary literals in. Pass whatever the repository writes partitions with: a timestamp/date key renders its bounds without an offset, so reader and writer must agree or a partition is reported closed at the wrong moment. A timestamptz key is unaffected -- its literals carry an offset.

None
boundary_codec RangeBoundaryCodec | None

Codec used to read boundary literals back into instants. Required only when the partition key is an encoded identifier rather than a timestamp; pass the same codec the period calculator was built with.

None
Source code in pg_partsmith/sync/metadata.py
def __init__(
    self,
    engine: Engine,
    *,
    marker_prefix: str | None = None,
    boundary_codec: RangeBoundaryCodec | None = None,
    ddl_timezone: str | None = None,
) -> None:
    """Initialize provider.

    Args:
        engine: SQLAlchemy engine.
        marker_prefix: Optional COMMENT marker prefix for orphaned partitions.
            When None, the library default prefix is used. Pass the same
            value to both repository and metadata provider if you override it.
        ddl_timezone: Session timezone to read naive boundary literals in.
            Pass whatever the repository writes partitions with: a
            ``timestamp``/``date`` key renders its bounds without an offset,
            so reader and writer must agree or a partition is reported
            closed at the wrong moment. A ``timestamptz`` key is unaffected
            -- its literals carry an offset.
        boundary_codec: Codec used to read boundary literals back into
            instants. Required only when the partition key is an encoded
            identifier rather than a timestamp; pass the same codec the
            period calculator was built with.
    """
    self._engine = engine
    self._marker_prefix = orphan_comment_prefix(marker_prefix=marker_prefix)
    self._boundary_codec = boundary_codec
    self._ddl_timezone = ddl_timezone

get_default_partition(table_name)

Get DEFAULT partition for a table if it exists and is attached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required

Returns:

Type Description
PartitionInfo | None

PartitionInfo with is_default=True, or None if no default partition exists.

Source code in pg_partsmith/sync/metadata.py
def get_default_partition(self, table_name: str) -> PartitionInfo | None:
    """Get DEFAULT partition for a table if it exists and is attached.

    Args:
        table_name: Parent table name.

    Returns:
        PartitionInfo with is_default=True, or None if no default partition exists.
    """
    all_partitions = self.list_partitions(table_name)
    defaults = [p for p in all_partitions if p.is_default and p.is_attached]
    return defaults[0] if defaults else None

get_partition_boundaries(partition_name)

Get partition boundaries.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
tuple[str, str] | None

Tuple of (from_value, to_value) or None if not a range partition.

Source code in pg_partsmith/sync/metadata.py
def get_partition_boundaries(self, partition_name: str) -> tuple[str, str] | None:
    """Get partition boundaries.

    Args:
        partition_name: Partition table name.

    Returns:
        Tuple of (from_value, to_value) or None if not a range partition.
    """
    with self._engine.connect() as conn:
        result = conn.execute(
            text(
                """
                SELECT pg_get_expr(relpartbound, oid)
                FROM pg_class
                WHERE oid = to_regclass(:partition_name)
                """
            ),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        boundaries_expr = coerce_str(result.scalar())

    if not boundaries_expr:
        return None

    from_val, to_val = self._parse_boundaries(boundaries_expr)
    if from_val is not None and to_val is not None:
        return from_val, to_val

    return None

get_partition_column(table_name)

Get partition column for a table.

Raises:

Type Description
ValueError

If the table uses a composite (multi-column) partition key. Only single-column keys are supported by this library.

Source code in pg_partsmith/sync/metadata.py
def get_partition_column(self, table_name: str) -> str | None:
    """Get partition column for a table.

    Raises:
        ValueError: If the table uses a composite (multi-column) partition
            key.  Only single-column keys are supported by this library.
    """
    key = self._read_partition_key(table_name)
    if not key:
        return None

    if len(key) > 1:
        msg = (
            f"Table {table_name!r} uses a composite partition key {list(key)!r}. "
            "Only single-column partition keys are supported."
        )
        raise ValueError(msg)

    if key[0] is None:
        raise ValueError(_expression_key_message(table_name, 1))

    return key[0]

get_partition_columns(table_name)

Return a table's own partition key columns, in key order.

Unlike :meth:get_partition_column, which predates composite keys and refuses them, this reports the whole key. Key order is not column order, so it comes from partattrs' own ordering.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

The key columns in order; empty when the table is not partitioned.

Raises:

Type Description
InvalidPartitionConfigError

If any key position is an expression rather than a column, which this library cannot address.

Source code in pg_partsmith/sync/metadata.py
def get_partition_columns(self, table_name: str) -> tuple[str, ...]:
    """Return a table's own partition key columns, in key order.

    Unlike :meth:`get_partition_column`, which predates composite keys and
    refuses them, this reports the whole key. Key order is not column
    order, so it comes from ``partattrs``' own ordering.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        The key columns in order; empty when the table is not partitioned.

    Raises:
        InvalidPartitionConfigError: If any key position is an expression
            rather than a column, which this library cannot address.
    """
    key = self._read_partition_key(table_name)
    for position, column in enumerate(key, start=1):
        if column is None:
            raise InvalidPartitionConfigError(_expression_key_message(table_name, position))

    return tuple(column for column in key if column is not None)

get_partition_tree(table_name)

Return the whole partition tree rooted at table_name.

Unlike :meth:list_partitions, which reports the direct children a lifecycle acts on, this walks the hierarchy to the leaves — the shape subpartition reconciliation needs to know which buckets exist. One round-trip regardless of depth.

Detached partitions are absent by construction: a detached branch is no longer part of its parent's tree. Query it by name to inspect it.

Parameters:

Name Type Description Default
table_name str

Root of the tree, schema-qualified.

required

Returns:

Type Description
PartitionNode | None

The root node with its descendants, or None when table_name is

PartitionNode | None

not partitioned and is not itself a partition.

Source code in pg_partsmith/sync/metadata.py
def get_partition_tree(self, table_name: str) -> PartitionNode | None:
    """Return the whole partition tree rooted at ``table_name``.

    Unlike :meth:`list_partitions`, which reports the direct children a
    lifecycle acts on, this walks the hierarchy to the leaves — the shape
    subpartition reconciliation needs to know which buckets exist. One
    round-trip regardless of depth.

    Detached partitions are absent by construction: a detached branch is no
    longer part of its parent's tree. Query it by name to inspect it.

    Args:
        table_name: Root of the tree, schema-qualified.

    Returns:
        The root node with its descendants, or None when ``table_name`` is
        not partitioned and is not itself a partition.
    """
    with self._engine.connect() as conn:
        result = conn.execute(
            text(PARTITION_TREE_SQL),
            {"table_name": to_regclass_argument(table_name)},
        )
        rows = result.fetchall()

    tree_rows: list[PartitionTreeRow] = []
    unaddressable_parents: set[str] = set()
    for row in rows:
        schema = coerce_str(row.partition_schema) or ""
        relname = coerce_str(row.partition_name) or ""
        parent_schema_raw = coerce_str(row.parent_schema)
        parent_relname_raw = coerce_str(row.parent_name)
        if not is_addressable(schema, relname):
            # The parent keeps a child the tree cannot show. Recording that
            # is what keeps the planner from reading the shortened child set
            # as a set of gaps to fill.
            if parent_schema_raw and parent_relname_raw:
                unaddressable_parents.add(qualify(parent_schema_raw, parent_relname_raw))
            continue

        parent_schema = coerce_str(row.parent_schema)
        parent_relname = coerce_str(row.parent_name)
        parent_name = qualify(parent_schema, parent_relname) if parent_schema and parent_relname else None

        columns = row.partition_columns or ()
        named = tuple(str(c) for c in columns if c is not None)
        tree_rows.append(
            PartitionTreeRow(
                level=row.level,
                name=qualify(schema, relname),
                parent_name=parent_name,
                bounds=parse_partition_bounds(coerce_str(row.boundaries)),
                is_attached=bool(row.is_attached),
                partition_type=PartitionType.from_partstrat(coerce_str(row.partstrat, encoding="ascii")),
                partition_columns=named,
                # An expression key position comes back as NULL and has no
                # name to report; what matters is that the key is wider than
                # the names, so nothing compares it as if it were complete.
                has_expression_key=len(named) != (row.key_arity or len(named)),
            )
        )

    return build_partition_tree(tree_rows, unaddressable_parents)

get_partition_type(table_name)

Get partition type for a table.

Source code in pg_partsmith/sync/metadata.py
def get_partition_type(self, table_name: str) -> PartitionType | None:
    """Get partition type for a table."""
    with self._engine.connect() as conn:
        result = conn.execute(
            text(
                """
                SELECT partstrat
                FROM pg_partitioned_table t
                WHERE t.partrelid = to_regclass(:table_name)
                """
            ),
            {"table_name": to_regclass_argument(table_name)},
        )
        strat = coerce_str(result.scalar(), encoding="ascii")

    return PartitionType.from_partstrat(strat)

get_unique_constraint_columns(table_name)

Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

PostgreSQL requires such a constraint on a partitioned table to contain all of its partition-key columns. Reading them lets a subpartitioning config be refused with an explanation before any DDL is attempted, instead of failing halfway through a maintenance run.

Parameters:

Name Type Description Default
table_name str

Table to inspect, schema-qualified.

required

Returns:

Type Description
tuple[str, ...]

One tuple of column names per constraint; empty when the table has

...

no unique constraints at all.

Source code in pg_partsmith/sync/metadata.py
def get_unique_constraint_columns(self, table_name: str) -> tuple[tuple[str, ...], ...]:
    """Return the column tuples of every UNIQUE / PRIMARY KEY constraint.

    PostgreSQL requires such a constraint on a partitioned table to contain
    all of its partition-key columns. Reading them lets a subpartitioning
    config be refused with an explanation before any DDL is attempted,
    instead of failing halfway through a maintenance run.

    Args:
        table_name: Table to inspect, schema-qualified.

    Returns:
        One tuple of column names per constraint; empty when the table has
        no unique constraints at all.
    """
    with self._engine.connect() as conn:
        result = conn.execute(
            text(UNIQUE_CONSTRAINT_COLUMNS_SQL),
            {"table_name": to_regclass_argument(table_name)},
        )
        rows = result.fetchall()

    return tuple(tuple(str(c) for c in (row.columns or ())) for row in rows)

is_partition_attached(table_name, partition_name)

Check if a partition is currently attached to its parent via pg_inherits.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the partition is attached.

Source code in pg_partsmith/sync/metadata.py
def is_partition_attached(self, table_name: str, partition_name: str) -> bool:
    """Check if a partition is currently attached to its parent via pg_inherits.

    Args:
        table_name: Parent table name.
        partition_name: Partition table name.

    Returns:
        True if the partition is attached.
    """
    with self._engine.connect() as conn:
        result = conn.execute(
            text(PARTITION_IS_ATTACHED_SQL),
            {
                "table_name": to_regclass_argument(table_name),
                "partition_name": to_regclass_argument(partition_name),
            },
        )
        return bool(result.scalar())

is_partition_closed(partition_name, *, settle_seconds=0)

True when the partition's upper bound (+ settle buffer) has passed.

now() is evaluated on the server rather than on the client, so the answer tolerates app-clock skew. Useful for export/archive pipelines that must only finalize partitions which can no longer receive in-range rows.

Works for a subpartitioned branch exactly as for a plain leaf: what is read is the branch's own RANGE bound in the root table, and its whole subtree closes with it.

A naive bound -- which is what a timestamp or date key produces -- is resolved under this provider's ddl_timezone. Configure it with the same value the repository writes partitions with, or the two disagree about when the bound falls.

Parameters:

Name Type Description Default
partition_name str

Attached partition table name.

required
settle_seconds int

Extra buffer after the upper bound for late writers still holding open transactions.

0

Returns:

Type Description
bool

True when now() >= upper_bound + settle_seconds. False for the

bool

DEFAULT partition, non-RANGE partitions, unbounded upper bounds

bool

(MAXVALUE / infinity), detached tables, unresolvable names, and

bool

boundaries that carry no instant this provider can read.

Source code in pg_partsmith/sync/metadata.py
def is_partition_closed(self, partition_name: str, *, settle_seconds: int = 0) -> bool:
    """True when the partition's upper bound (+ settle buffer) has passed.

    ``now()`` is evaluated on the server rather than on the client, so the
    answer tolerates app-clock skew. Useful for export/archive pipelines
    that must only finalize partitions which can no longer receive
    in-range rows.

    Works for a subpartitioned branch exactly as for a plain leaf: what is
    read is the branch's own RANGE bound in the root table, and its whole
    subtree closes with it.

    A naive bound -- which is what a ``timestamp`` or ``date`` key produces
    -- is resolved under this provider's ``ddl_timezone``. Configure it with
    the same value the repository writes partitions with, or the two
    disagree about when the bound falls.

    Args:
        partition_name: Attached partition table name.
        settle_seconds: Extra buffer after the upper bound for late writers
            still holding open transactions.

    Returns:
        True when ``now() >= upper_bound + settle_seconds``. False for the
        DEFAULT partition, non-RANGE partitions, unbounded upper bounds
        (MAXVALUE / infinity), detached tables, unresolvable names, and
        boundaries that carry no instant this provider can read.
    """
    with self._engine.connect() as conn:
        if self._ddl_timezone is not None:
            # A naive bound is resolved by the session timezone, so this has
            # to be the one the partition was written with. Without it the
            # server default decides, and the two need not agree.
            conn.execute(text(f"SET LOCAL TIME ZONE {quote_literal(self._ddl_timezone)}"))

        bound_result = conn.execute(
            text(PARTITION_UPPER_BOUND_SQL),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        raw_bound = coerce_str(bound_result.scalar())
        if raw_bound is None:
            # No upper bound to read: DEFAULT, non-RANGE, detached, or unknown.
            return False

        if self._boundary_codec is not None:
            instant = self._boundary_codec.decode(raw_bound)
            if instant is None:
                self._warn_unreadable_bound(partition_name, raw_bound)
                return False
            query = INSTANT_HAS_PASSED_SQL
            upper_bound: datetime | str = instant
        else:
            query = TEXT_INSTANT_HAS_PASSED_SQL
            upper_bound = raw_bound

        try:
            result = conn.execute(
                text(query),
                {"upper_bound": upper_bound, "settle_seconds": settle_seconds},
            )
        except DBAPIError:
            # A bound can look like a date and still not be one -- a
            # sortable identifier with a date-like prefix, say. Reporting
            # "not closed" is the documented answer; raising out of a
            # predicate is not.
            self._warn_unreadable_bound(partition_name, raw_bound)
            return False

        return bool(result.scalar())

list_partitions(table_name)

List all partitions for a table, including orphaned detached ones.

Orphaned partitions are detached-but-not-dropped tables previously detached by this library. They are detected by a COMMENT marker set on successful detach and returned with is_attached=False and None boundaries.

Partition names are always schema-qualified with the child's catalog schema — a partition may live in a different schema than its parent, and a bare name could resolve to an unrelated table via search_path.

Source code in pg_partsmith/sync/metadata.py
def list_partitions(self, table_name: str) -> list[PartitionInfo]:
    """List all partitions for a table, including orphaned detached ones.

    Orphaned partitions are detached-but-not-dropped tables previously
    detached by this library. They are detected by a COMMENT marker set on
    successful detach and returned with ``is_attached=False`` and ``None``
    boundaries.

    Partition names are always schema-qualified with the child's catalog
    schema — a partition may live in a different schema than its parent,
    and a bare name could resolve to an unrelated table via ``search_path``.
    """
    with self._engine.connect() as conn:
        parent_info_result = conn.execute(
            text(
                """
                SELECT
                    pt.partstrat,
                    ns.nspname || '.' || c.relname AS qualified_name
                FROM pg_class c
                JOIN pg_namespace ns ON c.relnamespace = ns.oid
                LEFT JOIN pg_partitioned_table pt ON pt.partrelid = c.oid
                WHERE c.oid = to_regclass(:name)
                """
            ),
            {"name": to_regclass_argument(table_name)},
        )
        parent_row = parent_info_result.fetchone()
        if not parent_row:
            return []

        strat = coerce_str(parent_row[0], encoding="ascii")
        parent_qualified = coerce_str(parent_row[1]) or table_name

        partition_type = PartitionType.from_partstrat(strat)
        if not partition_type:
            return []

        attached_result = conn.execute(
            text(
                """
                SELECT
                    ns.nspname AS partition_schema,
                    child.relname AS partition_name,
                    pg_get_expr(child.relpartbound, child.oid) AS boundaries,
                    child.relispartition AS is_attached,
                    child_pt.partstrat AS subpartstrat
                FROM pg_inherits inh
                JOIN pg_class child ON inh.inhrelid = child.oid
                JOIN pg_namespace ns ON child.relnamespace = ns.oid
                LEFT JOIN pg_partitioned_table child_pt ON child_pt.partrelid = child.oid
                WHERE inh.inhparent = to_regclass(:table_name)
                ORDER BY ns.nspname, child.relname
                """
            ),
            {"table_name": to_regclass_argument(table_name)},
        )
        attached_rows = attached_result.fetchall()

        orphan_result = conn.execute(
            text(
                """
                SELECT
                    ns.nspname AS partition_schema,
                    c.relname AS partition_name
                FROM pg_class c
                JOIN pg_namespace ns ON c.relnamespace = ns.oid
                JOIN pg_description d
                  ON d.objoid = c.oid
                 AND d.classoid = 'pg_class'::regclass
                 AND d.objsubid = 0
                WHERE c.relkind IN ('r', 'p')
                  AND c.relispartition = false
                  AND split_part(d.description, E'\\n', 1) = :marker
                   AND NOT EXISTS (
                       SELECT 1
                       FROM pg_inherits inh
                       WHERE inh.inhrelid = c.oid
                   )
                ORDER BY ns.nspname, c.relname
                """
            ),
            {
                "marker": orphan_table_comment(parent_qualified, marker_prefix=self._marker_prefix),
            },
        )
        orphan_rows = orphan_result.fetchall()

    partitions: list[PartitionInfo] = []

    for row in attached_rows:
        relname = coerce_str(row.partition_name) or ""
        part_schema = coerce_str(row.partition_schema) or ""

        if not is_addressable(part_schema, relname):
            continue

        name = qualify(part_schema, relname)

        boundaries_str = coerce_str(row.boundaries) or ""
        is_default = boundaries_str.strip().upper() == "DEFAULT"
        from_val, to_val = (None, None) if is_default else self._parse_boundaries(boundaries_str)
        partitions.append(
            PartitionInfo(
                name=name,
                partition_type=partition_type,
                from_value=from_val,
                to_value=to_val,
                boundaries_expr=boundaries_str if boundaries_str else None,
                bounds=parse_partition_bounds(boundaries_str),
                is_attached=row.is_attached,
                is_default=is_default,
                subpartition_type=PartitionType.from_partstrat(coerce_str(row.subpartstrat, encoding="ascii")),
                parent_table=table_name,
            )
        )

    for row in orphan_rows:
        relname = coerce_str(row.partition_name) or ""
        part_schema = coerce_str(row.partition_schema) or ""

        if not is_addressable(part_schema, relname):
            continue

        name = qualify(part_schema, relname)
        partitions.append(
            PartitionInfo(
                name=name,
                partition_type=partition_type,
                from_value=None,
                to_value=None,
                is_attached=False,
                parent_table=table_name,
            )
        )

    return partitions

partition_exists(partition_name)

Check if a partition table exists in pg_class.

Parameters:

Name Type Description Default
partition_name str

Partition table name.

required

Returns:

Type Description
bool

True if the table exists as a regular or partitioned table

bool

(a partition may itself be subpartitioned).

Source code in pg_partsmith/sync/metadata.py
def partition_exists(self, partition_name: str) -> bool:
    """Check if a partition table exists in pg_class.

    Args:
        partition_name: Partition table name.

    Returns:
        True if the table exists as a regular or partitioned table
        (a partition may itself be subpartitioned).
    """
    with self._engine.connect() as conn:
        result = conn.execute(
            text(RELATION_EXISTS_SQL),
            {"partition_name": to_regclass_argument(partition_name)},
        )
        return bool(result.scalar())

Lock managers

Lock manager using PostgreSQL advisory locks.

Holds the advisory lock on a dedicated AUTOCOMMIT connection from the engine pool. This guarantees the lock survives any number of commits or rollbacks on the caller's session, which is required when the caller needs to commit DDL (e.g. ATTACH PARTITION) before running DETACH PARTITION CONCURRENTLY.

Override _compute_lock_id to customise the lock ID derivation.

Source code in pg_partsmith/sync/lock/postgres.py
class PostgresAdvisoryLockManager:
    """Lock manager using PostgreSQL advisory locks.

    Holds the advisory lock on a dedicated AUTOCOMMIT connection from the
    engine pool. This guarantees the lock survives any number of commits or
    rollbacks on the caller's session, which is required when the caller
    needs to commit DDL (e.g. ATTACH PARTITION) before running
    DETACH PARTITION CONCURRENTLY.

    Override `_compute_lock_id` to customise the lock ID derivation.
    """

    def __init__(
        self,
        engine: Engine,
        prefix: str = DEFAULT_LOCK_PREFIX,
        acquire_min_interval_seconds: float = 0.0,
    ) -> None:
        """Initialize lock manager.

        Args:
            engine: SQLAlchemy engine used to open a dedicated connection
                for the advisory lock.
            prefix: Prefix for lock key generation.
            acquire_min_interval_seconds: Minimum seconds between acquire attempts
                per table (rate limiting). 0 disables.
        """
        self._engine = engine
        self._prefix = prefix
        self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
        self._last_acquire_time: dict[str, float] = {}
        self._rate_limit_lock = threading.Lock()

    def _compute_lock_id(self, table_name: str) -> int:
        """Compute the advisory lock ID for a table name.

        Override this method to customise the ID derivation strategy.

        Args:
            table_name: Table name to lock.

        Returns:
            Advisory lock ID.
        """
        return calculate_lock_id(table_name, prefix=self._prefix)

    def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
        """Acquire advisory lock for a table.

        Opens a dedicated AUTOCOMMIT connection from the engine pool and
        acquires a session-level advisory lock on it. The lock is released
        when the context manager exits.

        Args:
            table_name: Table name to lock.

        Returns:
            Context manager for the lock.

        Raises:
            LockAcquisitionError: If the lock cannot be acquired.
        """
        return self._lock_scope(table_name)

    @contextmanager
    def _lock_scope(self, table_name: str) -> Iterator[None]:
        """Internal acquire/release flow for a single advisory lock."""
        self._respect_rate_limit(table_name)
        lock_id = self._compute_lock_id(table_name)

        with self._engine.connect() as base_conn:
            conn = base_conn.execution_options(isolation_level="AUTOCOMMIT")
            self._try_acquire(conn, lock_id, table_name)

            body_exc: BaseException | None = None
            try:
                yield
            except BaseException as exc:
                body_exc = exc
                raise
            finally:
                self._release_safely(conn, lock_id, table_name, body_exc)

    def _respect_rate_limit(self, table_name: str) -> None:
        """Sleep enough to enforce the configured min-interval between acquires.

        The per-table slot is reserved under the mutex; the sleep itself happens
        outside it so one table's owed delay never blocks acquires for other tables.
        """
        if self._acquire_min_interval <= 0:
            return
        with self._rate_limit_lock:
            now = time.monotonic()
            last = self._last_acquire_time.get(table_name)
            slot = now if last is None else max(now, last + self._acquire_min_interval)
            self._last_acquire_time[table_name] = slot
        delay = slot - now
        if delay > 0:
            time.sleep(delay)

    def _try_acquire(self, conn: Connection, lock_id: int, table_name: str) -> None:
        """Run ``pg_try_advisory_lock`` and raise if not granted."""
        result = conn.execute(text("SELECT pg_try_advisory_lock(:lock_id)"), {"lock_id": lock_id})
        if not result.scalar():
            raise LockAcquisitionError(table_name, "advisory lock unavailable")

    def _release_safely(
        self,
        conn: Connection,
        lock_id: int,
        table_name: str,
        body_exc: BaseException | None,
    ) -> None:
        """Release the lock; a body exception takes precedence over unlock failures."""
        try:
            self._unlock(conn, lock_id, table_name)
        except (KeyboardInterrupt, SystemExit):
            # Defensively invalidate so the connection is not returned to the pool with a dangling lock.
            with contextlib.suppress(Exception):
                conn.invalidate()
            raise
        except Exception:
            # Body exception takes precedence; otherwise propagate the unlock failure.
            if body_exc is None:
                raise
            logger.warning(
                "Failed to release advisory lock",
                extra={"table_name": table_name, "lock_id": lock_id},
            )

    def _unlock(self, conn: Connection, lock_id: int, table_name: str) -> None:
        """Run ``pg_advisory_unlock``; invalidate the connection on any failure."""
        try:
            conn.execute(text("SELECT pg_advisory_unlock(:lock_id)"), {"lock_id": lock_id})
        except (KeyboardInterrupt, SystemExit):
            raise
        except (SQLAlchemyError, OSError) as e:
            logger.warning(
                "Failed to release advisory lock (recoverable)",
                extra={"table_name": table_name, "lock_id": lock_id, "error": str(e)},
            )
            with contextlib.suppress(Exception):
                conn.invalidate()
            raise
        except Exception:
            logger.exception(
                "Unexpected error while releasing advisory lock",
                extra={"table_name": table_name, "lock_id": lock_id},
            )
            with contextlib.suppress(Exception):
                conn.invalidate()
            raise

    def is_locked(self, table_name: str) -> bool:
        """Check if lock is held by any session.

        Args:
            table_name: Table name.

        Returns:
            True if the advisory lock for the given table is currently held.
        """
        lock_id = self._compute_lock_id(table_name)
        # Split 64-bit lock_id into classid and objid as stored in pg_locks for int8 advisory locks (objsubid=1).
        class_id = (lock_id >> 32) & 0xFFFFFFFF
        if class_id > 0x7FFFFFFF:
            class_id -= 0x100000000
        obj_id = lock_id & 0xFFFFFFFF
        if obj_id > 0x7FFFFFFF:
            obj_id -= 0x100000000

        with self._engine.connect() as base_conn:
            conn = base_conn.execution_options(isolation_level="AUTOCOMMIT")
            result = conn.execute(
                text(
                    """
                    SELECT count(*)
                    FROM pg_locks
                    WHERE locktype = 'advisory'
                      AND granted = true
                      AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
                      AND classid = CAST(:class_id AS int4)
                      AND objid = CAST(:obj_id AS int4)
                      AND objsubid = 1
                    """
                ),
                {"class_id": class_id, "obj_id": obj_id},
            )
            count = result.scalar()
        return bool(count is not None and count > 0)

__init__(engine, prefix=DEFAULT_LOCK_PREFIX, acquire_min_interval_seconds=0.0)

Initialize lock manager.

Parameters:

Name Type Description Default
engine Engine

SQLAlchemy engine used to open a dedicated connection for the advisory lock.

required
prefix str

Prefix for lock key generation.

DEFAULT_LOCK_PREFIX
acquire_min_interval_seconds float

Minimum seconds between acquire attempts per table (rate limiting). 0 disables.

0.0
Source code in pg_partsmith/sync/lock/postgres.py
def __init__(
    self,
    engine: Engine,
    prefix: str = DEFAULT_LOCK_PREFIX,
    acquire_min_interval_seconds: float = 0.0,
) -> None:
    """Initialize lock manager.

    Args:
        engine: SQLAlchemy engine used to open a dedicated connection
            for the advisory lock.
        prefix: Prefix for lock key generation.
        acquire_min_interval_seconds: Minimum seconds between acquire attempts
            per table (rate limiting). 0 disables.
    """
    self._engine = engine
    self._prefix = prefix
    self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
    self._last_acquire_time: dict[str, float] = {}
    self._rate_limit_lock = threading.Lock()

acquire_lock(table_name)

Acquire advisory lock for a table.

Opens a dedicated AUTOCOMMIT connection from the engine pool and acquires a session-level advisory lock on it. The lock is released when the context manager exits.

Parameters:

Name Type Description Default
table_name str

Table name to lock.

required

Returns:

Type Description
AbstractContextManager[None]

Context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If the lock cannot be acquired.

Source code in pg_partsmith/sync/lock/postgres.py
def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
    """Acquire advisory lock for a table.

    Opens a dedicated AUTOCOMMIT connection from the engine pool and
    acquires a session-level advisory lock on it. The lock is released
    when the context manager exits.

    Args:
        table_name: Table name to lock.

    Returns:
        Context manager for the lock.

    Raises:
        LockAcquisitionError: If the lock cannot be acquired.
    """
    return self._lock_scope(table_name)

is_locked(table_name)

Check if lock is held by any session.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
bool

True if the advisory lock for the given table is currently held.

Source code in pg_partsmith/sync/lock/postgres.py
def is_locked(self, table_name: str) -> bool:
    """Check if lock is held by any session.

    Args:
        table_name: Table name.

    Returns:
        True if the advisory lock for the given table is currently held.
    """
    lock_id = self._compute_lock_id(table_name)
    # Split 64-bit lock_id into classid and objid as stored in pg_locks for int8 advisory locks (objsubid=1).
    class_id = (lock_id >> 32) & 0xFFFFFFFF
    if class_id > 0x7FFFFFFF:
        class_id -= 0x100000000
    obj_id = lock_id & 0xFFFFFFFF
    if obj_id > 0x7FFFFFFF:
        obj_id -= 0x100000000

    with self._engine.connect() as base_conn:
        conn = base_conn.execution_options(isolation_level="AUTOCOMMIT")
        result = conn.execute(
            text(
                """
                SELECT count(*)
                FROM pg_locks
                WHERE locktype = 'advisory'
                  AND granted = true
                  AND database = (SELECT oid FROM pg_database WHERE datname = current_database())
                  AND classid = CAST(:class_id AS int4)
                  AND objid = CAST(:obj_id AS int4)
                  AND objsubid = 1
                """
            ),
            {"class_id": class_id, "obj_id": obj_id},
        )
        count = result.scalar()
    return bool(count is not None and count > 0)

Lock manager using Redis for distributed coordination.

Uses SET NX EX to acquire the lock and a background renewal thread to extend the TTL while the lock is held, preventing expiry during long DDL operations (e.g. DETACH PARTITION CONCURRENTLY). The lock is released atomically via a Lua script that checks the ownership token, so it is safe even if Redis restarts during the renewal window.

The renewal interval is ttl_seconds // 3 (with random jitter to avoid thundering herds). If renewal fails — e.g. Redis is unreachable or another holder takes over — the watchdog logs a warning and stops renewing. Unlike the async version it cannot cancel the maintenance run, so the TTL becomes the upper bound on how long a stale holder can keep working.

For production use you may want to subclass and override acquire_lock to use Redlock or another algorithm with stronger guarantees.

Raises:

Type Description
ImportError

If the redis-locks optional dependency is not installed.

Source code in pg_partsmith/sync/lock/redis.py
class RedisDistributedLockManager:
    """Lock manager using Redis for distributed coordination.

    Uses ``SET NX EX`` to acquire the lock and a background renewal thread to
    extend the TTL while the lock is held, preventing expiry during long DDL
    operations (e.g. ``DETACH PARTITION CONCURRENTLY``). The lock is released
    atomically via a Lua script that checks the ownership token, so it is safe
    even if Redis restarts during the renewal window.

    The renewal interval is ``ttl_seconds // 3`` (with random jitter to avoid
    thundering herds). If renewal fails — e.g. Redis is unreachable or another
    holder takes over — the watchdog logs a warning and stops renewing. Unlike
    the async version it cannot cancel the maintenance run, so the TTL becomes
    the upper bound on how long a stale holder can keep working.

    For production use you may want to subclass and override ``acquire_lock``
    to use Redlock or another algorithm with stronger guarantees.

    Raises:
        ImportError: If the ``redis-locks`` optional dependency is not installed.
    """

    def __init__(
        self,
        redis_client: RedisClientProtocol,
        prefix: str = _DEFAULT_REDIS_LOCK_PREFIX,
        ttl_seconds: int = 300,
        acquire_min_interval_seconds: float = 0.0,
    ) -> None:
        """Initialize lock manager.

        Args:
            redis_client: Redis client instance.
            prefix: Prefix for Redis keys.
            ttl_seconds: Lock time-to-live in seconds. The lock is automatically
                renewed every ``ttl_seconds // 3`` seconds so that it does not
                expire during long DDL operations.
            acquire_min_interval_seconds: Minimum seconds between acquire attempts
                per table (rate limiting). 0 disables.

        Raises:
            ImportError: If ``redis-py`` is not installed.
            ValueError: If ``ttl_seconds`` is below the minimum.
        """
        if not _redis_available:
            msg = (
                "redis-py is required for RedisDistributedLockManager. "
                "Install it with: pip install pg-partsmith[redis-locks]"
            )
            raise ImportError(msg)

        if ttl_seconds < _MIN_TTL_SECONDS:
            msg = f"ttl_seconds must be >= {_MIN_TTL_SECONDS}, got {ttl_seconds!r}"
            raise ValueError(msg)

        self._redis = redis_client
        self._prefix = prefix
        self._ttl = ttl_seconds
        self._renew_interval = max(1, ttl_seconds // 3)

        self._unlock_script = self._redis.register_script(_UNLOCK_LUA)
        self._renew_script = self._redis.register_script(_RENEW_LUA)
        self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
        self._last_acquire_time: dict[str, float] = {}
        self._rate_limit_lock = threading.Lock()

    def _get_lock_key(self, table_name: str) -> str:
        return f"{self._prefix}:{table_name}"

    def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
        """Acquire Redis lock with automatic TTL renewal.

        Args:
            table_name: Table name.

        Returns:
            Context manager for the lock.

        Raises:
            LockAcquisitionError: If the lock is already held.
        """
        return self._lock_scope(table_name)

    @contextmanager
    def _lock_scope(self, table_name: str) -> Iterator[None]:
        """Internal acquire/release flow for a single Redis lock."""
        self._respect_rate_limit(table_name)

        key = self._get_lock_key(table_name)
        token = secrets.token_hex(16)

        try:
            acquired = self._redis.set(key, token, ex=self._ttl, nx=True)
        except (KeyboardInterrupt, SystemExit):
            # The SET may have been applied server-side before the interrupt
            # landed; the unlock script checks the token, so this is a safe
            # no-op when it was not.
            self._release_safely(key, token, table_name)
            raise

        if not acquired:
            raise LockAcquisitionError(table_name, "Redis lock unavailable")

        # From here on the key is held: any failure must release it rather than leak it until TTL.
        stop_event = threading.Event()
        watchdog: threading.Thread | None = None
        try:
            thread = threading.Thread(
                target=self._renewal_watchdog,
                args=(key, token, table_name, stop_event),
                name=f"redis-lock-watchdog:{key}",
                daemon=True,
            )
            thread.start()
            watchdog = thread
            yield
        finally:
            stop_event.set()
            try:
                if watchdog is not None:
                    watchdog.join(timeout=self._renew_interval * _RENEW_JITTER_RANGE[1] + 1.0)
            finally:
                self._release_safely(key, token, table_name)

    def _respect_rate_limit(self, table_name: str) -> None:
        """Sleep enough to enforce the configured min-interval between acquires.

        The per-table slot is reserved under the mutex; the sleep itself happens
        outside it so one table's owed delay never blocks acquires for other tables.
        """
        if self._acquire_min_interval <= 0:
            return
        with self._rate_limit_lock:
            now = time.monotonic()
            last = self._last_acquire_time.get(table_name)
            slot = now if last is None else max(now, last + self._acquire_min_interval)
            self._last_acquire_time[table_name] = slot
        delay = slot - now
        if delay > 0:
            time.sleep(delay)

    def _renewal_watchdog(
        self,
        key: str,
        token: str,
        table_name: str,
        stop_event: threading.Event,
    ) -> None:
        """Periodically extend the lock TTL until stopped or renewal fails."""
        while True:
            jitter = random.uniform(*_RENEW_JITTER_RANGE)  # noqa: S311
            if stop_event.wait(self._renew_interval * jitter):
                return
            try:
                renewed = self._renew_script(keys=[key], args=[token, str(self._ttl)])
            except (KeyboardInterrupt, SystemExit):
                raise
            except (OSError, ConnectionError, TimeoutError, RuntimeError) as exc:
                self._log_renewal_failure(key, table_name, "recoverable error", exc)
                return
            except Exception as exc:
                self._log_renewal_failure(key, table_name, "unexpected exception", exc)
                return

            if not renewed:
                self._log_renewal_failure(key, table_name, "lock lost", None)
                return

    def _log_renewal_failure(
        self,
        key: str,
        table_name: str,
        reason: str,
        exc: Exception | None,
    ) -> None:
        """Log the renewal failure; the TTL bounds how long a stale holder survives."""
        extra: dict[str, str] = {"table_name": table_name, "key": key, "reason": reason}
        if exc is not None:
            extra["error"] = str(exc)
            logger.warning(
                f"Redis lock renewal failed: {reason}; lock may expire before maintenance completes",
                extra=extra,
                exc_info=True,
            )
        else:
            logger.warning(
                f"Redis lock renewal failed: {reason}; lock may expire before maintenance completes",
                extra=extra,
            )

    def _release_safely(self, key: str, token: str, table_name: str) -> None:
        """Release the lock; failures are logged but do not propagate.

        If unlock fails, the TTL will eventually expire the lock.
        """
        try:
            self._unlock_script(keys=[key], args=[token])
        except (KeyboardInterrupt, SystemExit):
            raise
        except (OSError, ConnectionError, TimeoutError, RuntimeError) as exc:
            logger.warning(
                "Failed to release Redis lock (recoverable); TTL will expire it eventually",
                extra={
                    "table_name": table_name,
                    "key": key,
                    "error": str(exc),
                    "error_type": type(exc).__name__,
                },
            )
        except Exception:
            logger.exception(
                "Unexpected failure while releasing Redis lock",
                extra={"table_name": table_name, "key": key},
            )

    def is_locked(self, table_name: str) -> bool:
        """Return True if the Redis lock for ``table_name`` is currently held."""
        key = self._get_lock_key(table_name)
        return bool(self._redis.exists(key))

__init__(redis_client, prefix=_DEFAULT_REDIS_LOCK_PREFIX, ttl_seconds=300, acquire_min_interval_seconds=0.0)

Initialize lock manager.

Parameters:

Name Type Description Default
redis_client RedisClientProtocol

Redis client instance.

required
prefix str

Prefix for Redis keys.

_DEFAULT_REDIS_LOCK_PREFIX
ttl_seconds int

Lock time-to-live in seconds. The lock is automatically renewed every ttl_seconds // 3 seconds so that it does not expire during long DDL operations.

300
acquire_min_interval_seconds float

Minimum seconds between acquire attempts per table (rate limiting). 0 disables.

0.0

Raises:

Type Description
ImportError

If redis-py is not installed.

ValueError

If ttl_seconds is below the minimum.

Source code in pg_partsmith/sync/lock/redis.py
def __init__(
    self,
    redis_client: RedisClientProtocol,
    prefix: str = _DEFAULT_REDIS_LOCK_PREFIX,
    ttl_seconds: int = 300,
    acquire_min_interval_seconds: float = 0.0,
) -> None:
    """Initialize lock manager.

    Args:
        redis_client: Redis client instance.
        prefix: Prefix for Redis keys.
        ttl_seconds: Lock time-to-live in seconds. The lock is automatically
            renewed every ``ttl_seconds // 3`` seconds so that it does not
            expire during long DDL operations.
        acquire_min_interval_seconds: Minimum seconds between acquire attempts
            per table (rate limiting). 0 disables.

    Raises:
        ImportError: If ``redis-py`` is not installed.
        ValueError: If ``ttl_seconds`` is below the minimum.
    """
    if not _redis_available:
        msg = (
            "redis-py is required for RedisDistributedLockManager. "
            "Install it with: pip install pg-partsmith[redis-locks]"
        )
        raise ImportError(msg)

    if ttl_seconds < _MIN_TTL_SECONDS:
        msg = f"ttl_seconds must be >= {_MIN_TTL_SECONDS}, got {ttl_seconds!r}"
        raise ValueError(msg)

    self._redis = redis_client
    self._prefix = prefix
    self._ttl = ttl_seconds
    self._renew_interval = max(1, ttl_seconds // 3)

    self._unlock_script = self._redis.register_script(_UNLOCK_LUA)
    self._renew_script = self._redis.register_script(_RENEW_LUA)
    self._acquire_min_interval = max(0.0, acquire_min_interval_seconds)
    self._last_acquire_time: dict[str, float] = {}
    self._rate_limit_lock = threading.Lock()

acquire_lock(table_name)

Acquire Redis lock with automatic TTL renewal.

Parameters:

Name Type Description Default
table_name str

Table name.

required

Returns:

Type Description
AbstractContextManager[None]

Context manager for the lock.

Raises:

Type Description
LockAcquisitionError

If the lock is already held.

Source code in pg_partsmith/sync/lock/redis.py
def acquire_lock(self, table_name: str) -> AbstractContextManager[None]:
    """Acquire Redis lock with automatic TTL renewal.

    Args:
        table_name: Table name.

    Returns:
        Context manager for the lock.

    Raises:
        LockAcquisitionError: If the lock is already held.
    """
    return self._lock_scope(table_name)

is_locked(table_name)

Return True if the Redis lock for table_name is currently held.

Source code in pg_partsmith/sync/lock/redis.py
def is_locked(self, table_name: str) -> bool:
    """Return True if the Redis lock for ``table_name`` is currently held."""
    key = self._get_lock_key(table_name)
    return bool(self._redis.exists(key))

Hooks

No-op base implementation of partition lifecycle hooks.

Subclass and override only the methods you need. All methods are no-ops by default so you can selectively add behaviour without implementing every step.

Source code in pg_partsmith/sync/hooks.py
class BasePartitionLifecycleHooks:
    """No-op base implementation of partition lifecycle hooks.

    Subclass and override only the methods you need.
    All methods are no-ops by default so you can selectively add behaviour
    without implementing every step.
    """

    def before_create(
        self,
        config: TablePartitionConfig,
        partition_name: str,
        from_value: str,
        to_value: str,
    ) -> None:
        """Called before a partition is created.

        Args:
            config: Table partition configuration.
            partition_name: Name the new partition will be given.
            from_value: Start boundary value.
            to_value: End boundary value.
        """

    def after_create(
        self,
        config: TablePartitionConfig,
        partition: PartitionInfo,
    ) -> None:
        """Called after a partition has been created (and optionally attached).

        Args:
            config: Table partition configuration.
            partition: Info about the newly created partition.
        """

    def before_detach(
        self,
        table_name: str,
        partition: PartitionInfo,
    ) -> None:
        """Called before a partition is detached from its parent table.

        This is a good place to export or archive data while the partition
        is still accessible via the parent table's indexes and constraints.

        Args:
            table_name: Parent table name.
            partition: Info about the partition being detached.
        """

    def after_detach(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called after a partition has been detached.

        Args:
            table_name: Parent table name.
            partition_name: Name of the detached partition.
        """

    def before_drop(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called before a partition table is dropped.

        This is the last chance to read or export data from the partition
        before it is permanently destroyed.

        Args:
            table_name: Parent table name.
            partition_name: Name of the partition about to be dropped.
        """

    def after_drop(
        self,
        table_name: str,
        partition_name: str,
    ) -> None:
        """Called after a partition table has been dropped.

        Args:
            table_name: Parent table name.
            partition_name: Name of the dropped partition.
        """

after_create(config, partition)

Called after a partition has been created (and optionally attached).

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition PartitionInfo

Info about the newly created partition.

required
Source code in pg_partsmith/sync/hooks.py
def after_create(
    self,
    config: TablePartitionConfig,
    partition: PartitionInfo,
) -> None:
    """Called after a partition has been created (and optionally attached).

    Args:
        config: Table partition configuration.
        partition: Info about the newly created partition.
    """

after_detach(table_name, partition_name)

Called after a partition has been detached.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the detached partition.

required
Source code in pg_partsmith/sync/hooks.py
def after_detach(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called after a partition has been detached.

    Args:
        table_name: Parent table name.
        partition_name: Name of the detached partition.
    """

after_drop(table_name, partition_name)

Called after a partition table has been dropped.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the dropped partition.

required
Source code in pg_partsmith/sync/hooks.py
def after_drop(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called after a partition table has been dropped.

    Args:
        table_name: Parent table name.
        partition_name: Name of the dropped partition.
    """

before_create(config, partition_name, from_value, to_value)

Called before a partition is created.

Parameters:

Name Type Description Default
config TablePartitionConfig

Table partition configuration.

required
partition_name str

Name the new partition will be given.

required
from_value str

Start boundary value.

required
to_value str

End boundary value.

required
Source code in pg_partsmith/sync/hooks.py
def before_create(
    self,
    config: TablePartitionConfig,
    partition_name: str,
    from_value: str,
    to_value: str,
) -> None:
    """Called before a partition is created.

    Args:
        config: Table partition configuration.
        partition_name: Name the new partition will be given.
        from_value: Start boundary value.
        to_value: End boundary value.
    """

before_detach(table_name, partition)

Called before a partition is detached from its parent table.

This is a good place to export or archive data while the partition is still accessible via the parent table's indexes and constraints.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition PartitionInfo

Info about the partition being detached.

required
Source code in pg_partsmith/sync/hooks.py
def before_detach(
    self,
    table_name: str,
    partition: PartitionInfo,
) -> None:
    """Called before a partition is detached from its parent table.

    This is a good place to export or archive data while the partition
    is still accessible via the parent table's indexes and constraints.

    Args:
        table_name: Parent table name.
        partition: Info about the partition being detached.
    """

before_drop(table_name, partition_name)

Called before a partition table is dropped.

This is the last chance to read or export data from the partition before it is permanently destroyed.

Parameters:

Name Type Description Default
table_name str

Parent table name.

required
partition_name str

Name of the partition about to be dropped.

required
Source code in pg_partsmith/sync/hooks.py
def before_drop(
    self,
    table_name: str,
    partition_name: str,
) -> None:
    """Called before a partition table is dropped.

    This is the last chance to read or export data from the partition
    before it is permanently destroyed.

    Args:
        table_name: Parent table name.
        partition_name: Name of the partition about to be dropped.
    """

pg_partsmith.settings

Env-driven configuration via pydantic-settings. Requires the pydantic-settings extra: pip install pg-partsmith[pydantic-settings].

Settings

Bases: BaseSettings

Env-loadable base class that maps 1-to-1 with :class:~pg_partsmith.TablePartitionConfig.

Subclass it, set model_config with your env prefix, then call :meth:to_config to get a ready-to-use TablePartitionConfig.

All fields correspond directly to TablePartitionConfig arguments. PartitionType, PartitionStrategy, and PartitionGranularity are StrEnum values — env vars accept their lowercase string forms (e.g. GRANULARITY=month).

Example::

class OutboxSettings(PartitionTableSettings):
    model_config = SettingsConfigDict(env_prefix="OUTBOX_")

# Reads: OUTBOX_TABLE_NAME, OUTBOX_PARTITION_TYPE, OUTBOX_GRANULARITY, …
settings = OutboxSettings()
config = settings.to_config()
calculator = settings.get_period_calculator()
Source code in pg_partsmith/settings.py
class PartitionTableSettings(BaseSettings):
    """Env-loadable base class that maps 1-to-1 with :class:`~pg_partsmith.TablePartitionConfig`.

    Subclass it, set ``model_config`` with your env prefix, then call
    :meth:`to_config` to get a ready-to-use ``TablePartitionConfig``.

    All fields correspond directly to ``TablePartitionConfig`` arguments.
    ``PartitionType``, ``PartitionStrategy``, and ``PartitionGranularity``
    are ``StrEnum`` values — env vars accept their lowercase string forms
    (e.g. ``GRANULARITY=month``).

    Example::

        class OutboxSettings(PartitionTableSettings):
            model_config = SettingsConfigDict(env_prefix="OUTBOX_")

        # Reads: OUTBOX_TABLE_NAME, OUTBOX_PARTITION_TYPE, OUTBOX_GRANULARITY, …
        settings = OutboxSettings()
        config = settings.to_config()
        calculator = settings.get_period_calculator()
    """

    schema_name: str | None = Field(default=None, description="PostgreSQL schema (omit for public)")
    table_name: str = Field(..., description="Partitioned table name")
    partition_type: PartitionType = Field(..., description="Partition type: range, list, hash")
    partition_strategy: PartitionStrategy = Field(
        ...,
        description="Strategy: time_based, value_based, hash_based",
    )
    partition_column: str = Field(..., description="Leading column used for partitioning")
    trailing_partition_columns: tuple[str, ...] = Field(
        default=(),
        description='Rest of a composite partition key, as JSON: ["tenant_id"]',
    )
    granularity: PartitionGranularity | None = Field(
        default=None,
        description="Time granularity: hour, day, week, month, quarter, year",
    )
    create_ahead_count: int = Field(
        default=DEFAULT_CREATE_AHEAD_COUNT,
        ge=1,
        description="Number of periods to ensure exist, including the current period",
    )
    retention_count: int = Field(
        default=DEFAULT_RETENTION_COUNT,
        ge=1,
        description="Number of newest periods to keep, current one included",
    )
    auto_attach_after_create: bool = Field(
        default=True,
        description="Attach new partitions immediately after creation",
    )
    root_layout: SubpartitionSpec | None = Field(
        default=None,
        description=(
            "For a HASH_BASED / VALUE_BASED table, the partitions it is divided into, as JSON: "
            '{"strategy": "hash", "column": "tenant_id", "modulus": 16}'
        ),
    )
    subpartition: SubpartitionSpec | None = Field(
        default=None,
        description=(
            "Subpartitioning inside each time partition, as JSON: "
            '{"strategy": "hash", "column": "tenant_id", "modulus": 4}'
        ),
    )

    def to_config(self) -> TablePartitionConfig:
        """Build a :class:`~pg_partsmith.TablePartitionConfig` from these settings."""
        return TablePartitionConfig(
            schema=self.schema_name,
            table_name=self.table_name,
            partition_type=self.partition_type,
            partition_strategy=self.partition_strategy,
            partition_column=self.partition_column,
            trailing_partition_columns=self.trailing_partition_columns,
            granularity=self.granularity,
            create_ahead_count=self.create_ahead_count,
            retention_count=self.retention_count,
            auto_attach_after_create=self.auto_attach_after_create,
            root_layout=self.root_layout,
            subpartition=self.subpartition,
        )

    def get_period_calculator(self, tz: tzinfo = UTC) -> BasePeriodCalculator:
        """Return the period calculator matching :attr:`granularity`.

        Args:
            tz: Timezone the calculator works in (``datetime.UTC`` or a keyed
                :class:`zoneinfo.ZoneInfo`). HOUR accepts only UTC.

        Raises:
            ValueError: If ``granularity`` is ``None`` or has no registered
                calculator, or ``tz`` is unsupported for it.
        """
        if self.granularity is None:
            msg = "Cannot resolve a period calculator: granularity is not set"
            raise ValueError(msg)
        return get_period_calculator(self.granularity, tz=tz)

get_period_calculator(tz=UTC)

Return the period calculator matching :attr:granularity.

Parameters:

Name Type Description Default
tz tzinfo

Timezone the calculator works in (datetime.UTC or a keyed :class:zoneinfo.ZoneInfo). HOUR accepts only UTC.

UTC

Raises:

Type Description
ValueError

If granularity is None or has no registered calculator, or tz is unsupported for it.

Source code in pg_partsmith/settings.py
def get_period_calculator(self, tz: tzinfo = UTC) -> BasePeriodCalculator:
    """Return the period calculator matching :attr:`granularity`.

    Args:
        tz: Timezone the calculator works in (``datetime.UTC`` or a keyed
            :class:`zoneinfo.ZoneInfo`). HOUR accepts only UTC.

    Raises:
        ValueError: If ``granularity`` is ``None`` or has no registered
            calculator, or ``tz`` is unsupported for it.
    """
    if self.granularity is None:
        msg = "Cannot resolve a period calculator: granularity is not set"
        raise ValueError(msg)
    return get_period_calculator(self.granularity, tz=tz)

to_config()

Build a :class:~pg_partsmith.TablePartitionConfig from these settings.

Source code in pg_partsmith/settings.py
def to_config(self) -> TablePartitionConfig:
    """Build a :class:`~pg_partsmith.TablePartitionConfig` from these settings."""
    return TablePartitionConfig(
        schema=self.schema_name,
        table_name=self.table_name,
        partition_type=self.partition_type,
        partition_strategy=self.partition_strategy,
        partition_column=self.partition_column,
        trailing_partition_columns=self.trailing_partition_columns,
        granularity=self.granularity,
        create_ahead_count=self.create_ahead_count,
        retention_count=self.retention_count,
        auto_attach_after_create=self.auto_attach_after_create,
        root_layout=self.root_layout,
        subpartition=self.subpartition,
    )