Skip to content

Core API

The user-facing facade lives in earthcatalog.facade; earthcatalog.catalog re-exports it, so from earthcatalog import EarthCatalog keeps working.

earthcatalog.facade.EarthCatalog

Simplified facade for querying an EarthCatalog.

Combines PyIceberg catalog, table, and CatalogInfo into a single interface for spatial/temporal queries with automatic file pruning.

Example::

from earthcatalog import open as ec_open
from obstore.store import S3Store
from shapely.geometry import Point

store = S3Store(bucket='my-bucket', region='us-west-2')
ec = ec_open(store=store, base='s3://my-bucket/catalog')

point = Point(-133.99, 58.74)
paths = ec.search_files(point, start_datetime='2020-01-01')
Source code in earthcatalog/facade.py
class EarthCatalog:
    """Simplified facade for querying an EarthCatalog.

    Combines PyIceberg catalog, table, and CatalogInfo into a single interface
    for spatial/temporal queries with automatic file pruning.

    Example::

        from earthcatalog import open as ec_open
        from obstore.store import S3Store
        from shapely.geometry import Point

        store = S3Store(bucket='my-bucket', region='us-west-2')
        ec = ec_open(store=store, base='s3://my-bucket/catalog')

        point = Point(-133.99, 58.74)
        paths = ec.search_files(point, start_datetime='2020-01-01')
    """

    def __init__(
        self,
        catalog: SqlCatalog,
        table: Table,
        info: CatalogInfo,
        store: ObjectStore | None = None,
        *,
        catalog_key: str | None = None,
    ):
        """Initialize an EarthCatalog facade.

        Args:
            catalog: PyIceberg SqlCatalog instance
            table: PyIceberg Table instance
            info: CatalogInfo with grid metadata
            store: obstore Store instance (for reading hash index from S3)
            catalog_key: Key within *store* where catalog.db is persisted.
                         Required for ingest() which needs to upload changes.
        """
        self._catalog = catalog
        self._table = table
        self._info = info
        self._store = store
        self._catalog_key = catalog_key

    # --- read-only component access (the facade composes, modules cooperate) ---

    @property
    def catalog(self) -> SqlCatalog:
        """The underlying PyIceberg catalog."""
        return self._catalog

    @property
    def table(self) -> Table:
        """The underlying Iceberg ``stac_items`` table."""
        return self._table

    @table.setter
    def table(self, value: Table) -> None:
        # Full-mode ingest drops and recreates the table; the pipeline
        # re-points the facade at the replacement.
        self._table = value

    @property
    def info(self) -> CatalogInfo:
        """Grid metadata + pruning for this catalog."""
        return self._info

    @property
    def store(self) -> ObjectStore | None:
        """The backing object store (bucket-level for S3 warehouses)."""
        return self._store

    @property
    def catalog_key(self) -> str | None:
        """Key of earthcatalog.db within :attr:`store`."""
        return self._catalog_key

    def search_files(
        self,
        geom,
        start_datetime: str | datetime | None = None,
        end_datetime: str | datetime | None = None,
    ) -> list[str]:
        """Return Parquet file paths for partitions intersecting *geom*."""
        return self._info.file_paths(
            self._table,
            geom,
            start_datetime=start_datetime,
            end_datetime=end_datetime,
        )

    def search(self, **kwargs):
        """Search across the catalog, returning a deferred ``EarthCatalogItemSearch``.

        Accepts the same kwargs as :func:`rustac.search`:
        ``intersects``, ``bbox``, ``datetime``, ``filter`` (CQL2 JSON),
        ``ids``, ``collections``, ``max_items``, ``limit``, ``sortby``,
        ``include``, ``exclude``, ``query``, etc.

        Use the top-level ``datetime`` kwarg for temporal filtering.  Do
        **not** reference ``datetime`` inside the CQL2 ``filter`` —
        rustac generates broken SQL when ``datetime`` appears in a CQL2
        expression.

        Performance
        -----------
        For fastest results use :func:`earthcatalog.search.duck_search`
        (DuckDB parallel I/O, ~2× faster across all query types).
        ``search()`` and ``search_to_arrow()`` use rustac (sequential per-file)
        and have comparable speed.  See :doc:`/operations/search_performance`
        for detailed benchmarks.

        Returns
        -------
        EarthCatalogItemSearch
            A lazy, pystac_client-compatible search result.  No I/O until
            ``items()``, ``item_collection()``, or ``pages()`` is called.
        """
        from .search import EarthCatalogItemSearch, _FileSearchEngine, cleared_env_s3

        engine = _FileSearchEngine(prune_fn=self._search_prune)
        store = self._store
        return EarthCatalogItemSearch(
            params=kwargs,
            engine=engine,
            table=self._table,
            # The search layer expects a zero-arg callable returning a
            # context manager (entered lazily, per result page).
            anonymous_ctx=lambda: cleared_env_s3(store),
        )

    def search_to_arrow(self, **kwargs):
        """Search across the catalog, returning a PyArrow table."""
        from .search import _FileSearchEngine, cleared_env_s3

        engine = _FileSearchEngine(prune_fn=self._search_prune)
        with cleared_env_s3(self._store):
            return engine.search_to_arrow(**kwargs)

    def _search_prune(self, geom, start_datetime=None, end_datetime=None):
        """Prune warehouse files via Iceberg partition metadata (zero I/O)."""
        return self._info.file_paths(
            self._table, geom, start_datetime=start_datetime, end_datetime=end_datetime
        )

    def ingest_inventory(
        self,
        inventory_path: str,
        *,
        mode: str = "auto",
        config: IngestConfig | None = None,
    ) -> dict:
        """Ingest an inventory using a (optionally distributed) Dask cluster.

        Delegates to :class:`earthcatalog.pipeline.IngestPipeline`.  *config*
        (a :class:`earthcatalog.ingest_config.IngestConfig`) holds the tuning
        knobs (chunk size, compact rows, stage, resume flags, create_client).

        There is one ingest operation: *mode* only controls table handling —
        ``"full"`` drops and rebuilds the Iceberg table, ``"delta"`` appends,
        ``"auto"`` appends iff the table has rows.  Input scope (complete
        inventory vs. newer snapshot vs. precomputed delta parquet) is simply
        which *inventory_path* you pass; the unified index dedups source
        keys, so every run is resumable and idempotent.

        With ``stage="ndjson"`` (default) items are staged to per-(cell,
        year) NDJSON before a memory-bounded compaction to GeoParquet;
        ``skip_fetch`` resumes from the staged NDJSON.  Distributed runs
        shard the inventory by part file where possible — workers stream
        their own files and only the head node commits.

        Returns the run summary dict (``{"items": …, "rows": …}``).
        """
        from .pipeline import IngestPipeline

        return IngestPipeline(self, config).run(inventory_path, mode=mode)

    def download_catalog(self, local_path: str) -> None:
        """Download catalog.db from the backing store to *local_path*."""
        from .catalog import download_catalog as _download_catalog

        _download_catalog(local_path, store=self._store)

    def upload_catalog(self, local_path: str) -> None:
        """Upload catalog.db from *local_path* to the backing store."""
        from .catalog import upload_catalog as _upload_catalog

        _upload_catalog(local_path, store=self._store)

    def garbage_collect(
        self,
        inventory_path: str,
        *,
        dry_run: bool = False,
    ) -> dict:
        """Remove orphaned STAC items whose source objects left the S3 Inventory.

        Thin wrapper over :func:`earthcatalog.gc.garbage_collect_for_catalog`
        using this catalog's store, unified index, and warehouse path: Bloom
        detection, targeted GeoParquet rewrites, an Iceberg rebuild so
        searches reflect the changes, and a stats-snapshot refresh.

        Parameters
        ----------
        inventory_path:
            Path or ``s3://`` URI to the current S3 Inventory.
        dry_run:
            When ``True``, detect and report orphans but make no changes.

        Returns
        -------
        Summary dict: ``candidates``, ``confirmed``, ``orphaned``,
        ``files_rewritten``, ``rows_removed``, ``partitions_affected``,
        ``copies_outside_index``, ``residual_copies``.
        """
        from .gc import garbage_collect_for_catalog

        return garbage_collect_for_catalog(
            catalog=self._catalog,
            table=self._table,
            store=self._store,
            catalog_key=self._catalog_key,
            inventory_path=inventory_path,
            dry_run=dry_run,
        )

    @property
    def grid_type(self) -> str:
        """Return the grid partitioning system type."""
        return self._info.grid_type

    @property
    def grid_resolution(self) -> float | None:
        """Return the grid resolution (None for grids without one)."""
        return self._info.grid_resolution

    def _repr_html_(self) -> str:
        """Jupyter HTML representation — rendering lives in
        :func:`earthcatalog.stats.render_catalog_html`."""
        from .stats import render_catalog_html

        return render_catalog_html(
            self._info,
            self._table,
            self._store,
            self._catalog.properties if self._catalog is not None else {},
        )

    def __repr__(self) -> str:
        parts = [f"grid_type={self._info.grid_type!r}"]
        if self._info.grid_resolution is not None:
            parts.append(f"resolution={self._info.grid_resolution}")
        if self._info.time_bin != "year":
            parts.append(f"time_bin={self._info.time_bin!r}")
        return f"EarthCatalog({', '.join(parts)})"

Attributes

catalog property

The underlying PyIceberg catalog.

table property writable

The underlying Iceberg stac_items table.

info property

Grid metadata + pruning for this catalog.

store property

The backing object store (bucket-level for S3 warehouses).

catalog_key property

Key of earthcatalog.db within :attr:store.

grid_type property

Return the grid partitioning system type.

grid_resolution property

Return the grid resolution (None for grids without one).

Functions

__init__(catalog, table, info, store=None, *, catalog_key=None)

Initialize an EarthCatalog facade.

Parameters:

Name Type Description Default
catalog SqlCatalog

PyIceberg SqlCatalog instance

required
table Table

PyIceberg Table instance

required
info CatalogInfo

CatalogInfo with grid metadata

required
store ObjectStore | None

obstore Store instance (for reading hash index from S3)

None
catalog_key str | None

Key within store where catalog.db is persisted. Required for ingest() which needs to upload changes.

None
Source code in earthcatalog/facade.py
def __init__(
    self,
    catalog: SqlCatalog,
    table: Table,
    info: CatalogInfo,
    store: ObjectStore | None = None,
    *,
    catalog_key: str | None = None,
):
    """Initialize an EarthCatalog facade.

    Args:
        catalog: PyIceberg SqlCatalog instance
        table: PyIceberg Table instance
        info: CatalogInfo with grid metadata
        store: obstore Store instance (for reading hash index from S3)
        catalog_key: Key within *store* where catalog.db is persisted.
                     Required for ingest() which needs to upload changes.
    """
    self._catalog = catalog
    self._table = table
    self._info = info
    self._store = store
    self._catalog_key = catalog_key

search_files(geom, start_datetime=None, end_datetime=None)

Return Parquet file paths for partitions intersecting geom.

Source code in earthcatalog/facade.py
def search_files(
    self,
    geom,
    start_datetime: str | datetime | None = None,
    end_datetime: str | datetime | None = None,
) -> list[str]:
    """Return Parquet file paths for partitions intersecting *geom*."""
    return self._info.file_paths(
        self._table,
        geom,
        start_datetime=start_datetime,
        end_datetime=end_datetime,
    )

search(**kwargs)

Search across the catalog, returning a deferred EarthCatalogItemSearch.

Accepts the same kwargs as :func:rustac.search: intersects, bbox, datetime, filter (CQL2 JSON), ids, collections, max_items, limit, sortby, include, exclude, query, etc.

Use the top-level datetime kwarg for temporal filtering. Do not reference datetime inside the CQL2 filter — rustac generates broken SQL when datetime appears in a CQL2 expression.

Performance

For fastest results use :func:earthcatalog.search.duck_search (DuckDB parallel I/O, ~2× faster across all query types). search() and search_to_arrow() use rustac (sequential per-file) and have comparable speed. See :doc:/operations/search_performance for detailed benchmarks.

Returns

EarthCatalogItemSearch A lazy, pystac_client-compatible search result. No I/O until items(), item_collection(), or pages() is called.

Source code in earthcatalog/facade.py
def search(self, **kwargs):
    """Search across the catalog, returning a deferred ``EarthCatalogItemSearch``.

    Accepts the same kwargs as :func:`rustac.search`:
    ``intersects``, ``bbox``, ``datetime``, ``filter`` (CQL2 JSON),
    ``ids``, ``collections``, ``max_items``, ``limit``, ``sortby``,
    ``include``, ``exclude``, ``query``, etc.

    Use the top-level ``datetime`` kwarg for temporal filtering.  Do
    **not** reference ``datetime`` inside the CQL2 ``filter`` —
    rustac generates broken SQL when ``datetime`` appears in a CQL2
    expression.

    Performance
    -----------
    For fastest results use :func:`earthcatalog.search.duck_search`
    (DuckDB parallel I/O, ~2× faster across all query types).
    ``search()`` and ``search_to_arrow()`` use rustac (sequential per-file)
    and have comparable speed.  See :doc:`/operations/search_performance`
    for detailed benchmarks.

    Returns
    -------
    EarthCatalogItemSearch
        A lazy, pystac_client-compatible search result.  No I/O until
        ``items()``, ``item_collection()``, or ``pages()`` is called.
    """
    from .search import EarthCatalogItemSearch, _FileSearchEngine, cleared_env_s3

    engine = _FileSearchEngine(prune_fn=self._search_prune)
    store = self._store
    return EarthCatalogItemSearch(
        params=kwargs,
        engine=engine,
        table=self._table,
        # The search layer expects a zero-arg callable returning a
        # context manager (entered lazily, per result page).
        anonymous_ctx=lambda: cleared_env_s3(store),
    )

search_to_arrow(**kwargs)

Search across the catalog, returning a PyArrow table.

Source code in earthcatalog/facade.py
def search_to_arrow(self, **kwargs):
    """Search across the catalog, returning a PyArrow table."""
    from .search import _FileSearchEngine, cleared_env_s3

    engine = _FileSearchEngine(prune_fn=self._search_prune)
    with cleared_env_s3(self._store):
        return engine.search_to_arrow(**kwargs)

ingest_inventory(inventory_path, *, mode='auto', config=None)

Ingest an inventory using a (optionally distributed) Dask cluster.

Delegates to :class:earthcatalog.pipeline.IngestPipeline. config (a :class:earthcatalog.ingest_config.IngestConfig) holds the tuning knobs (chunk size, compact rows, stage, resume flags, create_client).

There is one ingest operation: mode only controls table handling — "full" drops and rebuilds the Iceberg table, "delta" appends, "auto" appends iff the table has rows. Input scope (complete inventory vs. newer snapshot vs. precomputed delta parquet) is simply which inventory_path you pass; the unified index dedups source keys, so every run is resumable and idempotent.

With stage="ndjson" (default) items are staged to per-(cell, year) NDJSON before a memory-bounded compaction to GeoParquet; skip_fetch resumes from the staged NDJSON. Distributed runs shard the inventory by part file where possible — workers stream their own files and only the head node commits.

Returns the run summary dict ({"items": …, "rows": …}).

Source code in earthcatalog/facade.py
def ingest_inventory(
    self,
    inventory_path: str,
    *,
    mode: str = "auto",
    config: IngestConfig | None = None,
) -> dict:
    """Ingest an inventory using a (optionally distributed) Dask cluster.

    Delegates to :class:`earthcatalog.pipeline.IngestPipeline`.  *config*
    (a :class:`earthcatalog.ingest_config.IngestConfig`) holds the tuning
    knobs (chunk size, compact rows, stage, resume flags, create_client).

    There is one ingest operation: *mode* only controls table handling —
    ``"full"`` drops and rebuilds the Iceberg table, ``"delta"`` appends,
    ``"auto"`` appends iff the table has rows.  Input scope (complete
    inventory vs. newer snapshot vs. precomputed delta parquet) is simply
    which *inventory_path* you pass; the unified index dedups source
    keys, so every run is resumable and idempotent.

    With ``stage="ndjson"`` (default) items are staged to per-(cell,
    year) NDJSON before a memory-bounded compaction to GeoParquet;
    ``skip_fetch`` resumes from the staged NDJSON.  Distributed runs
    shard the inventory by part file where possible — workers stream
    their own files and only the head node commits.

    Returns the run summary dict (``{"items": …, "rows": …}``).
    """
    from .pipeline import IngestPipeline

    return IngestPipeline(self, config).run(inventory_path, mode=mode)

download_catalog(local_path)

Download catalog.db from the backing store to local_path.

Source code in earthcatalog/facade.py
def download_catalog(self, local_path: str) -> None:
    """Download catalog.db from the backing store to *local_path*."""
    from .catalog import download_catalog as _download_catalog

    _download_catalog(local_path, store=self._store)

upload_catalog(local_path)

Upload catalog.db from local_path to the backing store.

Source code in earthcatalog/facade.py
def upload_catalog(self, local_path: str) -> None:
    """Upload catalog.db from *local_path* to the backing store."""
    from .catalog import upload_catalog as _upload_catalog

    _upload_catalog(local_path, store=self._store)

garbage_collect(inventory_path, *, dry_run=False)

Remove orphaned STAC items whose source objects left the S3 Inventory.

Thin wrapper over :func:earthcatalog.gc.garbage_collect_for_catalog using this catalog's store, unified index, and warehouse path: Bloom detection, targeted GeoParquet rewrites, an Iceberg rebuild so searches reflect the changes, and a stats-snapshot refresh.

Parameters

inventory_path: Path or s3:// URI to the current S3 Inventory. dry_run: When True, detect and report orphans but make no changes.

Returns

Summary dict: candidates, confirmed, orphaned, files_rewritten, rows_removed, partitions_affected, copies_outside_index, residual_copies.

Source code in earthcatalog/facade.py
def garbage_collect(
    self,
    inventory_path: str,
    *,
    dry_run: bool = False,
) -> dict:
    """Remove orphaned STAC items whose source objects left the S3 Inventory.

    Thin wrapper over :func:`earthcatalog.gc.garbage_collect_for_catalog`
    using this catalog's store, unified index, and warehouse path: Bloom
    detection, targeted GeoParquet rewrites, an Iceberg rebuild so
    searches reflect the changes, and a stats-snapshot refresh.

    Parameters
    ----------
    inventory_path:
        Path or ``s3://`` URI to the current S3 Inventory.
    dry_run:
        When ``True``, detect and report orphans but make no changes.

    Returns
    -------
    Summary dict: ``candidates``, ``confirmed``, ``orphaned``,
    ``files_rewritten``, ``rows_removed``, ``partitions_affected``,
    ``copies_outside_index``, ``residual_copies``.
    """
    from .gc import garbage_collect_for_catalog

    return garbage_collect_for_catalog(
        catalog=self._catalog,
        table=self._table,
        store=self._store,
        catalog_key=self._catalog_key,
        inventory_path=inventory_path,
        dry_run=dry_run,
    )

earthcatalog.catalog

Catalog lifecycle and grid metadata.

  • :class:CatalogInfo — grid metadata read from Iceberg table properties, owning the read-side partitioner (spatial prune + temporal bin).
  • open() / get_or_create() / download_catalog / upload_catalog — the catalog db lifecycle (open, create, persist).
  • :class:EarthCatalog — re-exported from :mod:earthcatalog.facade, where the user-facing facade lives.

Classes

CatalogInfo dataclass

Grid metadata read from Iceberg table properties.

The query-side partitioner is built lazily from these properties (same factory the ingest side uses) and cached — one partitioner per catalog, so every search prunes with exactly the grid the data was written with.

Source code in earthcatalog/catalog.py
@dataclass
class CatalogInfo:
    """Grid metadata read from Iceberg table properties.

    The query-side partitioner is built lazily from these properties (same
    factory the ingest side uses) and cached — one partitioner per catalog,
    so every search prunes with exactly the grid the data was written with.
    """

    grid_type: str
    grid_resolution: float | None
    boundaries_path: str | None
    id_field: str | None
    time_bin: str = "year"
    _cached_stats: list[dict] | None = field(default=None, repr=False)
    _cached_top_cells: list[dict] | None = field(default=None, repr=False)
    _partitioner: object | None = field(default=None, repr=False, compare=False)

    def partitioner(self):
        """The cached read-side partitioner (spatial keys + temporal bin)."""
        if self._partitioner is None:
            from .config import GridConfig
            from .grids import build_partitioner

            self._partitioner = build_partitioner(
                GridConfig(
                    type=self.grid_type,
                    resolution=self.grid_resolution,
                    boundaries_path=self.boundaries_path,
                    id_field=self.id_field,
                    time_bin=self.time_bin,
                )
            )
        return self._partitioner

    def cells_for_geometry(self, geom) -> list[str]:
        """Return the partition keys that intersect *geom*."""
        from shapely import wkb

        return self.partitioner().get_intersecting_keys(wkb.dumps(geom))

    def cell_list_sql(self, geom) -> str:
        """Return a SQL fragment suitable for ``WHERE grid_partition IN (...)``."""
        cells = self.cells_for_geometry(geom)
        if not cells:
            return "grid_partition IN (NULL)"
        quoted = ", ".join(f"'{c}'" for c in cells)
        return f"grid_partition IN ({quoted})"

    def file_paths(
        self,
        table,
        geom,
        start_datetime: str | datetime | None = None,
        end_datetime: str | datetime | None = None,
        year_lookback: int = 2,
    ) -> list[str]:
        """Return Parquet file paths for partitions overlapping *geom* and the
        temporal range.

        Overlap semantics: items carry a temporal extent
        (``start_datetime``..``end_datetime`` — velocity pairs span ~500
        days and routinely cross year boundaries), so a partition is
        relevant when the item's *start* is not after the query end and its
        *end* is not before the query start.  The partition-year window is
        widened by *year_lookback* on the start side to reach midpoints that
        fall before the query interval.
        """
        from pyiceberg.expressions import (
            And,
            GreaterThanOrEqual,
            In,
            LessThanOrEqual,
        )

        cells = self.cells_for_geometry(geom)
        if not cells:
            return []

        # NB: pyiceberg 0.11's inline stubs describe the *bound* predicate
        # constructors, not these unbound ones (runtime accepts a plain
        # string term + python values) — hence the narrow ignores here.
        expr = In("grid_partition", cells)  # type: ignore[misc,arg-type,call-arg]
        q_end = _parse_dt(end_datetime) if end_datetime is not None else None
        q_start = _parse_dt(start_datetime) if start_datetime is not None else None
        if q_end is not None:
            # Item starts before the query ends.
            expr = And(expr, LessThanOrEqual("start_datetime", q_end))  # type: ignore[misc,arg-type,call-arg,assignment]
        if q_start is not None:
            # Item ends after the query starts.
            expr = And(expr, GreaterThanOrEqual("end_datetime", q_start))  # type: ignore[misc,arg-type,call-arg,assignment]

        start_year = q_start.year - year_lookback if q_start is not None else None
        end_year = q_end.year + 1 if q_end is not None else None
        time_bin = self.time_bin

        paths = []
        for task in table.scan(row_filter=expr).plan_files():
            year = partition_year(time_bin, task.file.partition[1])
            if start_year is not None and year < start_year:
                continue
            if end_year is not None and year > end_year:
                continue
            paths.append(task.file.file_path)

        return paths

    def _ensure_stats(self, table) -> list[dict]:
        if self._cached_stats is None:
            from .stats import aggregate_top_cells, build_stats_cache

            self._cached_stats = build_stats_cache(table)
            self._cached_top_cells = aggregate_top_cells(self._cached_stats)
        return self._cached_stats

    def stats(self, table) -> list[dict]:
        """Per-partition row counts and file sizes from Iceberg manifests."""
        return self._ensure_stats(table)

    def top_cells(self, table, limit: int = 5) -> list[dict]:
        """Top partitions by row count (cached alongside :meth:`stats`)."""
        self._ensure_stats(table)
        return self._cached_top_cells[:limit]  # type: ignore[index]

    def total_files(self, table) -> int:
        """Total Parquet file count from Iceberg snapshot manifests."""
        return sum(s["file_count"] for s in self._ensure_stats(table))

    def unique_item_count(self, table, store, default_index_path: str | None = None) -> int:
        """Number of active (non-deleted) items in the unified index."""
        from .index import count_active_items, resolve_index_path

        index_path = resolve_index_path(table, default_index_path or "")
        if not index_path:
            return 0
        try:
            return count_active_items(index_path, store)
        except Exception:
            return 0

    def __repr__(self) -> str:
        parts = [f"grid_type={self.grid_type!r}"]
        if self.grid_resolution is not None:
            parts.append(f"resolution={self.grid_resolution}")
        if self.time_bin != "year":
            parts.append(f"time_bin={self.time_bin!r}")
        if self.boundaries_path is not None:
            parts.append(f"boundaries_path={self.boundaries_path!r}")
        return f"CatalogInfo({', '.join(parts)})"
Functions
partitioner()

The cached read-side partitioner (spatial keys + temporal bin).

Source code in earthcatalog/catalog.py
def partitioner(self):
    """The cached read-side partitioner (spatial keys + temporal bin)."""
    if self._partitioner is None:
        from .config import GridConfig
        from .grids import build_partitioner

        self._partitioner = build_partitioner(
            GridConfig(
                type=self.grid_type,
                resolution=self.grid_resolution,
                boundaries_path=self.boundaries_path,
                id_field=self.id_field,
                time_bin=self.time_bin,
            )
        )
    return self._partitioner
cells_for_geometry(geom)

Return the partition keys that intersect geom.

Source code in earthcatalog/catalog.py
def cells_for_geometry(self, geom) -> list[str]:
    """Return the partition keys that intersect *geom*."""
    from shapely import wkb

    return self.partitioner().get_intersecting_keys(wkb.dumps(geom))
cell_list_sql(geom)

Return a SQL fragment suitable for WHERE grid_partition IN (...).

Source code in earthcatalog/catalog.py
def cell_list_sql(self, geom) -> str:
    """Return a SQL fragment suitable for ``WHERE grid_partition IN (...)``."""
    cells = self.cells_for_geometry(geom)
    if not cells:
        return "grid_partition IN (NULL)"
    quoted = ", ".join(f"'{c}'" for c in cells)
    return f"grid_partition IN ({quoted})"
file_paths(table, geom, start_datetime=None, end_datetime=None, year_lookback=2)

Return Parquet file paths for partitions overlapping geom and the temporal range.

Overlap semantics: items carry a temporal extent (start_datetime..end_datetime — velocity pairs span ~500 days and routinely cross year boundaries), so a partition is relevant when the item's start is not after the query end and its end is not before the query start. The partition-year window is widened by year_lookback on the start side to reach midpoints that fall before the query interval.

Source code in earthcatalog/catalog.py
def file_paths(
    self,
    table,
    geom,
    start_datetime: str | datetime | None = None,
    end_datetime: str | datetime | None = None,
    year_lookback: int = 2,
) -> list[str]:
    """Return Parquet file paths for partitions overlapping *geom* and the
    temporal range.

    Overlap semantics: items carry a temporal extent
    (``start_datetime``..``end_datetime`` — velocity pairs span ~500
    days and routinely cross year boundaries), so a partition is
    relevant when the item's *start* is not after the query end and its
    *end* is not before the query start.  The partition-year window is
    widened by *year_lookback* on the start side to reach midpoints that
    fall before the query interval.
    """
    from pyiceberg.expressions import (
        And,
        GreaterThanOrEqual,
        In,
        LessThanOrEqual,
    )

    cells = self.cells_for_geometry(geom)
    if not cells:
        return []

    # NB: pyiceberg 0.11's inline stubs describe the *bound* predicate
    # constructors, not these unbound ones (runtime accepts a plain
    # string term + python values) — hence the narrow ignores here.
    expr = In("grid_partition", cells)  # type: ignore[misc,arg-type,call-arg]
    q_end = _parse_dt(end_datetime) if end_datetime is not None else None
    q_start = _parse_dt(start_datetime) if start_datetime is not None else None
    if q_end is not None:
        # Item starts before the query ends.
        expr = And(expr, LessThanOrEqual("start_datetime", q_end))  # type: ignore[misc,arg-type,call-arg,assignment]
    if q_start is not None:
        # Item ends after the query starts.
        expr = And(expr, GreaterThanOrEqual("end_datetime", q_start))  # type: ignore[misc,arg-type,call-arg,assignment]

    start_year = q_start.year - year_lookback if q_start is not None else None
    end_year = q_end.year + 1 if q_end is not None else None
    time_bin = self.time_bin

    paths = []
    for task in table.scan(row_filter=expr).plan_files():
        year = partition_year(time_bin, task.file.partition[1])
        if start_year is not None and year < start_year:
            continue
        if end_year is not None and year > end_year:
            continue
        paths.append(task.file.file_path)

    return paths
stats(table)

Per-partition row counts and file sizes from Iceberg manifests.

Source code in earthcatalog/catalog.py
def stats(self, table) -> list[dict]:
    """Per-partition row counts and file sizes from Iceberg manifests."""
    return self._ensure_stats(table)
top_cells(table, limit=5)

Top partitions by row count (cached alongside :meth:stats).

Source code in earthcatalog/catalog.py
def top_cells(self, table, limit: int = 5) -> list[dict]:
    """Top partitions by row count (cached alongside :meth:`stats`)."""
    self._ensure_stats(table)
    return self._cached_top_cells[:limit]  # type: ignore[index]
total_files(table)

Total Parquet file count from Iceberg snapshot manifests.

Source code in earthcatalog/catalog.py
def total_files(self, table) -> int:
    """Total Parquet file count from Iceberg snapshot manifests."""
    return sum(s["file_count"] for s in self._ensure_stats(table))
unique_item_count(table, store, default_index_path=None)

Number of active (non-deleted) items in the unified index.

Source code in earthcatalog/catalog.py
def unique_item_count(self, table, store, default_index_path: str | None = None) -> int:
    """Number of active (non-deleted) items in the unified index."""
    from .index import count_active_items, resolve_index_path

    index_path = resolve_index_path(table, default_index_path or "")
    if not index_path:
        return 0
    try:
        return count_active_items(index_path, store)
    except Exception:
        return 0

Functions

open_sqlite(db_path, warehouse_path)

Open a PyIceberg SqlCatalog over a local SQLite db for warehouse_path.

Part of the catalog lifecycle surface (used by the CLI, run.py, rebuild and the GC entry script). Credentials come from :func:earthcatalog.inventory.sql_catalog_props — this is the writer configuration; the read-mostly catalog built by :func:open configures anonymous/region properties itself.

Source code in earthcatalog/catalog.py
def open_sqlite(db_path: str, warehouse_path: str) -> SqlCatalog:
    """Open a PyIceberg SqlCatalog over a local SQLite db for *warehouse_path*.

    Part of the catalog lifecycle surface (used by the CLI, run.py, rebuild
    and the GC entry script).  Credentials come from
    :func:`earthcatalog.inventory.sql_catalog_props` — this is the
    *writer* configuration; the read-mostly catalog built by :func:`open`
    configures anonymous/region properties itself.
    """
    from .inventory import sql_catalog_props

    return SqlCatalog(NAMESPACE, **sql_catalog_props(db_path, warehouse_path))

download_catalog(local_path, store=None, catalog_key=None)

Pull catalog.db from store to local_path before a job starts.

Source code in earthcatalog/catalog.py
def download_catalog(
    local_path: str,
    store: ObjectStore | None = None,
    catalog_key: str | None = None,
) -> None:
    """Pull catalog.db from *store* to *local_path* before a job starts."""
    if store is None or catalog_key is None:
        store = store_config.get_store()
        catalog_key = store_config.get_catalog_key()
    try:
        result = obstore.get(store, catalog_key)
        Path(local_path).write_bytes(bytes(result.bytes()))
        print(f"Catalog downloaded: {catalog_key} -> {local_path}")
    except FileNotFoundError:
        print(f"No existing catalog at '{catalog_key}' — will create fresh.")

upload_catalog(local_path, store=None, catalog_key=None)

Push the updated catalog.db to store after all writes.

Source code in earthcatalog/catalog.py
def upload_catalog(
    local_path: str,
    store: ObjectStore | None = None,
    catalog_key: str | None = None,
) -> None:
    """Push the updated catalog.db to *store* after all writes."""
    if store is None or catalog_key is None:
        store = store_config.get_store()
        catalog_key = store_config.get_catalog_key()
    obstore.put(store, catalog_key, Path(local_path).read_bytes())
    print(f"Catalog uploaded: {local_path} -> {catalog_key}")

get_or_create(catalog, grid_config=None)

Return the stac_items table, creating it (and the namespace) if needed.

Parameters

catalog: Open SqlCatalog instance. grid_config: Optional :class:earthcatalog.config.GridConfig. When provided, grid metadata (type, resolution, boundaries_path, id_field) is stored as Iceberg table properties so that :class:CatalogInfo can reconstruct the grid system without any external configuration.

Source code in earthcatalog/catalog.py
def get_or_create(catalog: SqlCatalog, grid_config=None) -> Table:
    """Return the stac_items table, creating it (and the namespace) if needed.

    Parameters
    ----------
    catalog:
        Open SqlCatalog instance.
    grid_config:
        Optional :class:`earthcatalog.config.GridConfig`.  When provided, grid
        metadata (type, resolution, boundaries_path, id_field) is stored as
        Iceberg table properties so that :class:`CatalogInfo`
        can reconstruct the grid system without any external configuration.
    """
    try:
        catalog.create_namespace(NAMESPACE)
    except NamespaceAlreadyExistsError:
        pass

    props: dict[str, str] = {}
    if grid_config is not None:
        props[PROP_GRID_TYPE] = str(grid_config.type)
        if grid_config.resolution is not None:
            props[PROP_GRID_RESOLUTION] = str(grid_config.resolution)
        if grid_config.boundaries_path is not None:
            props[PROP_GRID_BOUNDARIES_PATH] = str(grid_config.boundaries_path)
        if grid_config.id_field is not None:
            props[PROP_GRID_ID_FIELD] = str(grid_config.id_field)
        props[PROP_TIME_BIN] = grid_config.time_bin

    warehouse = catalog.properties.get("warehouse", "")
    if warehouse:
        props[PROP_INDEX_PATH] = f"{warehouse.rstrip('/')}_index.parquet"

    time_bin = grid_config.time_bin if grid_config is not None else "year"

    try:
        table = catalog.load_table(FULL_NAME)
        missing = {k: v for k, v in props.items() if k not in table.properties}
        # A legacy warehouse carries earthcatalog.hash_index_path; leave the
        # index property alone — it was stamped when the warehouse was
        # migrated to the unified index.
        if table.properties.get(PROP_HASH_INDEX_PATH):
            missing.pop(PROP_INDEX_PATH, None)
        if missing:
            with table.transaction() as tx:
                tx.set_properties(**missing)  # type: ignore[arg-type]
        return table
    except NoSuchTableError:
        return catalog.create_table(
            identifier=FULL_NAME,
            schema=ICEBERG_SCHEMA,
            partition_spec=build_partition_spec(time_bin),
            properties=props,
        )

open(store, base, *, anonymous=None)

Open an EarthCatalog backed by store at base.

Parameters

store: An obstore-compatible store (S3Store, LocalStore, etc.). All catalog I/O (download, upload) and warehouse file operations flow through this store. base: Base path containing: - earthcatalog.db (SQLite Iceberg catalog) - warehouse/ (GeoParquet files) Optionally: - warehouse_index.parquet (unified index) anonymous: Force anonymous S3 access when the warehouse path is s3://. Auto-detected for stores with skip_signature=True.

Returns

EarthCatalog Facade combining PyIceberg catalog, table, and grid metadata.

Source code in earthcatalog/catalog.py
def open(
    store: ObjectStore,
    base: str,
    *,
    anonymous: bool | None = None,
) -> EarthCatalog:
    """Open an EarthCatalog backed by *store* at *base*.

    Parameters
    ----------
    store:
        An obstore-compatible store (``S3Store``, ``LocalStore``, etc.).
        All catalog I/O (download, upload) and warehouse file operations
        flow through this store.
    base:
        Base path containing:
        - ``earthcatalog.db``   (SQLite Iceberg catalog)
        - ``warehouse/``        (GeoParquet files)
        Optionally:
        - ``warehouse_index.parquet`` (unified index)
    anonymous:
        Force anonymous S3 access when the warehouse path is ``s3://``.
        Auto-detected for stores with ``skip_signature=True``.

    Returns
    -------
    EarthCatalog
        Facade combining PyIceberg catalog, table, and grid metadata.
    """
    import os
    import tempfile
    import uuid

    _warehouse_path = f"{base}/warehouse"

    if base.startswith("s3://"):
        rest = base[5:]
        parts = rest.split("/", 1)
        catalog_key = f"{parts[1]}/earthcatalog.db" if len(parts) > 1 else "earthcatalog.db"
    else:
        catalog_key = str(Path(base) / "earthcatalog.db")

    _db_path = str(Path(tempfile.gettempdir()) / f"earthcatalog_{uuid.uuid4().hex[:8]}.db")
    try:
        result = obstore.get(store, catalog_key)
        Path(_db_path).write_bytes(bytes(result.bytes()))
    except FileNotFoundError:
        pass

    if anonymous is None and hasattr(store, "config"):
        skip_sig = store.config.get("skip_signature")
        if skip_sig in (True, "true"):
            anonymous = True

    region = os.environ.get("AWS_DEFAULT_REGION") or os.environ.get("AWS_REGION") or "us-west-2"
    props: dict = {"uri": f"sqlite:///{_db_path}", "warehouse": _warehouse_path}

    if _warehouse_path.startswith("s3://"):
        props["s3.region"] = region
        if anonymous:
            props["s3.anonymous"] = "true"
            props["s3.endpoint"] = f"https://s3.{region}.amazonaws.com"

    sql_catalog = SqlCatalog(NAMESPACE, **props)
    table = get_or_create(sql_catalog)
    return EarthCatalog(
        catalog=sql_catalog,
        table=table,
        info=_catalog_info(table),
        store=store,
        catalog_key=catalog_key,
    )

earthcatalog.catalog.ICEBERG_SCHEMA = Schema(NestedField(1, 'id', StringType(), required=False), NestedField(2, 'grid_partition', StringType(), required=False), NestedField(3, 'geometry', BinaryType(), required=False), NestedField(4, 'datetime', TimestamptzType(), required=False), NestedField(5, 'platform', StringType(), required=False), NestedField(6, 'percent_valid_pixels', LongType(), required=False), NestedField(7, 'date_dt', LongType(), required=False), NestedField(8, 'proj:code', StringType(), required=False), NestedField(9, 'assets', StringType(), required=False), NestedField(10, 'links', StringType(), required=False), NestedField(11, 'stac_version', StringType(), required=False), NestedField(12, 'type', StringType(), required=False), NestedField(13, 'start_datetime', TimestamptzType(), required=False), NestedField(14, 'version', StringType(), required=False), NestedField(15, 'sat:orbit_state', StringType(), required=False), NestedField(16, 'scene_1_id', StringType(), required=False), NestedField(17, 'scene_2_id', StringType(), required=False), NestedField(18, 'scene_1_frame', StringType(), required=False), NestedField(19, 'scene_2_frame', StringType(), required=False), NestedField(20, 'mid_datetime', StringType(), required=False), NestedField(21, 'created', TimestamptzType(), required=False), NestedField(22, 'updated', TimestamptzType(), required=False), NestedField(23, 'end_datetime', TimestamptzType(), required=False), NestedField(24, 'stac_extensions', StringType(), required=False), NestedField(25, 'collection', StringType(), required=False), NestedField(26, 'latitude', DoubleType(), required=False), NestedField(27, 'longitude', DoubleType(), required=False), NestedField(28, 'bbox', StringType(), required=False)) module-attribute

earthcatalog.catalog.PARTITION_SPEC = build_partition_spec('year') module-attribute

Search extras

DuckDB-backed searches are module-level functions (the facade only keeps search, search_to_arrow and search_files):

Search using DuckDB, returning results as a pandas.DataFrame.

Accepts the same kwargs as :meth:EarthCatalog.search (intersects, bbox, datetime, filter, max_items, etc.). DuckDB reads Parquet files in parallel internally, making this ~2x faster than :meth:EarthCatalog.search across all query types. Returns a DataFrame with flat columns — no pystac conversion overhead.

Examples::

from earthcatalog.search import duck_search

df = duck_search(
    catalog,
    intersects={"type": "Point", "coordinates": [-45, 70]},
    datetime="1980-01-01/2015-12-31",
    max_items=100,
)
Source code in earthcatalog/search.py
def duck_search(catalog, **kwargs):
    """Search using DuckDB, returning results as a ``pandas.DataFrame``.

    Accepts the same kwargs as :meth:`EarthCatalog.search` (``intersects``,
    ``bbox``, ``datetime``, ``filter``, ``max_items``, etc.).  DuckDB reads
    Parquet files in parallel internally, making this ~2x faster than
    :meth:`EarthCatalog.search` across all query types.  Returns a DataFrame
    with flat columns — no pystac conversion overhead.

    Examples::

        from earthcatalog.search import duck_search

        df = duck_search(
            catalog,
            intersects={"type": "Point", "coordinates": [-45, 70]},
            datetime="1980-01-01/2015-12-31",
            max_items=100,
        )
    """
    import pandas as pd

    prepared = _duck_sql(catalog.info, catalog.table, kwargs)
    if prepared is None:
        return pd.DataFrame()
    sql, max_items = prepared
    df = _duck_connect().execute(sql).fetchdf()
    if max_items is not None and len(df) > max_items:
        df = df.head(max_items)
    return df

earthcatalog.search.search_uris(catalog, **kwargs)

Return asset URIs as a DataFrame with (id, uri) columns.

Accepts the same kwargs as :meth:EarthCatalog.search. Uses Iceberg pruning + DuckDB, reading only the id and assets columns — the fastest way to get download URLs for thousands of items.

Examples::

from earthcatalog.search import search_uris

df = search_uris(
    catalog,
    intersects={"type": "Point", "coordinates": [-45, 70]},
    datetime="2020-01-01/2020-12-31",
    max_items=100,
)
for _, row in df.iterrows():
    print(row.id, row.uri)
Source code in earthcatalog/search.py
def search_uris(catalog, **kwargs):
    """Return asset URIs as a DataFrame with ``(id, uri)`` columns.

    Accepts the same kwargs as :meth:`EarthCatalog.search`.  Uses Iceberg
    pruning + DuckDB, reading **only** the ``id`` and ``assets`` columns —
    the fastest way to get download URLs for thousands of items.

    Examples::

        from earthcatalog.search import search_uris

        df = search_uris(
            catalog,
            intersects={"type": "Point", "coordinates": [-45, 70]},
            datetime="2020-01-01/2020-12-31",
            max_items=100,
        )
        for _, row in df.iterrows():
            print(row.id, row.uri)
    """
    import json

    import pandas as pd

    prepared = _duck_sql(catalog.info, catalog.table, kwargs, select="id, assets")
    if prepared is None:
        return pd.DataFrame({"id": [], "uri": []})
    sql, max_items = prepared
    arrow = _duck_connect().execute(sql).to_arrow_table()
    if max_items is not None and arrow.num_rows > max_items:
        arrow = arrow.slice(0, max_items)

    ids = arrow.column("id").to_pylist()
    uris = []
    for a in arrow.column("assets").to_pylist():
        href = None
        if a:
            try:
                href = json.loads(a).get("data", {}).get("href")
            except (json.JSONDecodeError, AttributeError):
                pass
        uris.append(href)
    return pd.DataFrame({"id": ids, "uri": uris})

earthcatalog.transform

STAC item transformation: H3 fan-out + stac-geoparquet writing via rustac.

Public functions

fan_out(items, partitioner) Produce one synthetic STAC item per (source_item × grid_cell) pair. Injects grid_partition into each item's properties.

group_by_partition(fan_out_items) Group the output of fan_out() by (grid_partition, year) so that each group can be written to exactly one Parquet file. This is required for Iceberg IdentityTransform + YearTransform partition pruning.

write_geoparquet(fan_out_items, path) Write a single-partition list of synthetic items to a GeoParquet file using rustac.write(). rustac writes proper stac-geoparquet with: - assets as struct column - links as list column - properties promoted to top-level columns - geoarrow.wkb extension on geometry column

Spatial predicate pushdown

The correct usage pattern for spatial queries:

  1. Convert the query geometry to grid cell IDs (e.g. H3 cells at resolution 1): candidate_cells = h3.geo_to_cells(mapping(query_geom), resolution=1)
  2. Filter the Iceberg table via: WHERE grid_partition IN () Iceberg's IdentityTransform partition pruning will skip all files whose grid_partition value is not in the candidate set.

Classes

FileMetadata dataclass

Lightweight record describing one GeoParquet file written to a store.

Designed to be trivially serialisable by Dask (no PyArrow, no Iceberg imports) so it crosses the network from worker to head node at ~200 B/file.

Attributes

s3_key: Key relative to the warehouse store root — e.g. "grid_partition=81003ffffffffff/year=2025/part_000000_abc1.parquet". The caller that knows the store root appends this to construct the full URI used in table.add_files(). grid_partition: H3 cell string (or "__none__" for unlocated items). year: 4-digit calendar year from the datetime property, or None for items without a parseable datetime. row_count: Number of rows in the file. file_size_bytes: Byte size of the written Parquet file.

Source code in earthcatalog/transform.py
@dataclass
class FileMetadata:
    """
    Lightweight record describing one GeoParquet file written to a store.

    Designed to be trivially serialisable by Dask (no PyArrow, no Iceberg
    imports) so it crosses the network from worker to head node at ~200 B/file.

    Attributes
    ----------
    s3_key:
        Key relative to the warehouse store root — e.g.
        ``"grid_partition=81003ffffffffff/year=2025/part_000000_abc1.parquet"``.
        The caller that knows the store root appends this to construct the full
        URI used in ``table.add_files()``.
    grid_partition:
        H3 cell string (or ``"__none__"`` for unlocated items).
    year:
        4-digit calendar year from the ``datetime`` property, or ``None`` for
        items without a parseable datetime.
    row_count:
        Number of rows in the file.
    file_size_bytes:
        Byte size of the written Parquet file.
    """

    s3_key: str
    grid_partition: str
    year: int | None
    row_count: int
    file_size_bytes: int

Functions

fan_out(stac_items, partitioner)

Produce one synthetic STAC item per (source_item × grid_cell) pair.

Each synthetic item is the original STAC item with grid_partition injected into its properties. All original fields (assets, links, collection, …) are preserved as-is so that rustac.write() can emit a complete stac-geoparquet file with the native rustac schema.

Items with unparseable or empty geometry are silently skipped.

Source code in earthcatalog/transform.py
def fan_out(
    stac_items: list[dict],
    partitioner: AbstractPartitioner,
) -> list[dict]:
    """
    Produce one synthetic STAC item per (source_item × grid_cell) pair.

    Each synthetic item is the original STAC item with ``grid_partition``
    injected into its ``properties``.  All original fields (assets, links,
    collection, …) are preserved as-is so that rustac.write() can emit a
    complete stac-geoparquet file with the native rustac schema.

    Items with unparseable or empty geometry are silently skipped.
    """
    from shapely.geometry import shape  # deferred — heavy library

    result: list[dict] = []
    for item in stac_items:
        props = item.get("properties", {})
        try:
            geom = shape(item["geometry"])
            keys = partitioner.get_intersecting_keys(geom.wkb) or ["__none__"]
        except Exception:
            continue

        for key in keys:
            synthetic = {**item, "properties": {**props, "grid_partition": key}}
            result.append(synthetic)

    return result

group_by_partition(fan_out_items, partitioner)

Group fan-out items by (grid_partition, temporal bin) and sort each group by (platform, datetime).

The partitioner supplies the temporal bin for each item (partitioner.bin_value(props["datetime"])) — it owns temporal binning, so the grouping follows whatever time_bin the partitioner was configured with.

Each resulting group satisfies both Iceberg partition constraints:

  • IdentityTransform on grid_partition — every item in the group has the same grid_partition value, so Parquet column statistics give a single min == max that add_files() can use unambiguously.
  • the temporal transform on datetime (year/month/day per the partitioner's time_bin) — every item in the group has the same bin value, so the partition-level Parquet statistics are also unambiguous.

The within-group sort by (platform, datetime) maximises Parquet row-group min/max statistics for predicate pushdown on those columns.

Parameters

fan_out_items: Output of :func:fan_out — each item has exactly one grid_partition value in its properties. partitioner: The partitioner whose get_intersecting_keys produced the fan-out; its time_bin must match the table's partition spec.

Returns

dict mapping (cell_id, bin_value) → sorted list of synthetic STAC items. bin_value is the formatted hive value ("2025", "2025-12", "2025-12-20") or "unknown" for items that carry no datetime property.

Source code in earthcatalog/transform.py
def group_by_partition(
    fan_out_items: list[dict],
    partitioner: AbstractPartitioner,
) -> dict[tuple[str, str], list[dict]]:
    """
    Group fan-out items by ``(grid_partition, temporal bin)`` and sort
    each group by ``(platform, datetime)``.

    The *partitioner* supplies the temporal bin for each item
    (``partitioner.bin_value(props["datetime"])``) — it owns temporal
    binning, so the grouping follows whatever ``time_bin`` the partitioner
    was configured with.

    Each resulting group satisfies both Iceberg partition constraints:

    * ``IdentityTransform`` on ``grid_partition`` — every item in the group
      has the same ``grid_partition`` value, so Parquet column statistics give
      a single min == max that ``add_files()`` can use unambiguously.
    * the temporal transform on ``datetime`` (year/month/day per the
      partitioner's *time_bin*) — every item in the group has the same bin
      value, so the partition-level Parquet statistics are also unambiguous.

    The within-group sort by ``(platform, datetime)`` maximises Parquet
    row-group min/max statistics for predicate pushdown on those columns.

    Parameters
    ----------
    fan_out_items:
        Output of :func:`fan_out` — each item has exactly one
        ``grid_partition`` value in its ``properties``.
    partitioner:
        The partitioner whose ``get_intersecting_keys`` produced the fan-out;
        its ``time_bin`` must match the table's partition spec.

    Returns
    -------
    dict mapping ``(cell_id, bin_value)`` → sorted list of synthetic STAC
    items.  ``bin_value`` is the formatted hive value (``"2025"``,
    ``"2025-12"``, ``"2025-12-20"``) or ``"unknown"`` for items that carry
    no ``datetime`` property.
    """
    groups: dict[tuple[str, str], list[dict]] = {}
    for item in fan_out_items:
        props = item["properties"]
        cell = props.get("grid_partition", "__none__")
        bv = partitioner.bin_value(props.get("datetime"))
        key = (cell, bv)
        groups.setdefault(key, []).append(item)

    # Sort within each group for optimal Parquet column statistics
    for key in groups:
        groups[key].sort(key=_sort_key)

    return groups

write_geoparquet(fan_out_items, path)

Write fan-out STAC items to a GeoParquet file using rustac.

Caller's responsibility

Pass items for a single (grid_partition, year) group — i.e. the output of one iteration over :func:group_by_partition. If items span multiple partitions the resulting file will violate the Iceberg IdentityTransform constraint and table.add_files() will raise a ValueError.

rustac writes the full stac-geoparquet schema. A post-processing step casts struct/list columns (assets, links) to JSON strings and drops null- typed columns (collection) so the file is compatible with PyIceberg V2 add_files().

Returns the number of rows written (0 if the input list is empty).

Source code in earthcatalog/transform.py
def write_geoparquet(fan_out_items: list[dict], path: str) -> int:
    """
    Write fan-out STAC items to a GeoParquet file using rustac.

    Caller's responsibility
    -----------------------
    Pass items for **a single (grid_partition, year) group** — i.e. the output
    of one iteration over :func:`group_by_partition`.  If items span multiple
    partitions the resulting file will violate the Iceberg ``IdentityTransform``
    constraint and ``table.add_files()`` will raise a ``ValueError``.

    rustac writes the full stac-geoparquet schema.  A post-processing step
    casts struct/list columns (assets, links) to JSON strings and drops null-
    typed columns (collection) so the file is compatible with PyIceberg V2
    ``add_files()``.

    Returns the number of rows written (0 if the input list is empty).
    """
    if not fan_out_items:
        return 0

    import pyarrow.parquet as pq

    async def _write():
        await rustac.write(path, fan_out_items)

    loop = asyncio.new_event_loop()
    try:
        loop.run_until_complete(_write())
    finally:
        loop.close()

    # Post-process: cast assets/links → JSON strings, drop null columns,
    # cast whole-number floats → int32.  Field metadata (e.g. geoarrow.wkb
    # extension on geometry) is preserved by _normalize_for_iceberg.
    # File-level keys written by rustac (geo, stac-geoparquet) live in the
    # Parquet file metadata, not the Arrow schema metadata.  We carry them
    # into the Arrow schema so pq.write_table re-encodes them.
    # Use ParquetFile to read the single file exactly — avoids PyArrow's
    # Hive-partition directory discovery which breaks inside partitioned layouts.
    pf = pq.ParquetFile(path)
    file_meta = pf.metadata.metadata
    table: pa.Table = pf.read()
    table = _normalize_for_iceberg(table)  # type: ignore[arg-type]
    preserve_keys = (b"geo", b"stac-geoparquet")
    extra = {k: v for k, v in file_meta.items() if k in preserve_keys}
    if extra:
        merged = {**(table.schema.metadata or {}), **extra}
        table = table.replace_schema_metadata(merged)
    # Write via a file object rather than a path string to prevent PyArrow's
    # Parquet reader/writer from treating the parent directory as a Hive-
    # partitioned dataset (which causes schema-merge errors inside layouts
    # like grid_partition=X/year=Y/).  S3 paths never reach this code path —
    # write_geoparquet_s3 writes to a local temp file then uploads via obstore.
    with open(path, "wb") as _fh:
        pq.write_table(table, _fh, compression="zstd")

    return len(fan_out_items)

write_geoparquet_s3(fan_out_items, store, s3_key)

Write a single-partition list of fan-out items as GeoParquet to a store.

Writes to a local temporary file (via :func:write_geoparquet) then uploads the bytes via obstore.put. The temporary file is always deleted, even on error.

This is the S3-capable counterpart to :func:write_geoparquet. Workers on a Dask cluster call this function directly; the store is injected so the function is testable with any obstore-compatible backend (MemoryStore, LocalStore, S3Store).

Parameters

fan_out_items: Output of one group_by_partition() iteration — all items must belong to the same (grid_partition, year) group. store: An obstore-compatible store (S3Store, LocalStore, or MemoryStore). s3_key: Key within the store, e.g. "grid_partition=81003ffffffffff/year=2025/part_000000_abc1.parquet".

Returns

(row_count, byte_count) — both zero if fan_out_items is empty (no file is uploaded in that case).

Source code in earthcatalog/transform.py
def write_geoparquet_s3(
    fan_out_items: list[dict],
    store: ObjectStore,
    s3_key: str,
) -> tuple[int, int]:
    """
    Write a **single-partition** list of fan-out items as GeoParquet to a store.

    Writes to a local temporary file (via :func:`write_geoparquet`) then
    uploads the bytes via ``obstore.put``.  The temporary file is always
    deleted, even on error.

    This is the S3-capable counterpart to :func:`write_geoparquet`.  Workers
    on a Dask cluster call this function directly; the store is injected so
    the function is testable with any ``obstore``-compatible backend
    (``MemoryStore``, ``LocalStore``, ``S3Store``).

    Parameters
    ----------
    fan_out_items:
        Output of one ``group_by_partition()`` iteration — all items must
        belong to the same ``(grid_partition, year)`` group.
    store:
        An ``obstore``-compatible store (``S3Store``, ``LocalStore``, or
        ``MemoryStore``).
    s3_key:
        Key within the store, e.g.
        ``"grid_partition=81003ffffffffff/year=2025/part_000000_abc1.parquet"``.

    Returns
    -------
    ``(row_count, byte_count)`` — both zero if *fan_out_items* is empty (no
    file is uploaded in that case).
    """
    if not fan_out_items:
        return 0, 0

    with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
        tmp_path = tmp.name
    try:
        n = write_geoparquet(fan_out_items, tmp_path)
        if n == 0:
            return 0, 0
        data = Path(tmp_path).read_bytes()
        obstore.put(store, s3_key, data)
        return n, len(data)
    finally:
        Path(tmp_path).unlink(missing_ok=True)

earthcatalog.lock

S3 atomic lockfile using conditional writes (If-None-Match: *).

Prevents concurrent writes to the SQLite catalog.db.

Uses the store configured in earthcatalog.store_config (defaults to LocalStore for zero-config local development and testing). Override the store before running a job:

from earthcatalog import store_config
from obstore.store import S3Store

store_config.set_store(S3Store(bucket="my-bucket", region="us-west-2"))
store_config.set_lock_key("catalog/.lock")
Usage

from earthcatalog.lock import S3Lock

with S3Lock(owner="incremental"): download_catalog(...) ... do work ... upload_catalog(...)

Classes

CatalogLocked

Bases: RuntimeError

Raised when the lock is held by another process.

Source code in earthcatalog/lock.py
class CatalogLocked(RuntimeError):
    """Raised when the lock is held by another process."""

S3Lock

Atomic lockfile using obstore conditional writes (If-None-Match: *).

When store and key are provided explicitly they are used directly; otherwise falls back to the global :mod:earthcatalog.store_config (deprecated path).

Stale locks (older than ttl_hours) are automatically overridden.

Source code in earthcatalog/lock.py
class S3Lock:
    """
    Atomic lockfile using obstore conditional writes (If-None-Match: *).

    When *store* and *key* are provided explicitly they are used directly;
    otherwise falls back to the global :mod:`earthcatalog.store_config`
    (deprecated path).

    Stale locks (older than ttl_hours) are automatically overridden.
    """

    def __init__(
        self,
        owner: str,
        ttl_hours: int = 12,
        store: ObjectStore | None = None,
        key: str | None = None,
    ) -> None:
        """
        Args:
            owner:     Human-readable name for the lock holder (e.g. "backfill").
            ttl_hours: Age after which a lock is considered stale and overridable.
            store:     Optional explicit obstore store (avoids store_config globals).
            key:       Optional explicit lock key (avoids store_config globals).
        """
        self._owner = owner
        self._ttl = ttl_hours
        self._explicit_store = store
        self._explicit_key = key

    def __enter__(self) -> "S3Lock":
        self.acquire()
        return self

    def __exit__(self, *_: object) -> None:
        self.release()

    @property
    def _store(self) -> ObjectStore:
        if self._explicit_store is not None:
            return self._explicit_store
        return store_config.get_store()

    @property
    def _key(self) -> str:
        if self._explicit_key is not None:
            return self._explicit_key
        return store_config.get_lock_key()

    def acquire(self) -> None:
        """
        Atomically acquire the lock via mode='create' (If-None-Match: *).

        Succeeds only if the key does not exist. On conflict, reads the
        existing lock; if stale, deletes and retries. Raises CatalogLocked
        if a fresh lock is held by another process.
        """
        payload = self._make_payload()

        try:
            obstore.put(self._store, self._key, payload, mode="create")
            print(f"Lock acquired by '{self._owner}'.")
            return
        except AlreadyExistsError:
            pass

        # Key exists — read it to decide what to do
        existing = self._read_lock()
        if existing is None:
            # Disappeared between our failed PUT and this GET — retry once
            obstore.put(self._store, self._key, payload, mode="create")
            print(f"Lock acquired by '{self._owner}' (second attempt).")
            return

        acquired_at = datetime.fromisoformat(existing["acquired"])
        age = datetime.now(UTC) - acquired_at
        ttl = timedelta(hours=existing.get("ttl_hours", self._ttl))

        if age >= ttl:
            print(
                f"WARNING: Overriding stale lock from '{existing['owner']}' "
                f"on {existing['hostname']} (age: {age}, TTL: {ttl})."
            )
            obstore.delete(self._store, self._key)
            obstore.put(self._store, self._key, payload, mode="create")
            print(f"Lock acquired by '{self._owner}' (after stale override).")
            return

        raise CatalogLocked(
            f"Catalog is locked by '{existing['owner']}' on "
            f"{existing['hostname']} since {existing['acquired']}. "
            f"Lock expires in {ttl - age}."
        )

    def release(self) -> None:
        try:
            obstore.delete(self._store, self._key)
            print(f"Lock released by '{self._owner}'.")
        except Exception:
            pass  # already gone — fine

    def _make_payload(self) -> bytes:
        return json.dumps(
            {
                "owner": self._owner,
                "pid": os.getpid(),
                "hostname": socket.gethostname(),
                "acquired": datetime.now(UTC).isoformat(),
                "ttl_hours": self._ttl,
            }
        ).encode()

    def _read_lock(self) -> dict | None:
        try:
            result = obstore.get(self._store, self._key)
            return json.loads(bytes(result.bytes()))
        except FileNotFoundError:
            return None
Functions
__init__(owner, ttl_hours=12, store=None, key=None)

Parameters:

Name Type Description Default
owner str

Human-readable name for the lock holder (e.g. "backfill").

required
ttl_hours int

Age after which a lock is considered stale and overridable.

12
store ObjectStore | None

Optional explicit obstore store (avoids store_config globals).

None
key str | None

Optional explicit lock key (avoids store_config globals).

None
Source code in earthcatalog/lock.py
def __init__(
    self,
    owner: str,
    ttl_hours: int = 12,
    store: ObjectStore | None = None,
    key: str | None = None,
) -> None:
    """
    Args:
        owner:     Human-readable name for the lock holder (e.g. "backfill").
        ttl_hours: Age after which a lock is considered stale and overridable.
        store:     Optional explicit obstore store (avoids store_config globals).
        key:       Optional explicit lock key (avoids store_config globals).
    """
    self._owner = owner
    self._ttl = ttl_hours
    self._explicit_store = store
    self._explicit_key = key
acquire()

Atomically acquire the lock via mode='create' (If-None-Match: *).

Succeeds only if the key does not exist. On conflict, reads the existing lock; if stale, deletes and retries. Raises CatalogLocked if a fresh lock is held by another process.

Source code in earthcatalog/lock.py
def acquire(self) -> None:
    """
    Atomically acquire the lock via mode='create' (If-None-Match: *).

    Succeeds only if the key does not exist. On conflict, reads the
    existing lock; if stale, deletes and retries. Raises CatalogLocked
    if a fresh lock is held by another process.
    """
    payload = self._make_payload()

    try:
        obstore.put(self._store, self._key, payload, mode="create")
        print(f"Lock acquired by '{self._owner}'.")
        return
    except AlreadyExistsError:
        pass

    # Key exists — read it to decide what to do
    existing = self._read_lock()
    if existing is None:
        # Disappeared between our failed PUT and this GET — retry once
        obstore.put(self._store, self._key, payload, mode="create")
        print(f"Lock acquired by '{self._owner}' (second attempt).")
        return

    acquired_at = datetime.fromisoformat(existing["acquired"])
    age = datetime.now(UTC) - acquired_at
    ttl = timedelta(hours=existing.get("ttl_hours", self._ttl))

    if age >= ttl:
        print(
            f"WARNING: Overriding stale lock from '{existing['owner']}' "
            f"on {existing['hostname']} (age: {age}, TTL: {ttl})."
        )
        obstore.delete(self._store, self._key)
        obstore.put(self._store, self._key, payload, mode="create")
        print(f"Lock acquired by '{self._owner}' (after stale override).")
        return

    raise CatalogLocked(
        f"Catalog is locked by '{existing['owner']}' on "
        f"{existing['hostname']} since {existing['acquired']}. "
        f"Lock expires in {ttl - age}."
    )

earthcatalog.store_config

Global store configuration for earthcatalog.

Defaults to a LocalStore rooted at /tmp/earthcatalog_store for zero-config local development and testing. Override before running any job:

from earthcatalog import store_config
from obstore.store import S3Store

store_config.set_store(S3Store(bucket="my-bucket", region="us-west-2"))
store_config.set_catalog_key("catalog/catalog.db")
store_config.set_lock_key("catalog/.lock")

Functions

set_store(store)

Override the store backend (e.g. S3Store for production).

Source code in earthcatalog/store_config.py
def set_store(store: ObjectStore) -> None:
    """Override the store backend (e.g. S3Store for production)."""
    global _store
    _store = store

get_store()

Return the active obstore-compatible store.

Source code in earthcatalog/store_config.py
def get_store() -> ObjectStore:
    """Return the active obstore-compatible store."""
    return _store

earthcatalog.partitioner

Spatial + temporal partitioning.

A partitioner owns BOTH halves of the hive partition key:

  • the spatial cell keys from a WKB geometry (:meth:AbstractPartitioner.get_intersecting_keys)
  • the temporal bin value from an item datetime (:meth:AbstractPartitioner.bin_value)

Together they form the warehouse path segment grid=<type>/level=<res>/tile=<cell>/<time_bin>=<value>/. The boundary-inclusive contract means that a geometry touching a cell boundary is assigned to that cell, preventing coverage gaps along shared edges.

Built-in implementations

  • :class:~earthcatalog.grids.h3_partitioner.H3Partitioner — Uber H3 hexagonal grid
  • :class:~earthcatalog.grids.s2_partitioner.S2Partitioner — Google S2 cells
  • :class:~earthcatalog.grids.utm_partitioner.UTMPartitioner — UTM zones
  • :class:~earthcatalog.grids.geojson_partitioner.GeoJSONPartitioner — arbitrary polygon regions

Custom partitioners

Subclass :class:AbstractPartitioner, implement :meth:get_intersecting_keys, accept time_bin in __init__ and forward it to super().__init__, then register the builder with :func:~earthcatalog.grids.register_grid (or add it to the built-in registry in earthcatalog.grids).

Classes

AbstractPartitioner

Bases: ABC

Maps an item to its partition keys — spatial cells AND temporal bin.

Given a WKB geometry, :meth:get_intersecting_keys returns the set of grid cell keys whose boundaries intersect that geometry (a single item may map to multiple keys — the Overlap Multiplier). Given an item datetime, :meth:bin_value returns the formatted temporal bin for this partitioner's configured time_bin.

Source code in earthcatalog/partitioner.py
class AbstractPartitioner(ABC):
    """Maps an item to its partition keys — spatial cells AND temporal bin.

    Given a WKB geometry, :meth:`get_intersecting_keys` returns the set of
    grid cell keys whose boundaries intersect that geometry (a single item
    may map to multiple keys — the Overlap Multiplier).  Given an item
    datetime, :meth:`bin_value` returns the formatted temporal bin for this
    partitioner's configured *time_bin*.
    """

    def __init__(self, time_bin: str = "year") -> None:
        if time_bin not in TIME_BINS:
            raise ValueError(f"unknown time bin: {time_bin!r}")
        self.time_bin = time_bin

    @abstractmethod
    def get_intersecting_keys(self, geom_wkb: bytes) -> list[str]:
        """Return grid cell IDs that intersect the given WKB geometry."""
        ...

    def bin_value(self, value: str | datetime | None) -> str:
        """Temporal bin (``"2025"`` / ``"2025-12"`` / ``"2025-12-20"``) for an
        item datetime under this partitioner's *time_bin*."""
        return bin_value(value, self.time_bin)
Functions
get_intersecting_keys(geom_wkb) abstractmethod

Return grid cell IDs that intersect the given WKB geometry.

Source code in earthcatalog/partitioner.py
@abstractmethod
def get_intersecting_keys(self, geom_wkb: bytes) -> list[str]:
    """Return grid cell IDs that intersect the given WKB geometry."""
    ...
bin_value(value)

Temporal bin ("2025" / "2025-12" / "2025-12-20") for an item datetime under this partitioner's time_bin.

Source code in earthcatalog/partitioner.py
def bin_value(self, value: str | datetime | None) -> str:
    """Temporal bin (``"2025"`` / ``"2025-12"`` / ``"2025-12-20"``) for an
    item datetime under this partitioner's *time_bin*."""
    return bin_value(value, self.time_bin)

Functions

bin_value(value, time_bin='year')

Format a temporal value for the hive path: 2025 / 2025-12 / 2025-12-20.

Accepts the ISO strings STAC items carry or a datetime. Missing or unparseable values map to "unknown" — files without a datetime live in the unknown partition and index rows must agree.

Source code in earthcatalog/partitioner.py
def bin_value(value: str | datetime | None, time_bin: str = "year") -> str:
    """Format a temporal value for the hive path: ``2025`` / ``2025-12`` /
    ``2025-12-20``.

    Accepts the ISO strings STAC items carry or a datetime.  Missing or
    unparseable values map to ``"unknown"`` — files without a datetime live
    in the ``unknown`` partition and index rows must agree.
    """
    if time_bin not in TIME_BINS:
        raise ValueError(f"unknown time bin: {time_bin!r}")
    if value is None:
        return "unknown"
    if isinstance(value, datetime):
        value = value.astimezone(UTC).isoformat()
    s = str(value)
    y, m, d = s[:4], s[5:7], s[8:10]
    if not (y.isdigit() and len(y) == 4):
        return "unknown"
    if time_bin == "year":
        return y
    if m.isdigit() and len(m) == 2:
        if time_bin == "month":
            return f"{y}-{m}"
        if d.isdigit() and len(d) == 2:
            return f"{y}-{m}-{d}"
    return "unknown"

earthcatalog.grids

Partitioner factory and grid registry.

Usage

from earthcatalog.config import GridConfig from earthcatalog.grids import build_partitioner

cfg = GridConfig(type="h3", resolution=3, time_bin="month") partitioner = build_partitioner(cfg)

The factory passes cfg.time_bin into every partitioner — the built object owns both the spatial keys and the temporal bin. New grids are added by registering a builder (no factory edits needed):

from earthcatalog.grids import register_grid

register_grid("my_grid", lambda cfg, **kw: MyPartitioner(resolution=cfg.resolution, **kw))

Classes

Functions

register_grid(grid_type, builder)

Register (or replace) the builder for grid_type.

builder is called as builder(cfg, time_bin=cfg.time_bin) and returns an :class:~earthcatalog.partitioner.AbstractPartitioner.

Source code in earthcatalog/grids/__init__.py
def register_grid(grid_type: str, builder: Callable[..., AbstractPartitioner]) -> None:
    """Register (or replace) the builder for *grid_type*.

    *builder* is called as ``builder(cfg, time_bin=cfg.time_bin)`` and
    returns an :class:`~earthcatalog.partitioner.AbstractPartitioner`.
    """
    _REGISTRY[grid_type] = builder

build_partitioner(cfg)

Instantiate the partitioner for a GridConfig (grid + temporal bin).

Source code in earthcatalog/grids/__init__.py
def build_partitioner(cfg: GridConfig) -> AbstractPartitioner:
    """Instantiate the partitioner for a GridConfig (grid + temporal bin)."""
    try:
        builder = _REGISTRY[cfg.type]
    except KeyError:
        known = ", ".join(sorted(_REGISTRY))
        raise ValueError(f"Unknown grid type: {cfg.type!r} (known: {known})") from None
    return builder(cfg, time_bin=cfg.time_bin)