"""
What a person made in the Cellucid viewer, brought into an AnnData in one call.
The viewer sends every result it holds (``viewer-protocol.json``, kind
``result``): highlight groups, user-defined categorical fields (labels,
annotations, clusters), continuous fields and scores, gene sets, the filter's
visible cells and the cells it opened. :func:`pull` writes them onto
``obs`` -- the global ``obs`` of a MuData -- and the gene sets into
``uns['cellucid']['gene_sets']``, from a live viewer
(:class:`~cellucid.AnnDataViewer`) or from what the web app exports with no
notebook at all: obs-column CSV files, GMT files and ``.cellucid-session``
files.
Column names, one scheme (documented in the Python API reference, "Round
trip"):
- a highlight group: ``<highlight_prefix><group id>``
(``cellucid_highlight__highlight_3``), ``bool``;
- a categorical or continuous field, a score: ``<field_prefix><key>``;
- the visible cells (``visibility=True``): ``cellucid_visible``.
Every column a pull writes is recorded in ``uns['cellucid']['pulled']`` with
where it came from (``pulled_from``: ``"viewer"`` or the file's name) and the
result it holds (``kind``, ``id``). A later pull of the same result writes the
same column again, whatever suffix it took; a viewer pull removes a column
only when the viewer no longer holds its result (``prune``); a column another
source wrote, or Cellucid did not write, is a conflict (``on_conflict``). The
column rules -- dtype, missing values, a subsample's unopened cells as
``<NA>`` -- are :mod:`._columns`', shared with
:func:`~cellucid.apply_cellucid_session_to_anndata`.
"""
from __future__ import annotations
import copy
import os
import re
import warnings
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
from .._anndata_files import is_mudata
from .._plural import plural
from ._columns import categorical_column, continuous_column, membership_column, write_columns
from ._records import _ColumnPlan
#: The ``uns['cellucid']`` key recording what :func:`pull` wrote.
PULLED_KEY = "pulled"
#: The ``uns['cellucid']`` key holding the gene sets :func:`pull` brought.
GENE_SETS_KEY = "gene_sets"
#: The column of the filter's visible cells.
VISIBLE_COLUMN = "cellucid_visible"
#: The first column of an obs-column CSV: the cells' obs names.
OBS_NAME_COLUMN = "obs_name"
#: The first cell of an obs-column CSV's second row, which declares each
#: column's kind.
KIND_ROW = "kind"
#: What a column of an obs-column CSV holds (its kind row).
CSV_KINDS = ("highlight", "categorical", "continuous")
#: Where a pull read a result: the live viewer (a file's results carry the
#: file's name).
VIEWER_SOURCE = "viewer"
_CONFLICT_RULES = frozenset({"error", "replace", "suffix"})
[docs]
@dataclass(frozen=True)
class ViewerResult:
"""One result the viewer holds (the protocol's ``result``).
``cells`` are original positions (rows of ``obs``), ascending, or None for
every cell; ``values`` are a field's codes or numbers, one per cell named
(or per cell of the dataset), or None.
"""
kind: str
id: str
revision: int
n_obs: int
meta: dict[str, Any]
cells: np.ndarray | None
values: np.ndarray | None
#: The ``obs`` column it is written to, when it names one itself (a column
#: of an obs-column CSV); None follows the naming scheme.
column: str | None = None
#: Where it was read: ``"viewer"``, or the name of the file it came from.
source: str = VIEWER_SOURCE
#: A highlight's known cells, when the source says which (an obs-column
#: CSV lists them); None: the cells the viewer opened.
known: np.ndarray | None = None
[docs]
@classmethod
def from_event(cls, payload: Mapping[str, Any]) -> ViewerResult:
"""The result of one decoded ``result`` event payload."""
return cls(
kind=str(payload["kind"]),
id=str(payload["id"]),
revision=int(payload["revision"]),
n_obs=int(payload["nObs"]),
meta=copy.deepcopy(dict(payload["meta"])),
cells=payload["cells"],
values=payload["values"],
)
[docs]
@dataclass(frozen=True)
class Selection:
"""The cells of the viewer's last selection: original positions, their obs
names (when the viewer's dataset has them in this process), and the tool
that made it."""
cells: np.ndarray
obs_names: pd.Index | None
source: str
def __len__(self) -> int:
return int(self.cells.size)
[docs]
@dataclass(frozen=True)
class PullSummary:
"""What :func:`pull` wrote.
``columns`` maps each ``obs`` column written to what it holds (``kind``,
``id``, ``pulled_from`` and the result's label, key or page);
``removed`` lists the columns of an earlier viewer pull whose result the
viewer no longer holds; ``gene_sets`` maps each gene set brought to its
genes, and ``removed_gene_sets`` names the gene sets of an earlier viewer
pull the viewer no longer holds; ``skipped`` names results left out and
why.
"""
columns: dict[str, dict[str, Any]] = field(default_factory=dict)
removed: list[str] = field(default_factory=list)
gene_sets: dict[str, list[str]] = field(default_factory=dict)
removed_gene_sets: list[str] = field(default_factory=list)
skipped: dict[str, str] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# GMT
# ---------------------------------------------------------------------------
_GMT_BREAKS = re.compile(r"[\t\r\n]+")
def _clean_gmt_text(value: object) -> str:
"""Text a GMT cell can hold: each run of tabs and line breaks one space,
trimmed (the web app's ``formatGmt`` and cellucid-r's rule)."""
return _GMT_BREAKS.sub(" ", str(value)).strip()
def unique_gene_set_name(name: str, taken: set[str]) -> str:
"""``name``, or ``"name (2)"``, ``"name (3)"``... -- the first not in
``taken`` -- added to ``taken``. The one rule for a gene set name used
twice, in the web app, cellucid-python and cellucid-r alike."""
candidate = name
number = 2
while candidate in taken:
candidate = f"{name} ({number})"
number += 1
taken.add(candidate)
return candidate
[docs]
def write_gmt(
gene_sets: Mapping[str, Sequence[str]],
path: str | os.PathLike[str],
*,
descriptions: Mapping[str, str] | None = None,
) -> Path:
"""Write gene sets as a GMT file (MSigDB): one set per line,
``name<TAB>description<TAB>gene...``.
Parameters
----------
gene_sets
Each set's name and its genes, in order.
path
The file to write.
descriptions
A description per set name; empty when not given.
Returns
-------
Path
The file written. Each run of tabs and line breaks in a name,
description or gene is written as one space; a name that then reads
as an earlier one's is written ``"name (2)"`` (as :func:`read_gmt`
names a repeated name); each gene once. The Cellucid web app reads it
(Gene signature and Dot plot: Import), as do :func:`read_gmt` and
cellucid-r's ``cellucid_read_gmt()``.
"""
if not isinstance(gene_sets, Mapping):
raise TypeError("gene_sets must map each set name to its genes")
descriptions = {} if descriptions is None else dict(descriptions)
lines: list[str] = []
taken: set[str] = set()
for name, genes in gene_sets.items():
clean_name = _clean_gmt_text(name)
if not clean_name:
raise ValueError("A gene set needs a name")
if isinstance(genes, str):
raise TypeError(f"Gene set {name!r} must list its genes, not one string")
clean_genes = dict.fromkeys(gene for gene in (_clean_gmt_text(gene) for gene in genes) if gene)
lines.append("\t".join([
unique_gene_set_name(clean_name, taken), _clean_gmt_text(descriptions.get(name, "")), *clean_genes,
]))
target = Path(path).expanduser()
target.write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
return target
[docs]
def read_gmt(path: str | os.PathLike[str]) -> dict[str, list[str]]:
"""Read a GMT file into ``{set name: [genes]}``, in file order.
One rule with the web app and cellucid-r: cells are tab-separated and
trimmed; blank lines are skipped; a line without a name is ``"Set <n>"``
(its place among the sets); a name used again is ``"name (2)"``,
``"name (3)"``...; each gene is kept once.
"""
sets: dict[str, list[str]] = {}
taken: set[str] = set()
for line in Path(path).expanduser().read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
cells = line.split("\t")
name = unique_gene_set_name(cells[0].strip() or f"Set {len(sets) + 1}", taken)
sets[name] = list(dict.fromkeys(gene.strip() for gene in cells[2:] if gene.strip()))
return sets
# ---------------------------------------------------------------------------
# Results -> columns
# ---------------------------------------------------------------------------
def _uns_record(target: Any) -> dict[str, Any]:
uns = target.uns
cellucid = uns.get("cellucid")
if cellucid is None:
return {}
if not isinstance(cellucid, Mapping):
raise TypeError("uns['cellucid'] must be a dictionary if present")
return dict(cellucid)
def _require_conflict_rule(value: object) -> str:
if value not in _CONFLICT_RULES:
raise ValueError(f"on_conflict must be one of {sorted(_CONFLICT_RULES)}, not {value!r}")
return str(value)
#: Which result a column holds: where it was pulled from, its kind and id.
_ResultKey = tuple[str, str, str]
def _key_of(info: Mapping[str, Any]) -> _ResultKey | None:
"""The result a ``pulled`` record names, or None for a record that names
none (it is then never replaced or removed by a pull)."""
pulled_from, kind, result_id = info.get("pulled_from"), info.get("kind"), info.get("id")
if not all(isinstance(value, str) for value in (pulled_from, kind, result_id)):
return None
return (str(pulled_from), str(kind), str(result_id))
@dataclass
class _Naming:
"""Column names, unique among the pull's own.
A result an earlier pull wrote takes the column it wrote then (``ours``,
by result) -- its own name, or that name with the suffix it took (unless
``on_conflict="replace"`` asks for the name itself) -- so a pull run
again writes the same column. Otherwise its name is used when no
column has it, or when a pull of the same source wrote it; any other
existing column is a conflict, settled by ``on_conflict``.
"""
on_conflict: str
existing: set[str]
ours: dict[_ResultKey, str]
written_by: dict[str, str]
used: set[str] = field(default_factory=set)
conflicts: list[str] = field(default_factory=list)
def _suffixed(self, wanted: str) -> str:
number = 2
while f"{wanted}_{number}" in self.existing or f"{wanted}_{number}" in self.used:
number += 1
return f"{wanted}_{number}"
def name(self, wanted: str, key: _ResultKey) -> str:
earlier = self.ours.get(key)
if earlier is not None and earlier not in self.used and (
earlier == wanted
or (self.on_conflict != "replace" and re.fullmatch(rf"{re.escape(wanted)}_[0-9]+", earlier) is not None)
):
name = earlier
elif wanted in self.used:
name = self._suffixed(wanted)
elif wanted not in self.existing or self.written_by.get(wanted) == key[0] or self.on_conflict == "replace":
name = wanted
elif self.on_conflict == "suffix":
name = self._suffixed(wanted)
else:
self.conflicts.append(wanted)
name = wanted
self.used.add(name)
return name
def _open_cells(results: Iterable[ViewerResult]) -> np.ndarray | None:
for result in results:
if result.kind == "subset":
return result.cells
return None
@dataclass(frozen=True)
class _Candidate:
"""One column a pull would write: the result it holds, the name it wants."""
key: _ResultKey
wanted: str
values: pd.Series
info: dict[str, Any]
def _result_candidates(
target: Any,
results: Sequence[ViewerResult],
*,
highlights: bool,
fields: bool,
scores: bool,
visibility: bool,
include_pushed: bool,
highlight_prefix: str,
field_prefix: str,
) -> tuple[list[_Candidate], dict[str, str]]:
"""The columns of ``results`` on ``target`` before naming, and the results
skipped (with why)."""
n_obs = int(target.n_obs)
index = target.obs_names
for result in results:
if result.n_obs != n_obs:
raise ValueError(
f"The viewer shows a dataset of {result.n_obs} {plural(result.n_obs, 'cell')} and this object has {n_obs}: "
"pull into the object the viewer was opened on, with its cells in the same order."
)
open_cells = _open_cells(results)
candidates: list[_Candidate] = []
skipped: dict[str, str] = {}
for result in sorted(results, key=lambda item: (item.kind, item.revision)):
key = (result.source, result.kind, result.id)
meta = result.meta
provenance = {"kind": result.kind, "id": result.id, "pulled_from": result.source}
if result.kind == "highlight":
if not highlights:
continue
wanted = result.column or f"{highlight_prefix}{result.id}"
values = membership_column(
n_obs, result.cells, open_cells=open_cells if result.known is None else result.known, index=index
)
info = {**provenance, "label": meta["label"], "tool": meta["tool"], "page": meta["pageName"],
"enabled": meta["enabled"]}
elif result.kind in {"categorical", "continuous"}:
origin = meta["origin"]
if origin == "notebook" and not include_pushed:
skipped[f"{result.kind}:{result.id}"] = "pushed from a notebook (include_pushed=True brings it back)"
continue
if origin == "analysis" and not scores:
continue
if origin != "analysis" and not fields:
continue
wanted = result.column or f"{field_prefix}{meta['key']}"
assert result.values is not None
if result.kind == "categorical":
values = categorical_column(
result.values,
meta["categories"],
cells=result.cells,
n_obs=n_obs,
missing_code=int(np.iinfo(result.values.dtype).max),
index=index,
)
else:
values = continuous_column(result.values, cells=result.cells, n_obs=n_obs, index=index)
info = {**provenance, "key": meta["key"], "origin": origin}
elif result.kind == "visibility":
if not visibility:
continue
wanted = VISIBLE_COLUMN
values = membership_column(n_obs, result.cells, open_cells=open_cells, index=index)
info = provenance
else:
continue
candidates.append(_Candidate(key=key, wanted=wanted, values=values, info=info))
return candidates, skipped
def _name_candidates(
target: Any, candidates: Sequence[_Candidate], *, on_conflict: str
) -> tuple[list[_ColumnPlan], dict[str, Any]]:
"""The plans of ``candidates``, named (see :class:`_Naming`), and the
``pulled`` record they are named against."""
record = _uns_record(target)
previous = record.get(PULLED_KEY) or {}
if not isinstance(previous, Mapping):
raise TypeError("uns['cellucid']['pulled'] must be a dictionary")
columns = set(map(str, target.obs.columns))
ours: dict[_ResultKey, str] = {}
written_by: dict[str, str] = {}
for column, info in previous.items():
key = _key_of(info) if isinstance(info, Mapping) else None
if key is None or column not in columns:
continue
ours[key] = str(column)
written_by[str(column)] = key[0]
naming = _Naming(on_conflict=on_conflict, existing=columns, ours=ours, written_by=written_by)
plans = [
_ColumnPlan(name=naming.name(candidate.wanted, candidate.key), values=candidate.values, metadata=candidate.info)
for candidate in candidates
]
if naming.conflicts:
raise ValueError(
"These obs columns exist already and no pull from the same source wrote them: "
+ ", ".join(repr(name) for name in naming.conflicts)
+ ". Pass on_conflict='replace' to overwrite them, on_conflict='suffix' to write "
"beside them, or another highlight_prefix/field_prefix."
)
return plans, dict(previous)
def _pruned(
target: Any, previous: Mapping[str, Any], plans: Sequence[_ColumnPlan], held: set[_ResultKey]
) -> list[str]:
"""The columns of an earlier viewer pull to remove: those whose result the
viewer no longer holds, and those of a result this pull wrote under
another name. A column a file pull wrote is never removed."""
written = {plan.name for plan in plans}
written_keys = {_key_of(plan.metadata) for plan in plans}
remove = []
for column, info in previous.items():
key = _key_of(info) if isinstance(info, Mapping) else None
if key is None or key[0] != VIEWER_SOURCE or column in written or column not in target.obs.columns:
continue
if key not in held or key in written_keys:
remove.append(str(column))
return sorted(remove)
def _gene_sets_of(results: Sequence[ViewerResult]) -> list[tuple[_ResultKey, str, dict[str, Any]]]:
"""Each gene set: the result it is, a name unique among them
(:func:`unique_gene_set_name`), and its entry: its genes by identifier
(the var index: ``adata.var_names``), and, from a viewer whose data
mapping names the genes by a var column, the name it shows for each
(``symbols``)."""
sets = []
taken: set[str] = set()
for result in results:
if result.kind != "geneSet":
continue
meta = result.meta
sets.append((
(result.source, result.kind, result.id),
unique_gene_set_name(meta["name"], taken),
{"genes": list(meta["genes"]), "description": meta["description"], "origin": meta["origin"],
**({"symbols": list(meta["symbols"])} if meta.get("symbols") is not None else {})},
))
return sets
def _merge_gene_sets(
existing: Mapping[str, Any],
brought: Sequence[tuple[_ResultKey, str, dict[str, Any]]],
*,
held: set[_ResultKey] | None,
) -> tuple[dict[str, Any], list[str], dict[str, list[str]]]:
"""``uns['cellucid']['gene_sets']`` with ``brought``: a set brought again
replaces its earlier entry; with ``held`` (a viewer pull that prunes), a
set of an earlier viewer pull the viewer no longer holds goes. Names are
unique (:func:`unique_gene_set_name`). Returns the sets, the names
removed, and each set brought by its name."""
brought_keys = {key for key, _, _ in brought}
merged: dict[str, Any] = {}
removed: list[str] = []
for name, entry in existing.items():
key = _key_of({**entry, "kind": "geneSet"}) if isinstance(entry, Mapping) else None
if key is not None and key in brought_keys:
continue
if key is not None and held is not None and key[0] == VIEWER_SOURCE and key not in held:
removed.append(str(name))
continue
merged[str(name)] = entry
taken = set(merged)
names: dict[str, list[str]] = {}
for key, name, entry in brought:
final = unique_gene_set_name(name, taken)
merged[final] = {**entry, "pulled_from": key[0], "id": key[2]}
names[final] = list(entry["genes"])
return merged, sorted(removed), names
def _write_pull(
target: Any,
plans: Sequence[_ColumnPlan],
previous: Mapping[str, Any],
remove: Sequence[str],
*,
gene_sets: Sequence[tuple[_ResultKey, str, dict[str, Any]]] | None,
held_gene_sets: set[_ResultKey] | None,
skipped: dict[str, str],
) -> PullSummary:
"""Write the named columns and the gene sets (None: leave them) onto
``target`` at once, and record what was written."""
write_columns(target, plans, remove=list(remove))
record = _uns_record(target)
pulled = {
str(name): info for name, info in previous.items() if name not in remove and name in target.obs.columns
}
pulled.update({plan.name: copy.deepcopy(plan.metadata) for plan in plans})
record[PULLED_KEY] = pulled
removed_sets: list[str] = []
brought: dict[str, list[str]] = {}
if gene_sets is not None:
record[GENE_SETS_KEY], removed_sets, brought = _merge_gene_sets(
dict(record.get(GENE_SETS_KEY) or {}), gene_sets, held=held_gene_sets
)
target.uns["cellucid"] = record
return PullSummary(
columns={plan.name: copy.deepcopy(plan.metadata) for plan in plans},
removed=list(remove),
gene_sets=brought,
removed_gene_sets=removed_sets,
skipped=skipped,
)
def _require_pull_options(target: Any, flags: Mapping[str, object], prefixes: Mapping[str, object], on_conflict: object) -> str:
for label, flag in flags.items():
if type(flag) is not bool:
raise TypeError(f"{label} must be exactly True or False")
for label, prefix in prefixes.items():
if not isinstance(prefix, str):
raise TypeError(f"{label} must be a string")
rule = _require_conflict_rule(on_conflict)
if not hasattr(target, "obs") or not hasattr(target, "uns") or not hasattr(target, "n_obs"):
raise TypeError("pull writes into an AnnData or a MuData")
if getattr(target, "isbacked", False) and not is_mudata(target):
raise ValueError("A backed AnnData cannot take new obs columns; load it with adata.to_memory() first")
return rule
def apply_results(
target: Any,
results: Sequence[ViewerResult],
*,
highlights: bool = True,
fields: bool = True,
scores: bool = True,
visibility: bool = False,
gene_sets: bool = True,
include_pushed: bool = False,
highlight_prefix: str = "cellucid_highlight__",
field_prefix: str = "",
on_conflict: str = "error",
prune: bool = True,
) -> PullSummary:
"""Write a viewer's ``results`` onto ``target`` in place (see :func:`pull`)."""
on_conflict = _require_pull_options(
target,
{"highlights": highlights, "fields": fields, "scores": scores, "visibility": visibility,
"gene_sets": gene_sets, "include_pushed": include_pushed, "prune": prune},
{"highlight_prefix": highlight_prefix, "field_prefix": field_prefix},
on_conflict,
)
candidates, skipped = _result_candidates(
target, results, highlights=highlights, fields=fields, scores=scores, visibility=visibility,
include_pushed=include_pushed, highlight_prefix=highlight_prefix, field_prefix=field_prefix,
)
plans, previous = _name_candidates(target, candidates, on_conflict=on_conflict)
held = {(result.source, result.kind, result.id) for result in results}
remove = _pruned(target, previous, plans, held) if prune else []
return _write_pull(
target, plans, previous, remove,
gene_sets=_gene_sets_of(results) if gene_sets else None,
held_gene_sets=held if prune else None,
skipped=skipped,
)
# ---------------------------------------------------------------------------
# The web app's exports
# ---------------------------------------------------------------------------
def _csv_column_result(
name: str,
kind: str,
column: pd.Series,
positions: np.ndarray,
n_obs: int,
*,
source: str,
revision: int,
) -> ViewerResult:
"""One CSV column as the result its kind row declares: ``highlight``
(``true``/``false``; empty is unknown), ``categorical`` (labels; empty is
no category) or ``continuous`` (numbers; empty is no value). A cell the
file does not list is unknown, or has no value."""
text = column.astype(str).str.strip()
present = (text != "").to_numpy()
order = np.argsort(positions, kind="stable")
if kind == "highlight":
lowered = text.str.lower()
unreadable = present & ~lowered.isin(["true", "false"]).to_numpy()
if unreadable.any():
raise ValueError(
f"{source}: the highlight column {name!r} holds {text.iloc[int(np.flatnonzero(unreadable)[0])]!r}; "
"a highlight column holds true, false or nothing"
)
members = positions[present & (lowered == "true").to_numpy()]
return ViewerResult(
kind="highlight", id=name, revision=revision, n_obs=n_obs,
meta={"label": name, "tool": "annotation", "pageId": "csv", "pageName": source, "color": "#000000",
"enabled": True},
cells=np.sort(members).astype(np.uint32), values=None, column=name, source=source,
known=np.sort(positions[present]).astype(np.int64),
)
if kind == "continuous":
numbers = pd.to_numeric(text.where(present, None), errors="coerce").to_numpy(dtype=np.float64)
unreadable = present & np.isnan(numbers)
if unreadable.any() or np.isinf(numbers).any():
bad = int(np.flatnonzero(unreadable | np.isinf(numbers))[0])
raise ValueError(
f"{source}: the continuous column {name!r} holds {text.iloc[bad]!r}; "
"a continuous column holds finite numbers or nothing"
)
return ViewerResult(
kind="continuous", id=name, revision=revision, n_obs=n_obs, meta={"key": name, "origin": "user"},
cells=positions[order].astype(np.uint32), values=numbers[order], column=name, source=source,
)
categories = list(dict.fromkeys(text[present].tolist()))
lookup = {category: code for code, category in enumerate(categories)}
dtype = _code_dtype(len(categories))
missing = np.iinfo(dtype).max
codes = np.array([lookup[value] if keep else missing for value, keep in zip(text, present, strict=True)],
dtype=dtype)
return ViewerResult(
kind="categorical", id=name, revision=revision, n_obs=n_obs,
meta={"key": name, "categories": categories, "origin": "user"},
cells=positions[order].astype(np.uint32), values=codes[order], column=name, source=source,
)
def read_obs_csv(path: str | os.PathLike[str], obs_names: pd.Index) -> list[ViewerResult]:
"""Read an obs-column CSV the web app exports: a header row (``obs_name``,
then each column's name), a kind row (``kind``, then each column's kind:
``highlight``, ``categorical`` or ``continuous``), then a row per cell.
A cell the file does not list is unknown; a name this object does not
have is refused."""
path = Path(path).expanduser()
with path.open(encoding="utf-8", newline="") as handle:
frame = pd.read_csv(handle, dtype=str, keep_default_na=False, header=None)
if frame.shape[0] < 2 or frame.shape[1] < 1 or frame.iloc[0, 0] != OBS_NAME_COLUMN or frame.iloc[1, 0] != KIND_ROW:
raise ValueError(
f"{path.name}: an obs-column CSV starts with a header row whose first cell is {OBS_NAME_COLUMN!r} "
f"and a kind row whose first cell is {KIND_ROW!r} (then each column's kind: {', '.join(CSV_KINDS)})"
)
header = [str(value) for value in frame.iloc[0]]
kinds = [str(value) for value in frame.iloc[1]]
for name, kind in zip(header[1:], kinds[1:], strict=True):
if kind not in CSV_KINDS:
raise ValueError(f"{path.name}: the column {name!r} is of kind {kind!r}, not one of {', '.join(CSV_KINDS)}")
if len(set(header)) != len(header):
raise ValueError(f"{path.name}: a column is named twice")
rows = frame.iloc[2:].reset_index(drop=True)
names = rows[0]
if names.duplicated().any():
raise ValueError(f"{path.name}: the cell {names[names.duplicated()].iloc[0]!r} is listed twice")
if not obs_names.is_unique:
raise ValueError("This object's obs names are not unique; make them unique (obs_names_make_unique()) first")
positions = obs_names.get_indexer(names)
if (positions < 0).any():
unknown = names[positions < 0]
raise ValueError(
f"{path.name}: {len(unknown)} of its cells are not in this object (first: {unknown.iloc[0]!r})"
)
return [
_csv_column_result(
header[number], kinds[number], rows[number], positions.astype(np.int64), len(obs_names),
source=path.name, revision=number,
)
for number in range(1, len(header))
]
def results_of_files(paths: Sequence[Path], target: Any) -> tuple[list[ViewerResult], list[Path]]:
"""The results of the web app's exports among ``paths`` (CSV and GMT), and
the session files among them, which are applied as sessions."""
results: list[ViewerResult] = []
sessions: list[Path] = []
revision = 1
for path in paths:
suffix = path.name.lower()
if suffix.endswith(".csv"):
for result in read_obs_csv(path, target.obs_names):
results.append(result)
elif suffix.endswith(".gmt"):
for name, genes in read_gmt(path).items():
results.append(ViewerResult(
kind="geneSet", id=name, revision=revision, n_obs=int(target.n_obs),
meta={"name": name, "description": "", "genes": genes, "origin": "user"},
cells=np.empty(0, dtype=np.uint32), values=None, source=path.name,
))
revision += 1
elif suffix.endswith(".cellucid-session"):
sessions.append(path)
else:
raise ValueError(
f"{path.name}: pull reads the web app's exports -- .csv (obs columns), .gmt (gene sets) "
"and .cellucid-session files"
)
return results, sessions
def export_paths(source: str | os.PathLike[str] | Sequence[str | os.PathLike[str]]) -> list[Path]:
"""The files of ``source``: one file, a folder's exports, or a list of them."""
items = [source] if isinstance(source, str | os.PathLike) else list(source)
paths: list[Path] = []
for item in items:
path = Path(item).expanduser()
if path.is_dir():
paths.extend(
sorted(
child for child in path.iterdir()
if child.is_file() and child.name.lower().endswith((".csv", ".gmt", ".cellucid-session"))
)
)
elif path.is_file():
paths.append(path)
else:
raise FileNotFoundError(f"{path} does not exist")
if not paths:
raise ValueError(f"{source} holds no .csv, .gmt or .cellucid-session file to pull")
return paths
# ---------------------------------------------------------------------------
# The one call
# ---------------------------------------------------------------------------
[docs]
def pull(
adata: Any,
source: Any,
*,
highlights: bool = True,
fields: bool = True,
scores: bool = True,
visibility: bool = False,
gene_sets: bool = True,
include_pushed: bool = False,
highlight_prefix: str = "cellucid_highlight__",
field_prefix: str = "",
on_conflict: str = "error",
prune: bool = True,
) -> PullSummary:
"""Write what was made in Cellucid onto ``adata`` in one call, in place.
Every highlight group (a ``bool`` column each), every user-defined
categorical field -- labels, annotations, clusters -- (``category``),
every continuous field and score (``float32``) go to ``adata.obs`` (a
MuData's global ``obs``), and every gene set to
``adata.uns['cellucid']['gene_sets']``: its genes by identifier -- the
var index, ``adata.var_names`` -- and, from a viewer whose data mapping
names the genes by a var column (such as ``feature_name``), the names it
shows for them (``symbols``).
Parameters
----------
adata : anndata.AnnData or mudata.MuData
The object the viewer shows, with its cells in the same order. From
a viewer, an object holding the viewer's cells in another order is
refused (nothing is written), as is one read from a Cellucid-ready
file in its stored order (``obs['cellucid_original_position']``;
:func:`cellucid.restore_original_order` puts it back).
source : AnnDataViewer, path, or list of paths
A viewer (a notebook widget or a browser-tab viewer): what it holds,
as it last sent it -- every result as it was made, and all of them
whenever it opened its dataset, so a cell run after the work pulls it
all, with no waiting -- or what the web app exports with no
notebook: obs-column ``.csv`` files (a header row, ``obs_name``
then each column's name; a kind row, ``kind`` then each column's
kind, ``highlight``, ``categorical`` or ``continuous``; a row per
cell; each column written as named), ``.gmt`` gene sets and
``.cellucid-session`` files (the state the session shows, its active
tracker's head: its highlight groups, user-defined fields and gene
sets, checked against ``adata`` as an apply checks it;
:func:`~cellucid.apply_cellucid_session_to_anndata` applies another
step, and the session's renames) -- one file, a list, or a folder of
them.
highlights, fields, scores, visibility, gene_sets : bool
Which results to write: highlight groups; user-defined fields; scores
an analysis made (a gene signature's); the filter's visible cells
(``cellucid_visible``, off by default); gene sets.
include_pushed : bool
Also write the fields this notebook pushed (``viewer.push``), which
``adata`` holds already.
highlight_prefix, field_prefix : str
Each highlight's column is ``<highlight_prefix><group id>``; each
field's and score's ``<field_prefix><key>``.
on_conflict : {"error", "replace", "suffix"}
What a column name that ``adata.obs`` has already does, when no pull
from the same source (the viewer, or the same file) wrote it: raise
(nothing is written), overwrite it, or write ``<name>_2`` beside it.
A result an earlier pull wrote is written to the same column again,
suffix and all.
prune : bool
For a viewer: remove the columns, and the gene sets, an earlier
viewer pull wrote whose result the viewer no longer holds (a
highlight deleted since). A result left out of this pull
(``highlights=False``, a pushed field) keeps its column, and files
never remove anything.
Returns
-------
PullSummary
The columns written (``columns``), removed (``removed``), the gene
sets brought (``gene_sets``) and removed (``removed_gene_sets``),
and the results left out (``skipped``).
Notes
-----
When the viewer opened a subsample, a cell it did not open is ``<NA>`` in
a highlight column and missing in a field's: unknown, not "not
selected"; a cell an obs-column CSV does not list is the same.
``adata.uns['cellucid']['pulled']`` records each column written, with
where it came from (``pulled_from``) and the result it holds (``kind``,
``id``); each gene set in ``uns['cellucid']['gene_sets']`` the same.
"""
from ..jupyter._viewer import AnnDataViewer
if isinstance(source, AnnDataViewer):
source._require_viewer_order(adata)
return apply_results(
adata,
source.results,
highlights=highlights,
fields=fields,
scores=scores,
visibility=visibility,
gene_sets=gene_sets,
include_pushed=include_pushed,
highlight_prefix=highlight_prefix,
field_prefix=field_prefix,
on_conflict=on_conflict,
prune=prune,
)
from ._apply import session_columns
on_conflict = _require_pull_options(
adata,
{"highlights": highlights, "fields": fields, "scores": scores, "visibility": visibility,
"gene_sets": gene_sets, "include_pushed": include_pushed, "prune": prune},
{"highlight_prefix": highlight_prefix, "field_prefix": field_prefix},
on_conflict,
)
paths = export_paths(source)
results, sessions = results_of_files(paths, adata)
candidates, skipped = _result_candidates(
adata, results, highlights=highlights, fields=fields, scores=scores, visibility=visibility,
include_pushed=include_pushed, highlight_prefix=highlight_prefix, field_prefix=field_prefix,
)
sets = _gene_sets_of(results)
for path in sessions:
# The state the session shows (its active tracker's head), checked
# as an apply checks it; its names are then settled with every other
# column of this pull (on_conflict, reuse). A pull writes results;
# renaming the object's own columns is apply_cellucid_session_to_anndata's.
columns, session_sets = session_columns(path, adata)
for column in columns:
kind = str(column.metadata["kind"])
if (kind == "highlight" and not highlights) or (kind != "highlight" and not fields):
continue
result_id = str(column.metadata.get("group_id") or column.metadata.get("field_id"))
wanted = f"{highlight_prefix}{result_id}" if kind == "highlight" else f"{field_prefix}{column.metadata['key']}"
info = {**column.metadata, "id": result_id, "pulled_from": path.name}
assert column.values is not None
candidates.append(_Candidate(key=(path.name, kind, result_id), wanted=wanted, values=column.values, info=info))
taken = {name for _, name, _ in sets}
for entry in session_sets:
sets.append((
(path.name, "geneSet", entry["name"]),
unique_gene_set_name(entry["name"], taken),
{"genes": list(entry["genes"]), "description": entry["description"], "origin": "notebook"},
))
plans, previous = _name_candidates(adata, candidates, on_conflict=on_conflict)
return _write_pull(
adata, plans, previous, [], gene_sets=sets if gene_sets else None, held_gene_sets=None, skipped=skipped
)
# ---------------------------------------------------------------------------
# The other way: a column and the dataset's obs names
# ---------------------------------------------------------------------------
def read_obs_names(data: Any) -> pd.Index | None:
"""The obs names of what a viewer shows, by original position (the order
the viewer names cells in): an AnnData or MuData object's, or those an
AnnData or MuData file holds (read alone, nothing else); None for a format
only the browser reads (``.loom``, 10x, MatrixMarket, R objects).
A file in priority order (``uns['cellucid']['priority']``, as
``prepare()`` writes it) stores its rows in another order: its names are
put back in original order, as the viewer reads it -- when the record
describes the file's cells (:func:`cellucid.prepare_data._priority.stored_order`,
the rule the viewer reads it by); a record that does not is reported and
the names are taken as stored. An object's names follow the same rule:
one read from such a file and still in its stored order is named in its
original order, as it is served (:mod:`cellucid._served_store`)."""
if hasattr(data, "obs_names"):
from ..prepare_data._priority import stored_order_of
names = pd.Index(data.obs_names)
positions = stored_order_of(data).positions if hasattr(data, "uns") and hasattr(data, "obs") else None
if positions is None:
return names
named = np.empty(len(names), dtype=object)
named[positions] = np.asarray(names, dtype=object)
return pd.Index(named, dtype=object)
from .._anndata_files import (
ANNDATA_KINDS,
MUDATA_KINDS,
_open_root,
_text,
classify_anndata_path,
)
store = classify_anndata_path(data)
if store.kind not in ANNDATA_KINDS | MUDATA_KINDS:
return None
from anndata.io import read_elem
from ..prepare_data._priority import ORIGINAL_POSITION_COLUMN, stored_order
with _open_root(store) as root:
obs = root["obs"]
names = np.asarray(read_elem(obs[_text(obs.attrs.get("_index", "_index"))]), dtype=object)
cellucid_uns = root["uns"]["cellucid"] if "uns" in root and "cellucid" in root["uns"] else None
record = read_elem(cellucid_uns["priority"]) if cellucid_uns is not None and "priority" in cellucid_uns else None
column = read_elem(obs[ORIGINAL_POSITION_COLUMN]) if ORIGINAL_POSITION_COLUMN in obs else None
order = stored_order(record, names.size, column)
if order.notice is not None:
warnings.warn(f"{data}: {order.notice}", UserWarning, stacklevel=2)
if order.positions is not None:
in_original_order = np.empty_like(names)
in_original_order[order.positions] = names
names = in_original_order
return pd.Index(names, dtype=object)
def _code_dtype(count: int) -> np.dtype:
"""The narrowest code type whose largest value is free for "no category"."""
for dtype in (np.uint8, np.uint16, np.uint32):
if count < np.iinfo(dtype).max:
return np.dtype(dtype)
raise ValueError(f"{count} {plural(count, 'category', 'categories')} are more than a field holds")
def field_push_params(values: Any, key: str, *, n_obs: int, obs_names: pd.Index | None) -> dict[str, Any]:
"""The parameters of the ``field.push`` action of one column, aligned to
the viewer's cells: ``key``, ``kind``, ``categories``, ``n_obs``,
``cells`` (original positions as ``uint32``, ascending, or None for every
cell in order) and ``values`` (codes, the code type's largest value for no
category, or ``float32`` with NaN for no value).
A Series is aligned by its index: the viewer's obs names (every cell, or
some, the rest without a value), or a ``RangeIndex`` of every cell in
order. An array or list is one value per cell, in order. Categorical,
string, object and bool values make a categorical (a bool's labels are
``"false"`` and ``"true"``, as cellucid-r pushes them); numbers a
continuous field.
"""
if isinstance(values, pd.Series):
series = values
elif isinstance(values, np.ndarray | list | tuple | pd.Index | pd.Categorical):
series = pd.Series(values)
if len(series) != n_obs:
raise ValueError(
f"The column has {len(series)} {plural(len(series), 'value')} and the viewer's dataset "
f"{n_obs} {plural(n_obs, 'cell')}"
)
else:
raise TypeError("push takes a pandas Series (e.g. adata.obs['leiden']), an array or a list")
cells: np.ndarray | None = None
index = series.index
if obs_names is not None and index.equals(obs_names):
pass
elif obs_names is not None and not isinstance(index, pd.RangeIndex):
if not obs_names.is_unique:
raise ValueError("The viewer's obs names are not unique: push an array in obs order instead")
positions = obs_names.get_indexer(index)
if (positions < 0).any():
raise ValueError(
f"{int((positions < 0).sum())} of the column's index names are not cells of the viewer's dataset "
f"(first: {index[positions < 0][0]!r})"
)
if pd.Index(positions).has_duplicates:
raise ValueError("The column names a cell twice")
order = np.argsort(positions, kind="stable")
cells = positions[order].astype(np.int64)
series = series.iloc[order]
if cells.size == n_obs:
cells = None
elif len(series) != n_obs or (isinstance(index, pd.RangeIndex) and not index.equals(pd.RangeIndex(n_obs))):
raise ValueError(
f"The column has {len(series)} {plural(len(series), 'value')} for the viewer's {n_obs} "
f"{plural(n_obs, 'cell')} and an index that names none "
"of them; push a Series indexed by obs names, or one value per cell in order"
)
dtype = series.dtype
if dtype.kind == "b" or isinstance(dtype, pd.BooleanDtype):
# A boolean column's labels are "false" and "true", in every language.
labels = series.map({False: "false", True: "true"})
present = set(labels.dropna())
series = labels.astype(pd.CategoricalDtype([label for label in ("false", "true") if label in present]))
dtype = series.dtype
if isinstance(dtype, pd.CategoricalDtype) or dtype.kind in "OUS" or pd.api.types.is_string_dtype(dtype):
categorical = series if isinstance(dtype, pd.CategoricalDtype) else series.astype("category")
categories = [str(category) for category in categorical.cat.categories]
if len(set(categories)) != len(categories):
raise ValueError("Two categories read as the same text; rename them before pushing")
if not categories:
raise ValueError("The column has no value to push")
codes = categorical.cat.codes.to_numpy().astype(np.int64)
code_dtype = _code_dtype(len(categories))
out = np.where(codes < 0, np.iinfo(code_dtype).max, codes).astype(code_dtype)
kind, field_categories, field_values = "categorical", categories, out
elif dtype.kind in "iuf":
numbers = series.to_numpy(dtype=np.float64, na_value=np.nan)
if bool(np.isinf(numbers).any()):
raise ValueError("The column holds an infinity; the viewer takes finite numbers (NaN for no value)")
if bool((np.abs(numbers[np.isfinite(numbers)]) > np.finfo(np.float32).max).any()):
raise ValueError(
"The column holds a value beyond the float32 range the viewer takes (about 3.4e38); "
"scale it first, or push its logarithm"
)
kind, field_categories, field_values = "continuous", None, numbers.astype(np.float32)
else:
raise TypeError(f"Values of dtype {dtype} cannot be pushed: push labels or numbers")
return {
"key": key,
"kind": kind,
"categories": field_categories,
"n_obs": n_obs,
"cells": None if cells is None else cells.astype(np.uint32),
"values": field_values,
}