Source code for lsdb.io.to_lance

from __future__ import annotations

from datetime import timedelta
from pathlib import Path

import pyarrow as pa
from tqdm import tqdm
from upath import UPath

from lsdb.catalog.dataset.healpix_dataset import HealpixDataset

_TABLE_NAME = "data"


def _lance_storage_options_from_upath(path: UPath) -> dict[str, str] | None:
    """Translate a UPath's fsspec-style ``storage_options`` into the key
    names lance/lancedb's Rust ``object_store`` backend expects.

    fsspec filesystems (s3fs, gcsfs, adlfs, ...) and lance's ``object_store``
    each have their own, incompatible ``storage_options`` schemas. A UPath
    built for read/write via fsspec can't be handed to
    ``lancedb.connect(storage_options=...)`` as-is -- this maps the handful
    of fields we care about (endpoint, credentials, TLS) between the two.

    Parameters
    ----------
    path : UPath
        A UPath instance pointing at the target Lance dataset location.

    Returns
    -------
    dict[str, str] | None
        A ``storage_options`` dict suitable for ``lancedb.connect``, or
        ``None`` if the path's protocol needs no special handling (e.g.
        local filesystem paths).
    """
    protocol = path.protocol
    fsso = dict(path.storage_options or {})

    if protocol == "s3":
        return _map_s3_storage_options(fsso)

    # Unknown/unsupported protocol (http, memory, ...) -- nothing sensible
    # to translate; let lance/lancedb use its own defaults.
    return None


def _map_s3_storage_options(fsso: dict) -> dict[str, str]:
    """Translate fsspec-style S3 storage_options into the key names
    lance/lancedb's Rust ``object_store`` backend expects.

    Will read endpoint_url/region_name from the top level of ``fsso``, or
    from ``fsso["client_kwargs"]`` if not present at the top level.

    Https endpoints are passed through as-is. Plain http endpoints are passed
    but accompanied by ``allow_http=true`` to opt into the object_store's
    refusal to use plain http endpoints by default.

    Parameters
    ----------
    fsso : dict
        A dict of fsspec-style S3 storage_options, e.g. from a UPath's
        ``storage_options`` attribute.

    Returns
    -------
    dict[str, str]
        A dict of storage_options suitable for ``lancedb.connect``.
    """
    lance_so: dict[str, str] = {}

    client_kwargs = fsso.get("client_kwargs", {}) or {}

    endpoint_url = fsso.get("endpoint_url") or client_kwargs.get("endpoint_url")
    if endpoint_url:
        lance_so["aws_endpoint"] = endpoint_url
        # object_store refuses plain http endpoints unless told it's ok.
        if endpoint_url.startswith("http://"):
            lance_so["allow_http"] = "true"

    region = fsso.get("region_name") or client_kwargs.get("region_name") or fsso.get("region")
    if region:
        lance_so["aws_region"] = region

    key = fsso.get("key")
    secret = fsso.get("secret")
    token = fsso.get("token")
    if key:
        lance_so["aws_access_key_id"] = key
    if secret:
        lance_so["aws_secret_access_key"] = secret
    if token:
        lance_so["aws_session_token"] = token

    if fsso.get("anon"):
        lance_so["aws_skip_signature"] = "true"

    return lance_so


[docs] def to_lance( catalog: HealpixDataset, *, base_catalog_path: str | Path | UPath, table_name: str = _TABLE_NAME, overwrite: bool = False, progress_bar: bool = True, optimize_dataset: bool = True, ) -> None: """Writes a catalog to a Lance dataset. All primary catalog partitions are written as a single flat Lance dataset. Every column in the catalog — including the HEALPix spatial index — is preserved. The margin catalog (if present) is not written to Lance. The resulting dataset can be opened with ``lancedb.connect(base_catalog_path).open_table("data")``. Parameters ---------- catalog : HealpixDataset The catalog to export. base_catalog_path : str | Path | UPath Path where the Lance dataset will be written. table_name : str, default "data" Name of the table to create in the Lance database. This is the name used to open the table later with lancedb. overwrite : bool, default False If True, an existing dataset at ``base_catalog_path`` is overwritten. If False and a dataset already exists there, an error is raised. progress_bar : bool, default True If True, shows a progress bar while writing partitions. optimize_dataset : bool, default True If True, optimizes the Lance dataset after writing all partitions. This will improve query performance but will increase the total time required to write the dataset. Raises ------ ImportError If the ``lancedb`` package is not installed. ValueError If a dataset already exists at ``base_catalog_path`` and ``overwrite=False``. RuntimeError If the catalog is empty and no data is written. Examples -------- Export a catalog and open it with lancedb: >>> import lsdb >>> catalog = lsdb.read_hats("path/to/small_sky") # doctest: +SKIP >>> catalog.to_lance("/tmp/my_catalog") # doctest: +SKIP Open the result: >>> import lancedb # doctest: +SKIP >>> db = lancedb.connect("/tmp/my_catalog") # doctest: +SKIP >>> tbl = db.open_table("data") # doctest: +SKIP """ try: # pylint: disable=import-outside-toplevel import lancedb except ImportError as err: raise ImportError( "to_lance requires the `lancedb` package. Install it with `pip install lancedb`." ) from err base_catalog_path = UPath(base_catalog_path) lance_table_path = base_catalog_path / f"{table_name}.lance" if lance_table_path.exists() and any(lance_table_path.iterdir()): if not overwrite: raise ValueError( f"A Lance table already exists at '{lance_table_path}'." " Choose a different path or set overwrite=True to overwrite the existing dataset." ) path = str(base_catalog_path) storage_options = _lance_storage_options_from_upath(base_catalog_path) # pylint: disable=protected-access delayed_partitions = catalog.to_delayed() db = lancedb.connect(path, storage_options=storage_options) table: lancedb.Table | None = None for _, partition in tqdm( enumerate(delayed_partitions), total=len(delayed_partitions), desc="Writing to Lance", disable=not progress_bar, ): df = partition.compute() if len(df) == 0: continue pa_table = pa.Table.from_pandas(df.reset_index(), preserve_index=False) if table is None: mode = "overwrite" if overwrite else "create" table = db.create_table(table_name, pa_table, mode=mode) else: table.add(pa_table) if table is None: raise RuntimeError("The output catalog is empty. No data was written to Lance.") if optimize_dataset: # TODO: Replace with appropriate logging message and level print("Optimizing Lance dataset...") table.optimize(cleanup_older_than=timedelta(0), delete_unverified=True) print(f"Finished writing output to lance. Path: {path}, Table name: {table_name}")