Source code for cellucid.server

"""
Cellucid Data Server

A lightweight HTTP server for serving pre-exported cellucid datasets.
Supports both local and remote access patterns:

1. Local mode: Run on your machine, open browser locally
2. SSH tunnel mode: Run on remote server, access via SSH port forwarding
3. Jupyter mode: Run alongside Jupyter, embed in notebooks

Usage:
    from cellucid import serve
    serve("/path/to/data", port=8765)

Or via CLI:
    cellucid serve /path/to/data --port 8765

The server provides:
- Static file serving for dataset files
- CORS headers for cross-origin access (needed for web viewer)
- Health check endpoint for connection validation

For serving AnnData directly (without pre-export), use:
    from cellucid import serve_anndata
    serve_anndata(
        "/path/to/data.h5ad",
        dataset_name="Example",
        dataset_id="example",
    )
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re
import stat
import threading
import webbrowser
from dataclasses import dataclass
from functools import partial
from http import HTTPStatus
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse

from ._server_base import (
    CELLUCID_WEB_URL,
    DEFAULT_HOST,
    DEFAULT_PORT,
    WEB_ASSET_INVENTORY_FILENAME,
    CORSMixin,
    _web_cache_dir,
    print_detail,
    print_server_banner,
    print_step,
    print_success,
    require_server_port,
)
from .prepare_data import (
    _require_nonempty_string,
    _require_portable_filename_component,
)

logger = logging.getLogger("cellucid.server")

_PUBLISHED_STATE_MANIFEST = "state-snapshots.json"
_PUBLISHED_STATE_BUNDLE = "default.cellucid-session"
_PUBLISHED_STATE_MANIFEST_BYTES = 43
_MAX_PUBLISHED_STATE_BUNDLE_BYTES = 32 * 1024


def _same_regular_file(left: os.stat_result, right: os.stat_result) -> bool:
    return (
        stat.S_ISREG(left.st_mode)
        and stat.S_ISREG(right.st_mode)
        and left.st_size == right.st_size
        and left.st_mtime_ns == right.st_mtime_ns
        and left.st_dev == right.st_dev
        and left.st_ino == right.st_ino
    )


def _read_published_state_file(
    dataset_root: Path,
    filename: str,
    *,
    minimum_bytes: int,
    maximum_bytes: int,
) -> bytes:
    """Read one unchanged in-root regular state sidecar without following links."""
    candidate = dataset_root / filename
    try:
        before = candidate.lstat()
    except OSError as error:
        raise ValueError(
            f"Published sample state sidecar is unreadable: {filename}"
        ) from error
    if stat.S_ISLNK(before.st_mode):
        raise ValueError(
            f"Published sample state sidecar must not be a symbolic link: {filename}"
        )
    if not stat.S_ISREG(before.st_mode):
        raise ValueError(
            f"Published sample state sidecar must be a regular file: {filename}"
        )
    if before.st_size < minimum_bytes or before.st_size > maximum_bytes:
        if minimum_bytes == maximum_bytes:
            raise ValueError(
                f"Published sample state {filename} must be exactly "
                f"{minimum_bytes} bytes."
            )
        raise ValueError(
            f"Published sample state {filename} size must be from "
            f"{minimum_bytes} through {maximum_bytes} bytes."
        )

    try:
        resolved = candidate.resolve(strict=True)
        resolved.relative_to(dataset_root)
    except (OSError, ValueError) as error:
        raise ValueError(
            f"Published sample state sidecar must remain inside its dataset root: {filename}"
        ) from error
    if resolved != candidate:
        raise ValueError(
            f"Published sample state sidecar must not traverse a symbolic link: {filename}"
        )

    flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
    nofollow = getattr(os, "O_NOFOLLOW", 0)
    if nofollow:
        flags |= nofollow
    try:
        descriptor = os.open(candidate, flags)
    except OSError as error:
        raise ValueError(
            f"Published sample state sidecar could not be opened safely: {filename}"
        ) from error

    try:
        opened = os.fstat(descriptor)
        if not _same_regular_file(before, opened):
            raise ValueError(
                f"Published sample state sidecar changed during validation: {filename}"
            )
        remaining = opened.st_size
        chunks: list[bytes] = []
        while remaining:
            chunk = os.read(descriptor, min(64 * 1024, remaining))
            if not chunk:
                raise ValueError(
                    f"Published sample state sidecar ended during validation: {filename}"
                )
            chunks.append(chunk)
            remaining -= len(chunk)
        if os.read(descriptor, 1):
            raise ValueError(
                f"Published sample state sidecar grew during validation: {filename}"
            )
        after = candidate.lstat()
        if not _same_regular_file(opened, after):
            raise ValueError(
                f"Published sample state sidecar changed during validation: {filename}"
            )
        return b"".join(chunks)
    finally:
        os.close(descriptor)


def _read_optional_published_state(dataset_dir: Path) -> dict[str, str]:
    """Validate and describe the exact optional published sample state pair."""
    dataset_root = dataset_dir.resolve(strict=True)
    manifest_path = dataset_root / _PUBLISHED_STATE_MANIFEST
    bundle_path = dataset_root / _PUBLISHED_STATE_BUNDLE
    manifest_present = manifest_path.is_symlink() or manifest_path.exists()
    bundle_present = bundle_path.is_symlink() or bundle_path.exists()
    if not manifest_present and not bundle_present:
        return {}
    if not manifest_present or not bundle_present:
        raise ValueError(
            "Published sample state requires the exact pair "
            f"{_PUBLISHED_STATE_MANIFEST} and {_PUBLISHED_STATE_BUNDLE}."
        )

    extra_bundles = sorted(
        child.name
        for child in dataset_root.iterdir()
        if child.name.endswith(".cellucid-session")
        and child.name != _PUBLISHED_STATE_BUNDLE
    )
    if extra_bundles:
        raise ValueError(
            "Published sample state has extra session bundles; exactly one "
            f"{_PUBLISHED_STATE_BUNDLE} is permitted: {', '.join(extra_bundles)}"
        )

    manifest_bytes = _read_published_state_file(
        dataset_root,
        _PUBLISHED_STATE_MANIFEST,
        minimum_bytes=_PUBLISHED_STATE_MANIFEST_BYTES,
        maximum_bytes=_PUBLISHED_STATE_MANIFEST_BYTES,
    )
    try:
        manifest = json.loads(manifest_bytes.decode("utf-8"))
    except (UnicodeError, json.JSONDecodeError) as error:
        raise ValueError(
            f"Published sample state {_PUBLISHED_STATE_MANIFEST} "
            "must contain readable UTF-8 JSON."
        ) from error
    if not isinstance(manifest, dict):
        raise TypeError(
            f"Published sample state {_PUBLISHED_STATE_MANIFEST} "
            "must contain a JSON object."
        )
    if set(manifest) != {"states"}:
        raise ValueError(
            f"Published sample state {_PUBLISHED_STATE_MANIFEST} "
            "must contain the exact keys: states."
        )
    if type(manifest["states"]) is not list or manifest["states"] != [
        _PUBLISHED_STATE_BUNDLE
    ]:
        raise ValueError(
            f"Published sample state {_PUBLISHED_STATE_MANIFEST} states "
            f"must contain exactly {_PUBLISHED_STATE_BUNDLE}."
        )

    bundle_bytes = _read_published_state_file(
        dataset_root,
        _PUBLISHED_STATE_BUNDLE,
        minimum_bytes=1,
        maximum_bytes=_MAX_PUBLISHED_STATE_BUNDLE_BYTES,
    )
    return {
        "state_manifest": _PUBLISHED_STATE_MANIFEST,
        "state_sha256": hashlib.sha256(bundle_bytes).hexdigest(),
    }


def _read_exported_dataset_entry(
    dataset_dir: Path,
    *,
    public_path: str,
) -> dict[str, str]:
    """Validate one complete prepared-dataset root and return its catalog entry."""
    required_files = (
        dataset_dir / "dataset_identity.json",
        dataset_dir / "obs_manifest.json",
    )
    point_files: list[Path] = []
    for dimension in (1, 2, 3):
        candidates = [
            dataset_dir / f"points_{dimension}d.bin",
            dataset_dir / f"points_{dimension}d.bin.gz",
        ]
        existing = [candidate for candidate in candidates if candidate.exists()]
        if len(existing) > 1:
            raise ValueError(
                f"{dataset_dir.name!r} is not a complete exported dataset: "
                f"both compressed and uncompressed {dimension}D points exist."
            )
        point_files.extend(existing)

    if (
        not dataset_dir.is_dir()
        or any(not path.is_file() for path in required_files)
        or not point_files
        or any(path.stat().st_size == 0 for path in point_files)
    ):
        raise ValueError(f"{dataset_dir.name!r} is not a complete exported dataset.")

    try:
        identity = json.loads(required_files[0].read_text(encoding="utf-8"))
        obs_manifest = json.loads(required_files[1].read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as error:
        raise ValueError(
            f"{dataset_dir.name!r} is not a complete exported dataset with "
            "readable UTF-8 JSON metadata."
        ) from error
    if not isinstance(identity, dict):
        raise TypeError("dataset_identity.json must contain a JSON object.")
    if not isinstance(obs_manifest, dict):
        raise TypeError("obs_manifest.json must contain a JSON object.")
    if type(identity.get("version")) is not int or identity["version"] != 2:
        raise ValueError("dataset_identity.json version must be exactly 2.")

    dataset_id = _require_nonempty_string(
        identity.get("id"),
        label="dataset_id",
    )
    dataset_name = _require_nonempty_string(
        identity.get("name"),
        label="dataset_name",
    )
    for label, value in (("dataset_id", dataset_id), ("dataset_name", dataset_name)):
        if value != value.strip() or any(
            ord(character) < 32 or ord(character) == 127 for character in value
        ):
            raise ValueError(
                f"{label} must be exact text without surrounding whitespace or control characters."
            )

    entry = {
        "id": dataset_id,
        "path": public_path,
        "name": dataset_name,
    }
    entry.update(_read_optional_published_state(dataset_dir))
    return entry


def _list_exported_datasets(data_dir: Path) -> list[dict[str, str]]:
    """Classify one exact prepared dataset or a root containing only datasets."""
    data_dir = Path(data_dir)
    if not data_dir.is_dir():
        raise NotADirectoryError(f"Prepared-data path must be a directory: {data_dir}")

    direct_markers = (
        data_dir / "dataset_identity.json",
        data_dir / "obs_manifest.json",
        *data_dir.glob("points_*d.bin"),
        *data_dir.glob("points_*d.bin.gz"),
    )
    if any(path.exists() for path in direct_markers):
        return [_read_exported_dataset_entry(data_dir, public_path="/")]

    subdirectories = sorted(path for path in data_dir.iterdir() if path.is_dir())
    if not subdirectories:
        return []

    candidate_flags = [
        any(
            marker.exists()
            for marker in (
                subdir / "dataset_identity.json",
                subdir / "obs_manifest.json",
                *subdir.glob("points_*d.bin"),
                *subdir.glob("points_*d.bin.gz"),
            )
        )
        for subdir in subdirectories
    ]
    if not any(candidate_flags):
        return []

    entries: list[dict[str, str]] = []
    seen_ids: set[str] = set()
    for subdir in subdirectories:
        _require_portable_filename_component(
            subdir.name,
            label="Dataset directory",
        )
        entry = _read_exported_dataset_entry(
            subdir,
            public_path=f"/{subdir.name}/",
        )
        if entry["id"] in seen_ids:
            raise ValueError(f"duplicate dataset id {entry['id']!r}.")
        seen_ids.add(entry["id"])
        entries.append(entry)
    return entries


@dataclass(frozen=True)
class _PreparedArtifact:
    path: Path
    size: int
    mtime_ns: int
    device: int
    inode: int
    content_type: str


def _require_artifact_path(value: object, *, label: str) -> str:
    if not isinstance(value, str) or not value:
        raise TypeError(f"{label} must be a non-empty string.")
    if value != value.strip() or value.startswith("/") or "\\" in value:
        raise ValueError(f"{label} must be one exact relative POSIX path.")
    parts = value.split("/")
    if any(
        part in {"", ".", ".."}
        or any(ord(character) < 32 or ord(character) == 127 for character in part)
        for part in parts
    ):
        raise ValueError(f"{label} must be one exact relative POSIX path.")
    return value


def _read_json_object(path: Path, *, label: str) -> dict:
    try:
        value = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, UnicodeError, json.JSONDecodeError) as error:
        raise ValueError(f"{label} must contain readable UTF-8 JSON.") from error
    if not isinstance(value, dict):
        raise TypeError(f"{label} must contain a JSON object.")
    return value


def _expand_artifact_pattern(
    pattern: object,
    *,
    key: str,
    label: str,
    extension: str | None = None,
) -> str:
    pattern_value = _require_artifact_path(pattern, label=label)
    value = pattern_value.replace("{key}", key)
    if extension is not None:
        value = value.replace("{ext}", extension)
    if "{" in value or "}" in value:
        raise ValueError(f"{label} contains an unsupported placeholder.")
    return _require_artifact_path(value, label=label)


def _declared_dataset_artifacts(
    dataset_dir: Path,
    *,
    include_published_state: bool,
) -> set[str]:
    """Return every artifact declared by one current prepared generation."""
    identity_path = dataset_dir / "dataset_identity.json"
    obs_manifest_path = dataset_dir / "obs_manifest.json"
    identity = _read_json_object(identity_path, label="dataset_identity.json")
    obs_manifest = _read_json_object(obs_manifest_path, label="obs_manifest.json")
    paths = {"dataset_identity.json", "obs_manifest.json"}

    embeddings = identity.get("embeddings")
    if not isinstance(embeddings, dict) or not isinstance(embeddings.get("files"), dict):
        raise ValueError("dataset_identity.json embeddings.files must be a JSON object.")
    for dimension, artifact_path in embeddings["files"].items():
        paths.add(
            _require_artifact_path(
                artifact_path,
                label=f"dataset_identity.json embeddings.files.{dimension}",
            )
        )

    schemas = obs_manifest.get("_obsSchemas")
    continuous_fields = obs_manifest.get("_continuousFields")
    categorical_fields = obs_manifest.get("_categoricalFields")
    if (
        not isinstance(schemas, dict)
        or not isinstance(continuous_fields, list)
        or not isinstance(categorical_fields, list)
    ):
        raise ValueError("obs_manifest.json must declare exact compact field schemas.")

    continuous_schema = schemas.get("continuous")
    if continuous_fields and not isinstance(continuous_schema, dict):
        raise ValueError("Continuous observation fields require their exact schema.")
    if not continuous_fields and continuous_schema is not None:
        raise ValueError("Continuous observation schema exists without any fields.")
    continuous_schema_values = continuous_schema if isinstance(continuous_schema, dict) else {}
    for index, field in enumerate(continuous_fields):
        if not isinstance(field, list) or not field:
            raise ValueError(f"obs_manifest.json continuous field {index} is invalid.")
        key = _require_portable_filename_component(
            field[0],
            label=f"Observation field {index}",
        )
        paths.add(
            _expand_artifact_pattern(
                continuous_schema_values.get("pathPattern"),
                key=key,
                label="obs continuous pathPattern",
            )
        )

    categorical_schema = schemas.get("categorical")
    if categorical_fields and not isinstance(categorical_schema, dict):
        raise ValueError("Categorical observation fields require their exact schema.")
    if not categorical_fields and categorical_schema is not None:
        raise ValueError("Categorical observation schema exists without any fields.")
    categorical_schema_values = categorical_schema if isinstance(categorical_schema, dict) else {}
    dtype_extensions = {"uint8": "u8", "uint16": "u16"}
    for index, field in enumerate(categorical_fields):
        if not isinstance(field, list) or len(field) < 3:
            raise ValueError(f"obs_manifest.json categorical field {index} is invalid.")
        key = _require_portable_filename_component(
            field[0],
            label=f"Observation field {index}",
        )
        dtype = field[2]
        if dtype not in dtype_extensions:
            raise ValueError(f"obs_manifest.json categorical field {index} has an invalid dtype.")
        paths.add(
            _expand_artifact_pattern(
                categorical_schema_values.get("codesPathPattern"),
                key=key,
                extension=dtype_extensions[dtype],
                label="obs categorical codesPathPattern",
            )
        )
        outlier_pattern = categorical_schema_values.get("outlierPathPattern")
        if outlier_pattern is not None:
            paths.add(
                _expand_artifact_pattern(
                    outlier_pattern,
                    key=key,
                    label="obs categorical outlierPathPattern",
                )
            )

    stats = identity.get("stats")
    if not isinstance(stats, dict):
        raise ValueError("dataset_identity.json stats must be a JSON object.")
    n_genes = stats.get("n_genes")
    if type(n_genes) is not int or n_genes < 0:
        raise ValueError("dataset_identity.json stats.n_genes must be non-negative.")
    var_manifest_path = dataset_dir / "var_manifest.json"
    if n_genes > 0:
        paths.add("var_manifest.json")
        var_manifest = _read_json_object(var_manifest_path, label="var_manifest.json")
        var_schema = var_manifest.get("_varSchema")
        fields = var_manifest.get("fields")
        if not isinstance(var_schema, dict) or not isinstance(fields, list):
            raise ValueError("var_manifest.json must declare one exact field schema.")
        for index, field in enumerate(fields):
            if not isinstance(field, list) or not field:
                raise ValueError(f"var_manifest.json field {index} is invalid.")
            key = _require_portable_filename_component(
                field[0],
                label=f"Gene field {index}",
            )
            paths.add(
                _expand_artifact_pattern(
                    var_schema.get("pathPattern"),
                    key=key,
                    label="var pathPattern",
                )
            )
        if len(fields) != n_genes:
            raise ValueError("var_manifest.json field count must match identity stats.n_genes.")
    elif var_manifest_path.exists():
        raise ValueError("var_manifest.json is present while identity stats.n_genes is zero.")

    has_connectivity = stats.get("has_connectivity")
    if type(has_connectivity) is not bool:
        raise ValueError("dataset_identity.json stats.has_connectivity must be a boolean.")
    connectivity_manifest_path = dataset_dir / "connectivity_manifest.json"
    if has_connectivity:
        paths.add("connectivity_manifest.json")
        connectivity = _read_json_object(
            connectivity_manifest_path,
            label="connectivity_manifest.json",
        )
        for key in ("sourcesPath", "destinationsPath", "weightsPath"):
            paths.add(
                _require_artifact_path(
                    connectivity.get(key),
                    label=f"connectivity_manifest.json {key}",
                )
            )
    elif connectivity_manifest_path.exists():
        raise ValueError("connectivity_manifest.json is present while connectivity is disabled.")

    vector_fields = identity.get("vector_fields")
    if vector_fields is not None:
        if not isinstance(vector_fields, dict) or not isinstance(
            vector_fields.get("fields"),
            dict,
        ):
            raise ValueError("dataset_identity.json vector_fields.fields must be an object.")
        for field_id, field in vector_fields["fields"].items():
            if not isinstance(field, dict) or not isinstance(field.get("files"), dict):
                raise ValueError(f"dataset_identity.json vector field {field_id!r} is invalid.")
            for dimension, artifact_path in field["files"].items():
                paths.add(
                    _require_artifact_path(
                        artifact_path,
                        label=f"vector field {field_id!r} file {dimension}",
                    )
                )
    if include_published_state:
        paths.add(_PUBLISHED_STATE_MANIFEST)
        paths.add(_PUBLISHED_STATE_BUNDLE)
    return paths


def _build_prepared_artifact_inventory(
    data_dir: Path,
    datasets: list[dict[str, str]],
) -> dict[str, _PreparedArtifact]:
    root = data_dir.resolve(strict=True)
    inventory: dict[str, _PreparedArtifact] = {}
    for dataset in datasets:
        public_path = dataset["path"]
        prefix = "" if public_path == "/" else public_path.strip("/")
        dataset_dir = root if not prefix else root / prefix
        has_state_manifest = "state_manifest" in dataset
        has_state_sha256 = "state_sha256" in dataset
        if has_state_manifest != has_state_sha256:
            raise ValueError(
                "Prepared dataset state_manifest and state_sha256 must be declared together."
            )
        if has_state_manifest:
            current_state = _read_optional_published_state(dataset_dir)
            expected_state = {
                "state_manifest": dataset["state_manifest"],
                "state_sha256": dataset["state_sha256"],
            }
            if current_state != expected_state:
                raise ValueError(
                    "Published sample state changed while the artifact inventory was built."
                )
        for relative_path in sorted(
            _declared_dataset_artifacts(
                dataset_dir,
                include_published_state=has_state_manifest,
            )
        ):
            request_path = relative_path if not prefix else f"{prefix}/{relative_path}"
            candidate = dataset_dir / relative_path
            current = dataset_dir
            for part in Path(relative_path).parts:
                current = current / part
                if current.is_symlink():
                    raise ValueError(
                        f"Declared artifact must not traverse a symbolic link: {request_path}"
                    )
            try:
                resolved = candidate.resolve(strict=True)
                resolved.relative_to(root)
            except (FileNotFoundError, ValueError) as error:
                raise ValueError(
                    f"Declared artifact is missing or outside the export root: {request_path}"
                ) from error
            metadata = resolved.stat()
            if not stat.S_ISREG(metadata.st_mode):
                raise ValueError(f"Declared artifact must be a regular file: {request_path}")
            if request_path in inventory:
                raise ValueError(f"Prepared artifact path is duplicated: {request_path}")
            inventory[request_path] = _PreparedArtifact(
                path=resolved,
                size=metadata.st_size,
                mtime_ns=metadata.st_mtime_ns,
                device=metadata.st_dev,
                inode=metadata.st_ino,
                content_type=(
                    "application/json"
                    if relative_path.endswith(".json")
                    else "application/octet-stream"
                ),
            )
    return inventory


def _parse_byte_range(value: str | None, size: int) -> tuple[int, int] | None:
    if value is None:
        return None
    if type(size) is not int or size < 0:
        raise ValueError("Artifact size must be a non-negative integer.")
    match = re.fullmatch(r"bytes=(\d{0,20})-(\d{0,20})", value)
    if match is None or (not match.group(1) and not match.group(2)):
        raise ValueError("Range must contain one exact byte interval.")
    if size == 0:
        raise ValueError("An empty artifact has no satisfiable byte range.")
    if match.group(1):
        start = int(match.group(1))
        end = int(match.group(2)) if match.group(2) else size - 1
        if start >= size or end < start:
            raise ValueError("Range is outside the artifact.")
        return start, min(end, size - 1)
    suffix_length = int(match.group(2))
    if suffix_length <= 0:
        raise ValueError("Range suffix length must be positive.")
    return max(0, size - suffix_length), size - 1


class CORSRequestHandler(CORSMixin, SimpleHTTPRequestHandler):
    """Serve only endpoints and immutable prepared artifacts in the active contract."""

    allow_caching = True  # Static files can be cached

    def __init__(
        self,
        *args,
        data_dir: Path,
        server_info: dict,
        datasets: list[dict[str, str]],
        artifact_inventory: dict[str, _PreparedArtifact],
        serve_web_ui: bool,
        web_cache_dir: Path,
        **kwargs,
    ):
        self.data_dir = data_dir
        self.server_info = server_info
        self.datasets = datasets
        self.artifact_inventory = artifact_inventory
        self.serve_web_ui = serve_web_ui
        self.web_cache_dir = web_cache_dir
        # Must call super().__init__ last because it calls do_GET immediately
        super().__init__(*args, directory=str(data_dir), **kwargs)

    def end_headers(self):
        """Add CORS headers to every response."""
        self.add_cors_headers()
        super().end_headers()

    def do_POST(self):
        """Handle POST requests (events from frontend)."""
        if self.handle_event_post():
            return
        if self.handle_session_bundle_post():
            return
        # No other POST endpoints - return 404
        self.send_error_response(404, f"POST not supported for path: {self.path}")

    def do_GET(self):
        """Serve one exact GET endpoint or prepared artifact."""
        self._handle_read_request(head_only=False)

    def do_HEAD(self):
        """Serve GET metadata without a response body."""
        self._handle_read_request(head_only=True)

    def _handle_read_request(self, *, head_only: bool) -> None:
        path = self._canonical_request_path()
        if path is None:
            self.send_error_response(404, "Request path is not in the active contract")
            return

        if path == "/_cellucid/health":
            self.send_json(
                {
                    "status": "ok",
                    "type": "exported",
                    "version": self.server_info["version"],
                },
                head_only=head_only,
            )
            return

        if path == "/_cellucid/info":
            self.send_json(self.server_info, head_only=head_only)
            return

        if path == "/_cellucid/datasets":
            self.send_json({"datasets": self.datasets}, head_only=head_only)
            return

        if self.serve_web_ui and self.handle_web_asset_get(path, head_only=head_only):
            return
        if self.serve_web_ui and (
            path == f"/{WEB_ASSET_INVENTORY_FILENAME}"
            or path == "/assets"
            or path.startswith("/assets/")
        ):
            self.send_error_response(404, "Web asset is not declared by the active build")
            return

        if path in {"/", "/index.html"}:
            self.send_error_response(503, "Cellucid viewer UI unavailable")
            return

        self._serve_prepared_artifact(path[1:], head_only=head_only)

    def _canonical_request_path(self) -> str | None:
        """Return one exact ASCII origin-form path without decoding aliases."""
        parsed = urlparse(self.path)
        exact_target = parsed.path
        if parsed.query:
            exact_target += f"?{parsed.query}"
        if (
            parsed.scheme
            or parsed.netloc
            or parsed.params
            or parsed.fragment
            or exact_target != self.path
            or (parsed.query and parsed.path != "/")
            or not parsed.path.startswith("/")
            or "\\" in parsed.path
            or "%" in parsed.path
        ):
            return None
        try:
            parsed.path.encode("ascii")
        except UnicodeEncodeError:
            return None
        if any(
            ord(character) < 33 or ord(character) == 127
            for character in parsed.path
            if character != "/"
        ):
            return None
        if parsed.path != "/":
            parts = parsed.path[1:].split("/")
            if any(part in {"", ".", ".."} for part in parts):
                return None
        return parsed.path

    @staticmethod
    def _artifact_metadata_matches(
        metadata: os.stat_result,
        artifact: _PreparedArtifact,
    ) -> bool:
        return (
            stat.S_ISREG(metadata.st_mode)
            and metadata.st_size == artifact.size
            and metadata.st_mtime_ns == artifact.mtime_ns
            and metadata.st_dev == artifact.device
            and metadata.st_ino == artifact.inode
        )

    def _open_prepared_artifact(self, artifact: _PreparedArtifact) -> int:
        """Open one unchanged regular artifact without following its final symlink."""
        try:
            if artifact.path.is_symlink() or artifact.path.resolve(strict=True) != artifact.path:
                raise OSError("Prepared artifact path changed.")
            before = artifact.path.stat()
            if not self._artifact_metadata_matches(before, artifact):
                raise OSError("Prepared artifact metadata changed.")

            flags = os.O_RDONLY | getattr(os, "O_BINARY", 0)
            nofollow = getattr(os, "O_NOFOLLOW", 0)
            if nofollow:
                flags |= nofollow
            descriptor = os.open(artifact.path, flags)
        except (OSError, RuntimeError):
            raise

        try:
            opened = os.fstat(descriptor)
            after = artifact.path.stat()
            if not self._artifact_metadata_matches(
                opened, artifact
            ) or not self._artifact_metadata_matches(after, artifact):
                raise OSError("Prepared artifact changed while it was opened.")
        except BaseException:
            os.close(descriptor)
            raise
        return descriptor

    def _serve_prepared_artifact(self, request_path: str, *, head_only: bool) -> None:
        artifact = self.artifact_inventory.get(request_path)
        if artifact is None:
            self.send_error_response(404, "Prepared artifact is not declared")
            return

        range_values = self._request_headers().get_all("Range", [])
        if len(range_values) > 1:
            self._send_range_error(artifact.size)
            return
        try:
            interval = _parse_byte_range(
                range_values[0] if range_values else None,
                artifact.size,
            )
        except ValueError:
            self._send_range_error(artifact.size)
            return

        try:
            descriptor = self._open_prepared_artifact(artifact)
        except OSError:
            self.send_error_response(
                HTTPStatus.CONFLICT,
                "Prepared artifact changed after server validation",
            )
            return

        start, end = interval if interval is not None else (0, artifact.size - 1)
        content_length = 0 if artifact.size == 0 else end - start + 1
        self.send_response(HTTPStatus.PARTIAL_CONTENT if interval is not None else HTTPStatus.OK)
        self.send_header("Content-Type", artifact.content_type)
        self.send_header("Content-Length", str(content_length))
        self.send_header("Accept-Ranges", "bytes")
        if interval is not None:
            self.send_header(
                "Content-Range",
                f"bytes {start}-{end}/{artifact.size}",
            )
        self.end_headers()

        try:
            if not head_only and content_length:
                os.lseek(descriptor, start, os.SEEK_SET)
                remaining = content_length
                while remaining:
                    chunk = os.read(descriptor, min(1024 * 1024, remaining))
                    if not chunk:
                        raise OSError("Prepared artifact ended during response.")
                    self._response_writer().write(chunk)
                    remaining -= len(chunk)
        finally:
            os.close(descriptor)

    def _send_range_error(self, artifact_size: int) -> None:
        body = b"Requested byte range is not satisfiable"
        self.send_response(HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Content-Range", f"bytes */{artifact_size}")
        self.send_header("Accept-Ranges", "bytes")
        self.end_headers()
        if self.command != "HEAD":
            self._response_writer().write(body)

    def _list_datasets(self) -> list[dict]:
        """Validate and return the prepared datasets under ``data_dir``."""
        return _list_exported_datasets(self.data_dir)

    def _is_dataset_dir(self, path: Path) -> bool:
        """Check if a directory is a valid cellucid dataset."""
        try:
            _read_exported_dataset_entry(path, public_path="/")
        except (OSError, TypeError, ValueError):
            return False
        return True

    def _get_dataset_identity_fields(self, path: Path) -> tuple[str, str]:
        """Return (dataset_id, dataset_name) for a dataset directory."""
        entry = _read_exported_dataset_entry(path, public_path="/")
        return entry["id"], entry["name"]

    def log_message(self, format: str, *args):
        """Override to use Python logging instead of stderr."""
        logger.debug("%s - %s", self.address_string(), format % args)


[docs] class CellucidServer: """ Cellucid data server for serving datasets over HTTP. Supports multiple deployment modes: - Local: Direct browser access on localhost - SSH tunnel: Access via port forwarding from remote server - Jupyter: Embedded in notebook environment Example: server = CellucidServer("/path/to/data") server.start() # Blocking # Or non-blocking: server.start_background() # ... do other things ... server.stop() """
[docs] def __init__( self, data_dir: str | Path, port: int = DEFAULT_PORT, host: str = DEFAULT_HOST, open_browser: bool = False, quiet: bool = False, *, serve_web_ui: bool = True, web_source_url: str = CELLUCID_WEB_URL, web_cache_dir: str | Path | None = None, ): """ Initialize the server. Args: data_dir: Path to the dataset directory (single dataset or multi-dataset) port: Port to serve on (default: 8765) host: Host to bind to (default: 127.0.0.1 for localhost only) open_browser: Whether to open the browser on start quiet: Suppress info messages serve_web_ui: Establish and serve the exact current web build. web_source_url: Origin publishing the web asset inventory. web_cache_dir: Directory holding the active verified web build. """ self.data_dir = Path(data_dir).resolve() self.port = require_server_port(port) self.host = host if type(open_browser) is not bool: raise TypeError("open_browser must be a boolean") if type(quiet) is not bool: raise TypeError("quiet must be a boolean") self.open_browser = open_browser self.quiet = quiet if type(serve_web_ui) is not bool: raise TypeError("serve_web_ui must be a boolean") self.serve_web_ui = serve_web_ui self.web_source_url = web_source_url self.web_cache_dir = ( Path(web_cache_dir).expanduser().resolve() if web_cache_dir is not None else _web_cache_dir() ) # Step 1: Validate dataset if not quiet: print_step(1, 3, "Validating dataset") print_detail("Path", str(self.data_dir)) if not self.data_dir.exists(): raise FileNotFoundError(f"Data directory not found: {self.data_dir}") if not self.data_dir.is_dir(): raise NotADirectoryError(f"Prepared-data path must be a directory: {self.data_dir}") self._datasets = _list_exported_datasets(self.data_dir) if not self._datasets: raise ValueError("Prepared-data path does not contain one complete current dataset.") self._artifact_inventory = _build_prepared_artifact_inventory( self.data_dir, self._datasets, ) if not quiet: print_success("Dataset valid") # Step 2: Load dataset info if not quiet: print_step(2, 3, "Loading dataset info") self._print_dataset_info() print_success("Dataset loaded") self._server: HTTPServer | None = None self._thread: threading.Thread | None = None self._running = False self._started = False self._closed = False self._serving = False self._background_error: BaseException | None = None from . import __version__ self.server_info = { "version": __version__, "host": self.host, "port": self.port, "mode": "standalone", }
def _print_dataset_info(self): """Print information about the dataset.""" if len(self._datasets) != 1 or self._datasets[0]["path"] != "/": print_detail("Datasets", str(len(self._datasets))) return identity = _read_json_object( self.data_dir / "dataset_identity.json", label="dataset_identity.json", ) stats = identity.get("stats") if not isinstance(stats, dict): raise TypeError("dataset_identity.json stats must be a JSON object.") n_cells = stats.get("n_cells") n_genes = stats.get("n_genes") has_connectivity = stats.get("has_connectivity") if type(n_cells) is not int or n_cells < 0: raise ValueError("dataset_identity.json stats.n_cells must be non-negative.") if type(n_genes) is not int or n_genes < 0: raise ValueError("dataset_identity.json stats.n_genes must be non-negative.") if type(has_connectivity) is not bool: raise ValueError("dataset_identity.json stats.has_connectivity must be a boolean.") print_detail("Cells", f"{n_cells:,}") print_detail("Genes", f"{n_genes:,}") print_detail("Connectivity", "yes" if has_connectivity else "no") @property def url(self) -> str: """Get the URL of the currently running server.""" if not self._running or self._server is None: raise RuntimeError( "CellucidServer URL is unavailable because the server is not running" ) return f"http://{self.host}:{self.port}" @property def viewer_url(self) -> str: """Open this server's prepared-data catalog in the verified viewer. The viewer selects the sole declared dataset when the catalog is unique. A multi-dataset catalog requires an exact dataset selection; this URL never embeds or guesses an arbitrary catalog entry. """ return f"{self.url}/?source=remote"
[docs] def start(self, blocking: bool = True): """Start this single-use server.""" if type(blocking) is not bool: raise TypeError("blocking must be a boolean") if self._running: raise RuntimeError("Server is already running.") if self._started or self._closed: raise RuntimeError( "CellucidServer is single-use and has been closed. Create a new server instance." ) self._started = True try: # Step 3: Start server if not self.quiet: print_step(3, 3, "Starting server") if self.serve_web_ui: from .web_cache import ensure_web_ui_cached if not self.quiet: print_detail( "Viewer UI generation", "establishing exact configured source", ) ensure_web_ui_cached( cache_dir=self.web_cache_dir, source_url=self.web_source_url, force=True, show_progress=not self.quiet, ) if not self.quiet: print_success("Viewer UI generation established") handler = partial( CORSRequestHandler, data_dir=self.data_dir, server_info=self.server_info, datasets=self._datasets, artifact_inventory=self._artifact_inventory, serve_web_ui=self.serve_web_ui, web_cache_dir=self.web_cache_dir, ) self._server = HTTPServer((self.host, self.port), handler) self.port = require_server_port(self._server.server_address[1]) self.server_info["port"] = self.port self._running = True if not self.quiet: print_success("Server ready") print_server_banner(self.url, self.viewer_url) if blocking: if self.open_browser and webbrowser.open(self.viewer_url) is not True: raise RuntimeError(f"Could not open the browser for {self.viewer_url}") self._serving = True try: self._server.serve_forever() finally: self._serving = False self._finish_serving() else: self._serve_entered = threading.Event() self._thread = threading.Thread( target=self._serve_in_background, daemon=True, ) self._thread.start() self._serve_entered.wait() if self.open_browser and webbrowser.open(self.viewer_url) is not True: raise RuntimeError(f"Could not open the browser for {self.viewer_url}") except BaseException: self._rollback_failed_start(shutdown=self._thread is not None) raise
def _serve_in_background(self) -> None: """Run the bound server and retain an exact asynchronous failure.""" self._serving = True self._serve_entered.set() serving_error: BaseException | None = None try: server = self._server if server is None: raise RuntimeError("CellucidServer lost its bound HTTP server before serving") server.serve_forever() except BaseException as error: serving_error = error finally: self._serving = False try: self._finish_serving() except BaseException as cleanup_error: if serving_error is None: serving_error = cleanup_error else: logger.exception( "Prepared-data server cleanup also failed after its serving loop failed" ) self._background_error = serving_error if serving_error is not None: logger.error( "Prepared-data background server failed", exc_info=( type(serving_error), serving_error, serving_error.__traceback__, ), ) def _finish_serving(self) -> None: """Close the socket after the serving loop has ended.""" self._running = False server = self._server self._server = None self._closed = True if server is not None: try: server.server_close() except BaseException as error: raise RuntimeError( f"Prepared-data server cleanup failed: {type(error).__name__}: {error}" ) from error def _rollback_failed_start(self, *, shutdown: bool) -> None: """Release acquired resources without replacing the startup exception.""" self._running = False server = self._server if server is not None and shutdown and self._serving: try: server.shutdown() except BaseException: logger.exception( "Failed to shut down the prepared-data server after startup failed" ) if server is not None: try: server.server_close() except BaseException: logger.exception("Failed to close the prepared-data socket after startup failed") thread = self._thread if thread is not None and thread is not threading.current_thread() and thread.is_alive(): thread.join() self._server = None self._thread = None self._serving = False self._closed = True
[docs] def start_background(self): """Start the server in a background thread.""" self.start(blocking=False)
[docs] def stop(self): """Stop this server and release its socket.""" self._running = False failures: list[BaseException] = [] server = self._server thread = self._thread if server is not None and self._serving: try: server.shutdown() except BaseException as error: failures.append(error) if server is not None: try: server.server_close() except BaseException as error: failures.append(error) self._server = None if thread is not None and thread is not threading.current_thread() and thread.is_alive(): thread.join() self._thread = None self._serving = False self._closed = True if not self.quiet: print("Server stopped") if failures: details = "; ".join(f"{type(error).__name__}: {error}" for error in failures) raise RuntimeError(f"Prepared-data server shutdown failed: {details}") from failures[0]
[docs] def is_running(self) -> bool: """Check if the server is running.""" return self._running
[docs] def wait(self): """Wait for the background server to stop.""" thread = self._thread if thread is not None: try: thread.join() except BaseException: self.stop() raise if self._background_error is not None: raise self._background_error
[docs] def serve( data_dir: str | Path, port: int = DEFAULT_PORT, host: str = DEFAULT_HOST, open_browser: bool = True, quiet: bool = False, *, serve_web_ui: bool = True, web_source_url: str = CELLUCID_WEB_URL, web_cache_dir: str | Path | None = None, ): """ Serve a cellucid dataset directory. This is the main entry point for serving data. It starts an HTTP server that serves the dataset files with CORS headers enabled. Args: data_dir: Path to the dataset directory port: Port to serve on (default: 8765) host: Host to bind to (default: 127.0.0.1) open_browser: Whether to open the viewer in browser (default: True) quiet: Suppress info messages serve_web_ui: Establish and serve the exact current web build. web_source_url: Origin publishing the web asset inventory. web_cache_dir: Directory holding the active verified web build. Example: >>> from cellucid import serve >>> serve("/path/to/my_dataset") # For remote server access via SSH: >>> serve("/path/to/data", host="0.0.0.0") # Then on local machine: ssh -L 8765:localhost:8765 remote-server """ server = CellucidServer( data_dir=data_dir, port=port, host=host, open_browser=open_browser, quiet=quiet, serve_web_ui=serve_web_ui, web_source_url=web_source_url, web_cache_dir=web_cache_dir, ) server.start()