Python backend for cBioPortal Cell Explorer. Converts single-cell RNA-seq data (h5ad) to Zarr v3 stores optimized for web-based visualization.
This is a uv workspace monorepo:
| Package | Description |
|---|---|
cell2zarr |
h5ad to Zarr conversion pipeline |
cell-explorer-api |
FastAPI API + static file serving |
uv syncTo convert single-cell RNA-seq data for use with Cell Explorer, see the cell2zarr documentation.
Quick start:
# Convert h5ad to Zarr
cell2zarr convert input.h5ad output.zarr --two-phase --encoding-config encoding.json
# Add a UMAP embedding to an existing store
cell2zarr add atlas.h5ad atlas.zarr --key obsm/X_umapuv run pytest packages/cell2zarr/tests/ -v# API only (no static serving)
uv run uvicorn cell_explorer_api.main:app --reload
# With frontend static serving
STATIC_DIR=/path/to/frontend/dist uv run uvicorn cell_explorer_api.main:app --reload
# Export OpenAPI spec
uv run python -m cell_explorer_api.export_openapi > openapi.jsonAll settings are environment variables, read case-insensitively into Settings
in packages/api/src/cell_explorer_api/config.py, which is the source of truth.
.env.example is a copy-paste template for local development;
DEPLOYMENT.md covers the operational detail behind several of these.
Most settings are optional, and several features switch themselves on only when their configuration is present — so an unset value usually means "off" rather than "broken".
| Variable | Default | Notes |
|---|---|---|
STATIC_DIR |
unset | Path to the built frontend. Unset means API-only, no static serving |
BRAND_DIR |
unset | Directory holding an operator-supplied brand bundle (brand.json plus assets). Unset means built-in cBioPortal branding. Mount read-only |
ENVIRONMENT |
development |
Reported on /api/info |
GIT_SHA |
auto-detected | Read from git at startup. Set explicitly in containers, where git is unavailable |
| Variable | Default | Notes |
|---|---|---|
GOOGLE_ANALYTICS_ID |
unset | GA4 measurement id, served to the frontend by /api/info. Unset means no analytics script is loaded |
| Variable | Default | Notes |
|---|---|---|
APP_DATA_DIR |
./data |
Holds the SQLite database and logs/ |
DATABASE_URL |
unset | Defaults to SQLite at $APP_DATA_DIR/cell_explorer.db |
LOG_LEVEL |
INFO |
|
LOG_ROTATION_INTERVAL |
daily |
daily, hourly or weekly |
LOG_BACKUP_COUNT |
30 |
Rotated files retained |
LOG_FILENAME |
cell-explorer.log |
Written under $APP_DATA_DIR/logs/ |
Auth is enabled only when an issuer, a client id and a client secret all
resolve. Any one missing leaves auth off and every dataset public — there is
no partial state and no error, so confirm /api/info reports
auth_enabled: true after configuring it.
AUTH_PROVIDER selects how the issuer is derived and which claims carry roles.
| Variable | Default | Notes |
|---|---|---|
AUTH_PROVIDER |
keycloak |
keycloak, entra or oidc |
OIDC_ISSUER |
unset | Required unless AUTH_PROVIDER=keycloak, which derives it from KEYCLOAK_URL + KEYCLOAK_REALM |
OIDC_CLIENT_ID |
unset | Falls back to KEYCLOAK_CLIENT_ID |
OIDC_CLIENT_SECRET |
unset | Falls back to KEYCLOAK_CLIENT_SECRET |
OIDC_SCOPES |
openid profile email |
entra appends offline_access automatically |
OIDC_AUDIENCE |
unset | Defaults to the resolved client id |
OIDC_ROLES_CLAIMS |
unset | Comma-separated dotted claim paths merged into the user's roles. Defaults per provider: Keycloak uses realm_access.roles and resource_access.<client>.roles, Entra uses roles |
The KEYCLOAK_* variables are the zero-config path for AUTH_PROVIDER=keycloak,
and remain supported aliases for the generic names above.
| Variable | Default | Notes |
|---|---|---|
KEYCLOAK_URL |
unset | Base URL; combined with the realm to derive the issuer |
KEYCLOAK_REALM |
unset | |
KEYCLOAK_CLIENT_ID |
unset | |
KEYCLOAK_CLIENT_SECRET |
unset | |
KEYCLOAK_IDP_HINT |
unset | Skips the provider chooser. Ignored unless AUTH_PROVIDER=keycloak |
| Variable | Default | Notes |
|---|---|---|
ACCESS_COOKIE_MAX_AGE |
300 |
Seconds. 5 minutes |
REFRESH_COOKIE_MAX_AGE |
86400 |
Seconds. 24 hours. Keep this at or below the realm's ssoSessionMaxLifespan, or refresh fails early and users see "Session expired" mid-session |
CORS_ORIGINS |
unset | Comma-separated origins. Unset means no CORS middleware is installed |
| Variable | Default | Notes |
|---|---|---|
ADMIN_API_KEY |
unset | Enables /api/admin/*. Unset means the admin endpoints reject every request |
ANTHROPIC_API_KEY |
unset | Unset means chat is disabled and /api/info reports chat_enabled: false |
CHAT_REQUIRED_ROLE |
unset | Role required for chat. Unset means any authenticated user, still subject to each dataset's own chat_enabled |
CLI_STATE_SECRET |
unset | Signs the CLI login callback state |
BRAND_DIR (see Configuration above) points at an operator-supplied
brand bundle: a brand.json plus the image assets it references. Setting it re-skins the
tab title, favicon, web app manifest, and the header identity surfaced through
/api/info (name, tagline, logo, colors) with the operator's own deployment identity.
It is co-branding, not white-labeling — the in-app wordmark beside the logo and the
cBioPortal footer attribution are fixed regardless of what BRAND_DIR contains; see
What isn't configurable below.
/opt/cell-explorer/brand/
├── brand.json
├── logo-light.svg
├── logo-dark.svg
├── favicon.ico
├── favicon.svg
├── icon-192.png
├── icon-512.png
└── apple-touch-icon.png
brand.json is the only filename that matters — it must be named exactly that. Every
other file in the directory is named whatever the operator likes; brand.json's logo
and favicon fields say which filename plays which role. Asset files must live directly
in BRAND_DIR — no subdirectories.
Every field is optional and falls back to its own default independently. A one-field
file such as {"name": "My Institute"} is valid — everything else keeps the built-in
cBioPortal defaults below.
| Field | Type | Max length | Default |
|---|---|---|---|
name |
string | 120 | "cBioPortal" |
shortName |
string | 40 (when set explicitly) | the resolved name |
title (browser tab title) |
string | 160 | "{name} Cell Explorer" |
tagline |
string | 200 | "Explore millions of cells in your browser." |
logoHref |
absolute http(s) URL |
— | none (logo is not a link) |
logo.onLight |
filename | 255 bytes | none |
logo.onDark |
filename | 255 bytes | none |
logo.alt |
string | 120 | the resolved name |
colors.ink |
#RRGGBB |
— | "#0d2c48" |
colors.inkDeep |
#RRGGBB |
— | none |
colors.themeColor |
#RRGGBB |
— | the operator's own ink, if supplied and valid — otherwise "#123a5e" |
favicon.ico |
filename | 255 bytes | none |
favicon.svg |
filename | 255 bytes | none |
favicon.png192 |
filename | 255 bytes | none |
favicon.png512 |
filename | 255 bytes | none |
favicon.appleTouch |
filename | 255 bytes | none |
colors.ink must be dark. It's the background of a light-on-dark identity band —
header text is rendered light on top of it — so a value whose WCAG relative luminance
exceeds 0.35 is rejected outright (the field falls back to the default rather than
shipping unreadable text). colors.inkDeep and colors.themeColor carry no such
constraint.
colors.themeColor's default is not the same as colors.ink's default: when no
colors are set at all, ink defaults to #0d2c48 but themeColor still defaults to
the separate constant #123a5e, because the fallback only reaches for the operator's
own ink value — never the built-in one. Set colors.ink alone and themeColor
follows it automatically; leave both unset and the two diverge.
colors.inkDeep has no default derivation today — an unset value stays unset and is
passed through as null. A frontend that derives a deeper shade from ink
automatically when inkDeep is absent is planned but not part of this backend.
shortName's 40-character cap applies only when it is set explicitly. Omit it and
it falls back to the resolved name, which has its own cap of 120 — so a 100-character
name yields a 100-character shortName. The fallback is deliberately not truncated:
cutting an operator's institution name mid-word is a worse outcome than a long one, and
an operator who wants a short form can supply it.
logo.onLight and logo.onDark are two separate assets, not one logo recolored by
CSS. Brand marks usually arrive as artwork with a fill already baked in (e.g. a white
knockout mark for a dark header, a full-color mark for a light background), so the
bundle carries both and the shell picks whichever fits the surface it's rendering on.
Asset filenames (logo.onLight, logo.onDark, and every favicon.* field) must be
a bare filename: no /, no .. anywhere in the string (not just as a ../ path
segment — my..logo.svg is rejected too), not empty or ., and at most 255 bytes
once UTF-8 encoded — bytes, not characters, matching the filesystem's own limit, so a
name with accented or non-Latin characters hits the cap sooner than its length suggests.
The extension must be one of .svg, .png, .ico, .jpg, .jpeg, .webp (case-insensitive). The file must
also actually exist in BRAND_DIR — a validated filename pointing at nothing is dropped
the same as an invalid one.
logoHref, if set, must be an absolute http:// or https:// URL with a host —
anything else (a relative path, a javascript: URL, a bare string) is rejected.
The five favicon fields split across two places. favicon.ico, favicon.svg, and
favicon.appleTouch populate <link> tags in the HTML <head>; favicon.png192 and
favicon.png512 populate the web app manifest's icons array instead, for PWA installs.
Setting only favicon.png512, for instance, changes nothing in <head> — that's
expected, not a bug.
Every field validates independently and fails soft: an invalid value — wrong type,
malformed hex, oversized text, a filename that fails the traversal/extension checks, an
asset that doesn't exist on disk, an ink that isn't dark enough — is dropped with a
warning in the container logs, and that one field falls back to its default. A bad
bundle can make the deployment look wrong; it can never stop the application from
starting.
Mount the directory read-only and point BRAND_DIR at the mount:
services:
cell-explorer:
volumes:
- /opt/cell-explorer/brand:/brand:ro
environment:
BRAND_DIR: /brandThe bundle is read once, at container startup — changing brand.json or an asset
requires restarting the container, not just replacing the file on disk.
On Kubernetes, the equivalent is a ConfigMap mount:
volumes:
- name: brand
configMap: { name: brand }
volumeMounts:
- name: brand
mountPath: /brand
readOnly: true
env:
- name: BRAND_DIR
value: /brandA ConfigMap base64-encodes binary assets and caps out around 1 MB, so a full favicon
set plus multiple logo variants can approach the limit. Larger bundles need a PVC or a
derived image (FROM cell-explorer / COPY brand/ /brand) instead.
Brand assets rarely arrive web-ready. A few conversions come up repeatedly:
- EPS → SVG:
inkscape in.eps --export-type=svg. Text in EPS source is typically outlined already, so the converted SVG has no font dependency. - Favicons need a finished set, not a single SVG. Safari and Windows still want an
.ico; PWA installs want 192×192 and 512×512 PNGs. Ship all offavicon.ico,favicon.svg,favicon.png192, andfavicon.png512rather than relying on the browser to synthesize the rest from one file. - Print-derived colors need remapping. Brand colors supplied as CMYK or spot values
render differently once naively converted to RGB than the brand's own on-screen
master. Check hex values against the brand's digital style guide, not the print one,
before dropping them into
brand.json.
The cBioPortal footer attribution is fixed and deliberately not a brand.json field —
there is no way to move, restyle, or suppress it. The in-app wordmark next to the logo
is likewise fixed. This is co-branding: the operator's identity is primary in the
header, and cBioPortal remains visibly present as the underlying platform.
The browser tab title is not in that fixed set — it's exactly the title field
above, operator-controlled, defaulting to "<name> Cell Explorer" when unset. An
operator who sets {"title": "Acme Institute"} gets a tab that says exactly that, with
no "Cell Explorer" in it at all; that's the field working as designed, not a gap.
MIT