"""
AnnData Server for Cellucid
HTTP server that serves AnnData data dynamically in the Cellucid format.
This allows direct visualization of AnnData files without pre-export.
Supports:
- h5ad files: Single HDF5 files (backed mode for lazy loading)
- zarr stores: Directory-based stores materialized by anndata.read_zarr
- In-memory AnnData objects
Usage:
from cellucid.anndata_server import serve_anndata
# Serve an h5ad file
serve_anndata(
"/path/to/data.h5ad",
dataset_name="Example",
dataset_id="example",
)
# Serve a zarr store (must be a directory)
serve_anndata(
"/path/to/data.zarr",
dataset_name="Example",
dataset_id="example",
)
# Or an in-memory AnnData
serve_anndata(
adata,
dataset_name="Example",
dataset_id="example",
)
"""
from __future__ import annotations
import json
import logging
import re
import threading
import webbrowser
from functools import partial
from http import HTTPStatus
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import unquote, 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 .anndata_adapter import AnnDataAdapter, _classify_anndata_path
from .connectivity_contract import build_connectivity_manifest
if TYPE_CHECKING:
import anndata
logger = logging.getLogger("cellucid.anndata_server")
_QVALUE_RE = re.compile(r"(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\Z")
def _accepts_gzip(header_value: str) -> bool:
"""Return whether an Accept-Encoding value permits the exact gzip coding."""
gzip_qualities: list[float] = []
wildcard_qualities: list[float] = []
for item in header_value.split(","):
segments = [segment.strip() for segment in item.split(";")]
coding = segments[0].lower()
if not coding:
continue
quality = 1.0
seen_quality = False
valid = True
for parameter in segments[1:]:
if "=" not in parameter:
valid = False
break
name, value = (part.strip() for part in parameter.split("=", 1))
if name.lower() != "q" or seen_quality or not _QVALUE_RE.fullmatch(value):
valid = False
break
quality = float(value)
seen_quality = True
if not valid:
continue
if coding == "gzip":
gzip_qualities.append(quality)
elif coding == "*":
wildcard_qualities.append(quality)
if gzip_qualities:
return max(gzip_qualities) > 0
return bool(wildcard_qualities and max(wildcard_qualities) > 0)
class AnnDataRequestHandler(CORSMixin, SimpleHTTPRequestHandler):
"""
HTTP handler for serving AnnData data in Cellucid format.
Routes:
/dataset_identity.json - Dataset metadata
/obs_manifest.json - Cell metadata manifest
/var_manifest.json - Gene expression manifest
/connectivity_manifest.json - Connectivity manifest
/points_{dim}d.bin - Embedding coordinates
/vectors/{fieldId}_{dim}d.bin - Vector field displacement vectors
/obs/{field}.values.f32 - Continuous obs field
/obs/{field}.codes.{ext} - Categorical obs field codes
/obs/{field}.outliers.f32 - Categorical outlier quantiles
/var/{gene}.values.f32 - Gene expression values
/connectivity/edges.src.bin - Edge sources
/connectivity/edges.dst.bin - Edge destinations
/connectivity/edges.weights.f64.bin - Edge weights
/_cellucid/health - Health check
/_cellucid/info - Server info
/_cellucid/events - POST endpoint for frontend events (hooks)
"""
allow_caching = False # Data is dynamic, don't cache
def __init__(
self,
*args,
adapter: AnnDataAdapter,
server_info: dict,
metadata_bodies: dict[str, bytes],
payload_lengths: dict[str, int],
serve_web_ui: bool,
web_cache_dir: Path,
**kwargs,
):
self.adapter = adapter
self.server_info = server_info
self.metadata_bodies = metadata_bodies
self.payload_lengths = payload_lengths
self.serve_web_ui = serve_web_ui
self.web_cache_dir = web_cache_dir
# We serve "virtual" files via explicit route handlers, but still want
# the normal BaseHTTPRequestHandler lifecycle (parse_request, etc.).
# `directory` is unused because we never call the default file-serving
# paths for AnnData mode.
super().__init__(*args, **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_HEAD(self):
"""Handle HEAD requests (for urlExists checks)."""
self.do_GET(head_only=True)
def do_GET(self, head_only: bool = False):
"""Handle GET requests."""
parsed = urlparse(self.path)
raw_path = unquote(parsed.path)
if self.serve_web_ui and self.handle_web_asset_get(raw_path, head_only=head_only):
return
if self.serve_web_ui and (
raw_path == f"/{WEB_ASSET_INVENTORY_FILENAME}"
or raw_path == "/assets"
or raw_path.startswith("/assets/")
):
self.send_error_response(404, "Web asset is not declared by the active build")
return
path = raw_path.lstrip("/")
# HEAD always describes the uncompressed representation so it can be
# answered from validated metadata without materializing payload bytes.
accept_encoding = self.headers.get("Accept-Encoding", "")
supports_gzip = not head_only and _accepts_gzip(accept_encoding)
try:
# Root path - redirect to viewer
if path == "" or path == "index.html":
self.send_error_response(503, "Cellucid viewer UI unavailable")
return
if path in self.metadata_bodies:
self._send_preencoded_json(self.metadata_bodies[path], head_only)
elif path == "_cellucid/health":
self.send_json(
{
"status": "ok",
"type": "anndata",
"version": self.server_info["version"],
"format": self.server_info["format"],
"is_backed": self.server_info["is_backed"],
"n_cells": self.adapter.n_cells,
"n_genes": self.adapter.n_genes,
},
head_only,
)
elif path == "_cellucid/info":
self.send_json(self.server_info, head_only)
elif path == "connectivity_manifest.json":
self.send_error_response(404, "No connectivity data")
elif head_only and path in self.payload_lengths:
self._send_binary_head(self.payload_lengths[path])
elif path not in self.payload_lengths:
self.send_error_response(404, f"Not found: {path}")
elif re.fullmatch(r"points_[123]d\.bin", path):
self._handle_points(path, head_only, supports_gzip)
elif path.startswith("obs/"):
self._handle_obs(path, head_only, supports_gzip)
elif path.startswith("var/"):
self._handle_var(path, head_only, supports_gzip)
elif path.startswith("connectivity/"):
self._handle_connectivity(path, head_only, supports_gzip)
elif path.startswith("vectors/"):
self._handle_vector_fields(path, head_only, supports_gzip)
else:
self.send_error_response(404, f"Not found: {path}")
except Exception:
logger.exception("Error handling %s", path)
self.send_error_response(500, "Internal server error")
def _send_preencoded_json(self, body: bytes, head_only: bool) -> None:
"""Send validated JSON bytes without rebuilding them for HEAD."""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if not head_only:
self.wfile.write(body)
def _send_binary_head(self, content_length: int) -> None:
"""Send headers for an uncompressed scientific payload."""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(content_length))
self.end_headers()
def _handle_points(self, path: str, head_only: bool, supports_gzip: bool):
"""Handle points_Xd.bin requests."""
match = re.fullmatch(r"points_([123])d\.bin", path)
if not match:
self.send_error_response(404, f"Invalid points path: {path}")
return
dim = int(match.group(1))
try:
# Compress if client supports gzip (transparent compression for better network perf)
compress = supports_gzip
data = self.adapter.get_points_binary(dim, compress=compress)
self.send_binary(data, head_only=head_only, compressed=compress)
except ValueError:
self.send_error_response(404, "Points data not available")
def _handle_vector_fields(self, path: str, head_only: bool, supports_gzip: bool):
"""
Handle vector field requests under /vectors/.
Supported paths:
- vectors/{fieldId}_{dim}d.bin
"""
match = re.fullmatch(r"vectors/(.+)_([123])d\.bin", path)
if not match:
self.send_error_response(404, f"Invalid vectors path: {path}")
return
field_id = match.group(1)
dim = int(match.group(2))
try:
data = self.adapter.get_vector_field_binary(
field_id,
dim,
compress=supports_gzip,
)
self.send_binary(
data,
head_only=head_only,
compressed=supports_gzip,
)
except ValueError as e:
self.send_error_response(404, str(e))
def _handle_obs(self, path: str, head_only: bool, supports_gzip: bool):
"""
Handle obs field requests.
Supported paths:
- obs/{field}.values.f32
- obs/{field}.codes.u8 or obs/{field}.codes.u16
- obs/{field}.outliers.f32
"""
# Remove 'obs/' prefix
filename = path[4:]
# Compress if client supports gzip (transparent compression)
compress = supports_gzip
# Parse filename: {field}.{type}.{dtype}
# Examples: cell_type.codes.u8, n_counts.values.f32, cluster.outliers.f32
parts = filename.rsplit(".", 2) # Split from right: [field, type, dtype]
if len(parts) < 3:
self.send_error_response(404, f"Invalid obs path format: {path}")
return
field_name = parts[0]
data_type = parts[1] # 'values', 'codes', or 'outliers'
actual_key = self.adapter.get_obs_key_for_payload_component(field_name)
if actual_key is None:
self.send_error_response(404, f"Obs field not found: {field_name}")
return
try:
if data_type == "values":
# Continuous field values
data = self.adapter.get_obs_continuous_values(actual_key, compress=compress)
self.send_binary(data, head_only=head_only, compressed=compress)
elif data_type == "codes":
# Categorical codes
data, categories, missing = self.adapter.get_obs_categorical_codes(
actual_key, compress=compress
)
self.send_binary(data, head_only=head_only, compressed=compress)
elif data_type == "outliers":
# Outlier quantiles for categorical field
data = self.adapter.get_obs_outlier_quantiles(actual_key, compress=compress)
self.send_binary(data, head_only=head_only, compressed=compress)
else:
self.send_error_response(
404, f"Unknown obs data type: {data_type} (expected values, codes, or outliers)"
)
except Exception:
logger.exception("Error handling obs request: %s", path)
self.send_error_response(500, "Internal server error")
def _handle_var(self, path: str, head_only: bool, supports_gzip: bool):
"""
Handle var (gene expression) requests.
Supported paths:
- var/{gene}.values.f32
"""
# Remove 'var/' prefix
filename = path[4:]
# Compress if client supports gzip (transparent compression)
compress = supports_gzip
# Check format: {gene}.values.f32
if not filename.endswith(".values.f32"):
self.send_error_response(
404, f"Invalid var path format: {path} (expected {{gene}}.values.f32)"
)
return
gene_safe = filename[:-11] # Remove '.values.f32'
actual_gene = self.adapter.get_gene_id_for_payload_component(gene_safe)
if actual_gene is None:
self.send_error_response(404, f"Gene not found: {gene_safe}")
return
try:
data = self.adapter.get_gene_expression(actual_gene, compress=compress)
self.send_binary(data, head_only=head_only, compressed=compress)
except KeyError:
self.send_error_response(404, "Gene not found")
except Exception:
logger.exception("Error handling var request: %s", path)
self.send_error_response(500, "Internal server error")
def _handle_connectivity(self, path: str, head_only: bool, supports_gzip: bool):
"""
Handle connectivity edge requests.
Supported paths:
- connectivity/edges.src.bin - Edge source indices
- connectivity/edges.dst.bin - Edge destination indices
- connectivity/edges.weights.f64.bin - Edge Float64 weights
"""
filename = path.split("/")[-1]
# Compress if client supports gzip (transparent compression)
compress = supports_gzip
try:
(
sources_data,
dests_data,
weights_data,
_n_edges,
_max_neighbors,
) = self.adapter.get_connectivity_edges(compress=compress)
if filename == "edges.src.bin":
self.send_binary(sources_data, head_only=head_only, compressed=compress)
elif filename == "edges.dst.bin":
self.send_binary(dests_data, head_only=head_only, compressed=compress)
elif filename == "edges.weights.f64.bin":
self.send_binary(weights_data, head_only=head_only, compressed=compress)
else:
self.send_error_response(
404,
"Unknown connectivity file: "
f"{filename} (expected edges.src.bin, edges.dst.bin, "
"or edges.weights.f64.bin)",
)
except ValueError:
self.send_error_response(404, "Connectivity data not available")
except Exception:
logger.exception("Error handling connectivity request: %s", path)
self.send_error_response(500, "Internal server error")
def log_message(self, format: str, *args):
"""Override to use Python logging."""
logger.debug("%s - %s", self.address_string(), format % args)
[docs]
class AnnDataServer:
"""
Server for serving AnnData data in Cellucid format.
Examples
--------
Start a blocking server::
AnnDataServer(
adata,
dataset_name="Example",
dataset_id="example",
).start()
Start and stop a background server::
server = AnnDataServer(
adata,
dataset_name="Example",
dataset_id="example",
)
server.start_background()
server.stop()
"""
[docs]
def __init__(
self,
data: str | Path | anndata.AnnData,
port: int = DEFAULT_PORT,
host: str = DEFAULT_HOST,
open_browser: bool = False,
quiet: bool = False,
*,
latent_key: str | None = None,
gene_id_column: str | None = None,
normalize_embeddings: bool = True,
centroid_outlier_quantile: float = 0.95,
centroid_min_points: int = 10,
dataset_name: str,
dataset_id: str,
vector_field_default: str | None = None,
serve_web_ui: bool = True,
web_source_url: str = CELLUCID_WEB_URL,
web_cache_dir: str | Path | None = None,
) -> None:
"""
Initialize the server.
Parameters
----------
data : str, Path, or AnnData
Path to h5ad file or AnnData object.
port : int
Port to serve on.
host : str
Host to bind to.
open_browser : bool
Whether to open browser on start.
quiet : bool
Suppress info messages.
latent_key : str, optional
Explicit key in ``obsm`` for the latent space.
gene_id_column : str, optional
Exact column in ``var`` containing gene identifiers. If None,
identifiers come from ``var.index``.
normalize_embeddings : bool
Whether to normalize embeddings into the viewer coordinate range.
centroid_outlier_quantile : float
Quantile used when computing categorical centroids.
centroid_min_points : int
Minimum category size used for centroid computation.
dataset_name : str
Explicit human-readable dataset name.
dataset_id : str
Explicit stable dataset identifier.
vector_field_default : str, optional
Exact field id required when multiple UMAP vector fields exist.
serve_web_ui : bool
Establish and serve the exact current web build.
web_source_url : str
Origin publishing the web asset inventory.
web_cache_dir : str or Path, optional
Directory holding the active verified web build.
"""
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()
)
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
# Step 1: Detect format
format_name: str
self.data_format: str
if isinstance(data, str | Path):
format_name = _classify_anndata_path(data)
self.data_source = str(data)
self.data_format = format_name
if not quiet:
print_step(1, 4, "Detecting format")
print_detail("Path", str(data))
print_detail("Format", format_name)
print_success("Format detected")
else:
# In-memory AnnData
self.data_source = "in-memory AnnData"
self.data_format = "in-memory"
if not quiet:
print_step(1, 4, "Detecting format")
print_detail("Source", "in-memory AnnData")
print_success("Format detected")
# Step 2: Load file
if not quiet:
print_step(2, 4, "Loading AnnData")
mode = "read-only backed h5ad" if self.data_format == "h5ad" else "in-memory"
print_detail("Mode", mode)
if isinstance(data, str | Path):
self.adapter = AnnDataAdapter.from_file(
data,
latent_key=latent_key,
gene_id_column=gene_id_column,
normalize_embeddings=normalize_embeddings,
centroid_outlier_quantile=centroid_outlier_quantile,
centroid_min_points=centroid_min_points,
dataset_name=dataset_name,
dataset_id=dataset_id,
vector_field_default=vector_field_default,
)
else:
self.adapter = AnnDataAdapter(
data,
latent_key=latent_key,
gene_id_column=gene_id_column,
normalize_embeddings=normalize_embeddings,
centroid_outlier_quantile=centroid_outlier_quantile,
centroid_min_points=centroid_min_points,
dataset_name=dataset_name,
dataset_id=dataset_id,
vector_field_default=vector_field_default,
)
try:
if not quiet:
print_success("File opened")
# Step 3: Analyze dataset
if not quiet:
print_step(3, 4, "Analyzing dataset")
self._print_dataset_info()
print_success("Analysis complete")
from . import __version__
self.server_info = {
"version": __version__,
"type": "anndata",
"format": self.data_format,
"host": self.host,
"port": self.port,
"n_cells": self.adapter.n_cells,
"n_genes": self.adapter.n_genes,
"is_backed": self.adapter.is_backed,
}
(
self._identity,
self._metadata_bodies,
self._payload_lengths,
) = self._build_http_contract()
except BaseException:
self._closed = True
try:
self.adapter.close()
except BaseException:
logger.exception(
"Failed to close the AnnData adapter after server construction failed"
)
raise
def _build_http_contract(self) -> tuple[dict, dict[str, bytes], dict[str, int]]:
"""Build and cross-check every public direct-AnnData route once."""
identity = self.adapter.get_dataset_identity()
obs_manifest = self.adapter.get_obs_manifest()
var_manifest = self.adapter.get_var_manifest()
connectivity_manifest = self.adapter.get_connectivity_manifest()
has_connectivity = identity["stats"]["has_connectivity"]
n_edges = identity["stats"]["n_edges"]
if has_connectivity:
if connectivity_manifest is None:
raise ValueError("Identity advertises connectivity without a connectivity manifest")
expected_connectivity_keys = {
"format",
"n_cells",
"n_edges",
"max_neighbors",
"index_bytes",
"index_dtype",
"sourcesPath",
"destinationsPath",
"weightsPath",
"weight_dtype",
"weight_bytes",
"compression",
}
if set(connectivity_manifest) != expected_connectivity_keys:
raise ValueError("Connectivity manifest does not contain the exact current fields")
if n_edges != connectivity_manifest["n_edges"]:
raise ValueError("Identity and connectivity manifest disagree on the edge count")
expected_connectivity_manifest = build_connectivity_manifest(
n_cells=identity["stats"]["n_cells"],
n_edges=connectivity_manifest["n_edges"],
max_neighbors=connectivity_manifest["max_neighbors"],
index_bytes=connectivity_manifest["index_bytes"],
index_dtype=connectivity_manifest["index_dtype"],
compression=None,
)
if (
list(connectivity_manifest) != list(expected_connectivity_manifest)
or connectivity_manifest != expected_connectivity_manifest
):
raise ValueError(
"Connectivity manifest does not match the direct weighted edge contract"
)
elif connectivity_manifest is not None or n_edges is not None:
raise ValueError(
"Connectivity manifest or edge count exists while connectivity is absent"
)
metadata = {
"dataset_identity.json": identity,
"obs_manifest.json": obs_manifest,
"var_manifest.json": var_manifest,
"_cellucid/datasets": {
"datasets": [
{
"id": identity["id"],
"path": "/",
"name": identity["name"],
}
]
},
}
if connectivity_manifest is not None:
metadata["connectivity_manifest.json"] = connectivity_manifest
metadata_bodies = {
path: json.dumps(value).encode("utf-8") for path, value in metadata.items()
}
n_cells = identity["stats"]["n_cells"]
payload_lengths: dict[str, int] = {}
def add_payload(path: str, content_length: int) -> None:
if not isinstance(path, str) or not path or path.startswith("/"):
raise ValueError(f"Invalid direct-AnnData payload path: {path!r}")
if path in payload_lengths:
raise ValueError(f"Duplicate direct-AnnData payload path: {path!r}")
if type(content_length) is not int or content_length < 0:
raise ValueError(f"Invalid payload length for {path!r}: {content_length!r}")
payload_lengths[path] = content_length
embeddings = identity["embeddings"]
for dim in embeddings["available_dimensions"]:
add_payload(embeddings["files"][f"{dim}d"], n_cells * dim * 4)
vector_fields = identity.get("vector_fields")
if vector_fields is not None:
for field in vector_fields["fields"].values():
for dim in field["available_dimensions"]:
add_payload(field["files"][f"{dim}d"], n_cells * dim * 4)
obs_schemas = obs_manifest["_obsSchemas"]
continuous_schema = obs_schemas.get("continuous")
if continuous_schema is not None:
for field in obs_manifest["_continuousFields"]:
key = self.adapter.get_obs_payload_component(field[0])
add_payload(
continuous_schema["pathPattern"].format(key=key),
n_cells * 4,
)
categorical_schema = obs_schemas.get("categorical")
if categorical_schema is not None:
for field in obs_manifest["_categoricalFields"]:
key = self.adapter.get_obs_payload_component(field[0])
ext = {"uint8": "u8", "uint16": "u16"}[field[2]]
item_size = {"uint8": 1, "uint16": 2}[field[2]]
add_payload(
categorical_schema["codesPathPattern"].format(
key=key,
ext=ext,
),
n_cells * item_size,
)
outlier_pattern = categorical_schema["outlierPathPattern"]
if outlier_pattern is not None:
add_payload(outlier_pattern.format(key=key), n_cells * 4)
var_schema = var_manifest["_varSchema"]
for field in var_manifest["fields"]:
key = self.adapter.get_gene_payload_component(field[0])
add_payload(var_schema["pathPattern"].format(key=key), n_cells * 4)
if connectivity_manifest is not None:
edge_bytes = connectivity_manifest["n_edges"] * connectivity_manifest["index_bytes"]
add_payload(connectivity_manifest["sourcesPath"], edge_bytes)
add_payload(connectivity_manifest["destinationsPath"], edge_bytes)
weight_bytes = connectivity_manifest["n_edges"] * connectivity_manifest["weight_bytes"]
add_payload(connectivity_manifest["weightsPath"], weight_bytes)
return identity, metadata_bodies, payload_lengths
def _print_dataset_info(self):
"""Print information about the loaded dataset."""
print_detail("Cells", f"{self.adapter.n_cells:,}")
print_detail("Genes", f"{self.adapter.n_genes:,}")
# Get embedding info
dims = self.adapter._available_dimensions
dim_str = ", ".join(f"{d}D" for d in sorted(dims)) if dims else "none"
print_detail("Embeddings", dim_str)
# Count obs field types
obs_keys = self.adapter.get_obs_keys()
n_categorical = sum(1 for k in obs_keys if self.adapter.get_obs_field_kind(k) == "category")
n_continuous = sum(
1 for k in obs_keys if self.adapter.get_obs_field_kind(k) == "continuous"
)
print_detail("Obs fields", f"{n_categorical} categorical, {n_continuous} continuous")
# Check connectivity
has_conn = self.adapter.has_connectivity()
if has_conn:
manifest = self.adapter.get_connectivity_manifest()
if manifest is None:
raise RuntimeError("Validated connectivity is missing its manifest")
conn_str = f"yes ({manifest['n_edges']:,} edges)"
else:
conn_str = "no"
print_detail("Connectivity", conn_str)
@property
def url(self) -> str:
"""Get the URL of the currently running server."""
if not self._running or self._server is None:
raise RuntimeError("AnnDataServer URL is unavailable because the server is not running")
return f"http://{self.host}:{self.port}"
@property
def viewer_url(self) -> str:
"""Get the full URL to open the viewer."""
return f"{self.url}/?anndata=true"
[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(
"AnnDataServer is single-use and has been closed. Create a new server instance."
)
self._started = True
try:
# Step 4: Start server
if not self.quiet:
print_step(4, 4, "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(
AnnDataRequestHandler,
adapter=self.adapter,
server_info=self.server_info,
metadata_bodies=self._metadata_bodies,
payload_lengths=self._payload_lengths,
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("AnnDataServer 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(
"AnnData server cleanup also failed after its serving loop failed"
)
self._background_error = serving_error
if serving_error is not None:
logger.error(
"AnnData background server failed",
exc_info=(
type(serving_error),
serving_error,
serving_error.__traceback__,
),
)
def _finish_serving(self) -> None:
"""Close the socket and adapter after the serving loop has ended."""
failures: list[BaseException] = []
self._running = False
server = self._server
self._server = None
if server is not None:
try:
server.server_close()
except BaseException as error:
failures.append(error)
try:
self.adapter.close()
except BaseException as error:
failures.append(error)
self._closed = True
if failures:
details = "; ".join(f"{type(error).__name__}: {error}" for error in failures)
raise RuntimeError(f"AnnData server cleanup failed: {details}") from failures[0]
def _rollback_failed_start(self, *, shutdown: bool) -> None:
"""Rollback 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 AnnData server after startup failed")
if server is not None:
try:
server.server_close()
except BaseException:
logger.exception("Failed to close the AnnData 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
try:
self.adapter.close()
except BaseException:
logger.exception("Failed to close the AnnData adapter after startup failed")
self._closed = True
[docs]
def start_background(self):
"""Start the server in a background thread."""
self.start(blocking=False)
[docs]
def stop(self):
"""Stop the server and cleanup resources."""
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
try:
self.adapter.close()
except BaseException as error:
failures.append(error)
self._closed = True
if not self.quiet:
print("AnnData server stopped")
if failures:
details = "; ".join(f"{type(error).__name__}: {error}" for error in failures)
raise RuntimeError(f"AnnData 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 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_anndata(
data: str | Path | anndata.AnnData,
port: int = DEFAULT_PORT,
host: str = DEFAULT_HOST,
open_browser: bool = True,
quiet: bool = False,
*,
latent_key: str | None = None,
gene_id_column: str | None = None,
normalize_embeddings: bool = True,
centroid_outlier_quantile: float = 0.95,
centroid_min_points: int = 10,
dataset_name: str,
dataset_id: str,
vector_field_default: str | None = None,
serve_web_ui: bool = True,
web_source_url: str = CELLUCID_WEB_URL,
web_cache_dir: str | Path | None = None,
) -> AnnDataServer:
"""
Serve an AnnData object or h5ad file directly.
This is a convenience function for quickly visualizing AnnData.
For production use, consider using prepare instead.
Parameters
----------
data : str, Path, or AnnData
Path to h5ad file or AnnData object.
port : int
Port to serve on (default: 8765).
host : str
Host to bind to (default: 127.0.0.1).
open_browser : bool
Whether to open browser (default: True).
quiet : bool
Suppress info messages.
latent_key : str, optional
Explicit key in ``obsm`` for the latent space.
gene_id_column : str, optional
Exact column in ``var`` containing gene identifiers. If None,
identifiers come from ``var.index``.
normalize_embeddings : bool
Whether to normalize embeddings into the viewer coordinate range.
centroid_outlier_quantile : float
Quantile used when computing categorical centroids.
centroid_min_points : int
Minimum category size used for centroid computation.
dataset_name : str
Explicit human-readable dataset name.
dataset_id : str
Explicit stable dataset identifier.
vector_field_default : str, optional
Exact field id required when multiple UMAP vector fields exist.
serve_web_ui : bool
Establish and serve the exact current web build.
web_source_url : str
Origin publishing the web asset inventory.
web_cache_dir : str or Path, optional
Directory holding the active verified web build.
Returns
-------
AnnDataServer
The running server instance.
Example
-------
>>> from cellucid import serve_anndata
>>> serve_anndata(
... "/path/to/data.h5ad",
... dataset_name="Example",
... dataset_id="example",
... )
>>> # Or with in-memory AnnData
>>> import anndata as ad
>>> adata = ad.read_h5ad("data.h5ad")
>>> serve_anndata(
... adata,
... dataset_name="Example",
... dataset_id="example",
... )
"""
server = AnnDataServer(
data=data,
port=port,
host=host,
open_browser=open_browser,
quiet=quiet,
latent_key=latent_key,
gene_id_column=gene_id_column,
normalize_embeddings=normalize_embeddings,
centroid_outlier_quantile=centroid_outlier_quantile,
centroid_min_points=centroid_min_points,
dataset_name=dataset_name,
dataset_id=dataset_id,
vector_field_default=vector_field_default,
serve_web_ui=serve_web_ui,
web_source_url=web_source_url,
web_cache_dir=web_cache_dir,
)
server.start()
return server