Source code for iceberg_ops.polaris.policies

"""
iceberg_ops.polaris.policies
============================
Policy dataclass + parsing helpers for the Polaris policy framework.

This module is the layer between the raw HTTP responses returned by
:class:`~iceberg_ops.polaris.client.PolarisClient` and the dispatcher
(:mod:`iceberg_ops.polaris.dispatcher`). It owns:

* the :class:`Policy` dataclass - a normalised view of a Polaris
  policy that downstream code can rely on;
* :func:`parse_policy` - decodes ``content`` (a JSON string per the
  Polaris policy API; an already-decoded object is accepted too) and
  emits a :class:`Policy`;
* :func:`fetch_table_policies` - convenience wrapper that calls
  :meth:`PolarisClient.list_applicable_policies_for_table` and
  parses the response into a list of :class:`Policy`.

Experimental
------------
Not verified against a live Apache Polaris server. The parser reads
the policy type from a ``type`` key, and :func:`fetch_table_policies`
relies on :class:`~iceberg_ops.polaris.client.PolarisClient`, whose
endpoint paths do not match the Polaris 1.x policy API.

Why a dataclass and not just dicts?
-----------------------------------
The dispatcher reads ``policy.policy_type`` and ``policy.content``
against known schemas. Going through a dataclass:

* gives a single place to decode ``content`` from its JSON-string
  form;
* makes the contract typed at the dispatcher boundary so a
  malformed-policy bug fails at the parser, not deep inside the
  dispatcher;
* lets tests construct fake :class:`Policy` instances directly
  without touching HTTP at all.
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from typing import Any

from iceberg_ops.polaris.client import PolarisClient, PolarisError

logger = logging.getLogger(__name__)


[docs] @dataclass(frozen=True) class Policy: """A parsed Polaris policy (experimental). Attributes ---------- name : str Policy name within its namespace. policy_type : str Polaris policy type identifier - e.g. ``"system.snapshot-expiry"``, ``"system.metadata-compaction"``. Custom (non-``system.*``) types are passed through verbatim; the dispatcher will skip anything it does not recognise. (Named ``policy_type`` rather than ``type`` so the attribute does not shadow the ``type`` builtin and so Sphinx's cross-references stay unambiguous.) content : dict Parsed policy parameters. Always a dict - :func:`parse_policy` decodes the JSON-string form the Polaris API uses. description : str or None Free-text description configured on the policy. inheritable : bool Whether the policy applies to nested namespaces / tables. Mostly informational at the executor level - Polaris itself resolves inheritance and returns the effective set via the applicable-policies endpoints. version : int Polaris's monotonic version counter for the policy. """ name: str policy_type: str content: dict[str, Any] description: str | None = None inheritable: bool = False version: int = 0
[docs] def parse_policy(raw: dict[str, Any]) -> Policy: """Parse a raw Polaris policy dict into a :class:`Policy`. The ``content`` field is handled as follows: * a string (the form the Polaris policy API uses) - parsed via :mod:`json`; it must decode to an object. An empty string yields an empty dict. * a dict - already decoded (for example a hand-built policy). Used as-is. * missing - an empty dict. Anything else raises :class:`PolarisError` so the caller has a single exception type to catch. Parameters ---------- raw : dict One element of the list returned by :meth:`~iceberg_ops.polaris.client.PolarisClient.list_policies` or one of the applicable-policies endpoints. Returns ------- Policy Parsed policy. Raises ------ PolarisError On missing required fields (``name``, ``type``) or unparseable ``content``. """ if not isinstance(raw, dict): raise PolarisError(f"parse_policy expected a dict, got {type(raw).__name__}") name = raw.get("name") policy_type = raw.get("type") if not name or not policy_type: raise PolarisError(f"Polaris policy missing required field(s): {raw!r}") content_raw = raw.get("content", {}) if isinstance(content_raw, dict): content = content_raw elif isinstance(content_raw, str): # The Polaris policy API carries content as a JSON-encoded # string. An empty string yields an empty dict. if not content_raw.strip(): content = {} else: try: parsed = json.loads(content_raw) except json.JSONDecodeError as exc: raise PolarisError( f"Polaris policy {name!r} has unparseable JSON content: {exc.msg}" ) from exc if not isinstance(parsed, dict): raise PolarisError( f"Polaris policy {name!r} content parsed to " f"{type(parsed).__name__}, expected object." ) content = parsed else: raise PolarisError( f"Polaris policy {name!r} content has unexpected type " f"{type(content_raw).__name__}; expected dict or JSON string." ) return Policy( name=str(name), policy_type=str(policy_type), content=content, description=raw.get("description"), inheritable=bool(raw.get("inheritable", False)), version=int(raw.get("version", 0)), )
[docs] def fetch_table_policies( client: PolarisClient, namespace: str, table: str, ) -> list[Policy]: """Fetch and parse all policies applicable to ``namespace.table``. Combines :meth:`PolarisClient.list_applicable_policies_for_table` with :func:`parse_policy` so the caller gets a list of :class:`Policy` ready to feed to the dispatcher. Policies that fail to parse are dropped with a warning so a single malformed entry doesn't block dispatch of the rest. Parameters ---------- client : PolarisClient Client connected to the Polaris instance. namespace : str Namespace name (dot-separated for multi-level). table : str Table name within the namespace. Returns ------- list of Policy Parsed policies, with any unparseable entries silently dropped (with a logged warning). """ raw = client.list_applicable_policies_for_table(namespace, table) policies: list[Policy] = [] for entry in raw: try: policies.append(parse_policy(entry)) except PolarisError as exc: logger.warning( "Skipping unparseable policy in %s.%s: %s", namespace, table, exc, ) return policies
# Re-export so callers can ``from iceberg_ops.polaris.policies import Policy`` # without also having to import PolarisError from .client. __all__ = [ "Policy", "fetch_table_policies", "parse_policy", ]