Source code for cellucid.anndata_session._apply

"""
Applying one step of a session to an AnnData or a MuData: the plan, then the change.

:func:`apply_cellucid_session_to_anndata` (and
:meth:`cellucid.CellucidSession.apply`) reads a session file, takes the step
asked for (by tracker, branch and step), checks that the object is the
dataset the session was saved on, and plans every change the step's state
holds as rows (:class:`ApplyPlan`, the history model's ``ApplyPlan``): one
per renamed column, renamed or merged category, deleted field, user-defined
field, gene set, highlight group, and presentation domain the step changed.
A dry run returns the plan; :meth:`ApplyPlan.apply` (or ``dry_run=False``)
writes the rows to write, all at once, and records what it did in
``uns['cellucid']['history_applied']`` (:class:`ApplyReport`).

The rules are the same in cellucid-python, cellucid-r and the web app's
"Save AnnData with this state…" (``app/history/exports/anndata-copy.js``),
compared with one shared expected plan (``history_golden.expected.json``,
``apply.sequence``):

- **Rows**, in domain order. ``field-overlays``: renamed columns, then
  renamed categories, then deleted fields, then user-defined fields;
  ``gene-sets``: each set; ``highlights``: each group of every page; every
  other domain whose value at the step differs from the tracker's opening is
  one ``presentation`` row, not applied (filters, colours, views, the camera,
  analyses are how the data was shown, not the data).
- **Statuses**: ``write`` (the object lacks it), ``unchanged`` (the object
  holds exactly it already: applying again is a no-op, never a failure),
  ``replace`` (a column or gene set an earlier apply wrote, holding other
  values, with ``overwrite=True``), ``not-applied`` (with the reason: a
  column the person made is never replaced, a column or category the object
  lacks, a deletion, a presentation domain).
- **Provenance**: one record per apply that wrote something, appended to
  ``uns['cellucid']['history_applied']`` (JSON text: a list of the model's
  ``Provenance``): the session's SHA-256, the tracker, branch and step, when,
  the package and its version, the columns it owns (which a later apply
  replaces only with ``overwrite=True``), what it renamed, what it did not
  apply, and, with ``keep_session=True``, the session file itself (base64),
  so the history can be rebuilt from the object.
- **Gene sets** go to ``uns['cellucid']['gene_sets']`` by name (origin
  ``session``) and, one ``bool`` column each, to the ``var`` of the genes the
  data mapping reads: ``var`` when it reads ``X`` or a layer, the modality's
  ``var`` of a MuData (``mdata.mod[modality].var``; the global ``mdata.var``
  is not written). The columns are named over every set of the step, in its
  order: ``cellucid_gene_set__<name>``, the highlight columns' rule, with
  ``_2``, ``_3``... on a clash (``gene_set_columns``). Only a set written
  or replaced gets its column, ``True`` on each row whose gene (as the
  mapping names genes) the set lists, a gene the dataset lacks left out and
  warned: a new column goes after ``var``'s columns, an earlier apply's
  (``replace``) is replaced in place, and a column of that name beside a set
  no apply wrote is the dataset's own, kept and warned. A mapping that reads
  ``raw`` or no expression has no ``var`` to mark: the sets stay in ``uns``
  only, warned. A set the step no longer holds has no row: its record and
  its column stay. ``expected.apply.sequence[].var_columns`` pins the
  columns. The plan's rows and the provenance's ``columns`` (obs columns)
  are the same as without them.
- **The priority-order column** ``obs['cellucid_original_position']`` (each
  row's original position in a file stored in priority order) is never
  written, renamed or replaced: a plan that would, or that renames another
  column onto it, is refused before anything is written, dry runs included.
"""

from __future__ import annotations

import base64
import copy
import hashlib
import json
import warnings
from collections.abc import Mapping
from dataclasses import dataclass, replace
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, cast

import numpy as np
import pandas as pd

from .. import _history_model as model
from .. import _history_rules as _rules
from .._anndata_files import is_mudata
from .._plural import plural
from ..data_mapping import (
    MODALITY_KEY,
    mapped_var_count,
    require_mapping_matches_anndata,
    require_mapping_modality,
    resolve_gene_names,
)
from ..history import DOMAIN_NAMES, Step, Tracker
from ..session import CellucidSession, package_version
from ._cells import known_cells
from ._columns import write_columns
from ._fields import _plan_user_fields
from ._highlights import _plan_highlights, column_part, unique_column
from ._labels import _plan_labels
from ._mudata import MuDataSessionView
from ._primitives import _require_exact_keys, _require_list, _require_text
from ._records import _Candidate, _ColumnPlan
from ._schema import (
    CELLS_DOMAIN,
    FIELD_OVERLAYS_DOMAIN,
    GENE_SET_KEYS,
    GENE_SETS_DOMAIN,
    GENE_SETS_KEYS,
    HIGHLIGHTS_DOMAIN,
    OVERLAY_KEYS,
)

#: The ``uns['cellucid']`` key every apply appends its record to (JSON text).
HISTORY_APPLIED_KEY = "history_applied"
#: Where gene sets go (``uns['cellucid']['gene_sets']``), as a pull writes them.
GENE_SETS_KEY = "gene_sets"
#: The domains an apply writes; every other domain is presentation.
DATA_DOMAINS = (CELLS_DOMAIN, FIELD_OVERLAYS_DOMAIN, GENE_SETS_DOMAIN, HIGHLIGHTS_DOMAIN)
#: The attributes a plan and a report keep beside their fields.
_CONTEXT = "_context"
_ADATA = "_adata"
#: Why a presentation domain is not applied.
PRESENTATION_NOTE = "Presentation: not applied to the object."
#: The start of every gene set's ``var`` column.
GENE_SET_PREFIX = "cellucid_gene_set__"
#: Why the gene sets an apply writes have no ``var`` columns: the mapping reads ``raw.var`` or no genes.
GENE_SETS_UNS_ONLY_NOTE = (
    "The gene sets are kept in uns['cellucid']['gene_sets'] only: the data mapping reads its genes from another "
    "matrix than var, so they have no var columns."
)
#: Why an apply refuses a plan that would write, rename or replace the priority-order column.
ORIGINAL_POSITION_MESSAGE = (
    "obs['cellucid_original_position'] holds each row's original position in a file stored in priority order, "
    "so an apply never writes, renames or replaces it."
)


def owned_note(name: str, step: str) -> str:
    """Why a column or gene set an earlier apply wrote is not replaced without ``overwrite``."""
    return f'An earlier apply (step {step}) wrote "{name}" with other values; overwrite replaces it.'


def taken_set_note(name: str) -> str:
    return f'The object already has a gene set "{name}" that no session wrote; it is never replaced.'


def taken_column_note(name: str) -> str:
    return f'The object already has an obs column "{name}" that Cellucid did not write; it is never replaced.'


def own_var_column_note(name: str, column: str) -> str:
    """Why a gene set no apply wrote gets no ``var`` column: one of that name is the dataset's own."""
    return f'The gene set "{name}" has no var column: the var column "{column}" is the dataset\'s own.'


def missing_genes_note(name: str, missing: list[str]) -> str:
    """The genes a gene set names that the dataset lacks, left out of its ``var`` column (the first three)."""
    shown = ", ".join(missing[:3]) + (", …" if len(missing) > 3 else "")
    return (
        f'The gene set "{name}" names {len(missing)} {plural(len(missing), "gene")} the dataset does not have '
        f"({shown}), left out of its var column."
    )


def steps_only_message() -> str:
    return (
        "This session was shared as its steps only, without the values they leave behind, so it cannot be "
        "applied to an object; replay it in Cellucid on its dataset instead."
    )


def _require_session(session: Any) -> CellucidSession:
    if isinstance(session, CellucidSession):
        return session
    if isinstance(session, str | Path):
        return CellucidSession(session)
    raise TypeError("session must be a CellucidSession or the path of a .cellucid-session file")


# ---------------------------------------------------------------------------
# The dataset the session was saved on


def _check_fingerprint(fingerprint: dict[str, Any], adata: Any, *, open_cells: int) -> Any:
    """Check the object against the session's dataset before anything is planned.

    Returns what the session is checked and planned against: ``adata``
    itself, or for a MuData the cells and the modality its data mapping
    names, as the viewer showed them (:class:`MuDataSessionView`). The order
    is the viewer's: the dataset's id (when the object names one in
    ``uns['cellucid']['id']`` and the session names one), the cell count,
    then the data mapping -- every key it names must exist on the object --
    and the gene count of the axis that mapping reads.
    """
    record = getattr(adata, "uns", {}).get("cellucid") if isinstance(getattr(adata, "uns", None), Mapping) else None
    own_id = record.get("id") if isinstance(record, Mapping) else None
    dataset_id = fingerprint["datasetId"]
    if isinstance(own_id, str) and own_id and dataset_id is not None and own_id != dataset_id:
        raise ValueError(
            f"This session was saved on the dataset {dataset_id!r}, and this object is {own_id!r} "
            "(uns['cellucid']['id']). Apply it to the dataset it was saved on."
        )
    if fingerprint["cellCount"] != open_cells:
        raise ValueError(
            f"This session was saved on {fingerprint['cellCount']} {plural(fingerprint['cellCount'], 'cell')}, "
            f"and this object has {open_cells}. Apply it to the dataset it was saved on."
        )
    mapping = fingerprint["dataMapping"]
    shown = adata
    if is_mudata(adata):
        require_mapping_modality(mapping, [str(name) for name in adata.mod])
        shown = MuDataSessionView(adata, mapping[MODALITY_KEY])
        mapping = {key: value for key, value in mapping.items() if key != MODALITY_KEY}
    require_mapping_matches_anndata(shown, mapping)
    expected_var_count = mapped_var_count(shown, mapping)
    if fingerprint["varCount"] != expected_var_count:
        axis = (
            "the raw matrix's genes" if mapping["expression"] == "raw"
            else "genes on a mapping without expression" if mapping["expression"] is None
            else "genes"
        )
        raise ValueError(
            f"This session was saved on a dataset of {fingerprint['varCount']} {plural(fingerprint['varCount'], 'gene')}, "
            f"and this object has {expected_var_count} {axis}. Apply it to the dataset it was saved on."
        )
    return shown


def cell_order_digest(values: np.ndarray, positions: np.ndarray | None = None) -> str:
    """The cell-order digest of some cells' stored embedding values
    (``data/cell-order-digest.js``): two MurmurHash3 lanes per cell over its
    original position and its float32 values on up to three axes, summed,
    finalised with the cell count; 16 lower-case hex digits.

    ``values`` is one row per cell, 1 to 3 columns; ``positions`` each row's
    original position (None: row ``i`` is position ``i``).
    """
    array = np.ascontiguousarray(values, dtype=np.float32)
    if array.ndim == 1:
        array = array[:, None]
    cells, width = array.shape
    if not 1 <= width <= 3:
        raise ValueError("A cell-order digest is taken over 1, 2 or 3 columns.")
    bits = array.view(np.uint32).astype(np.uint64)
    nan = (bits & 0x7F800000) == 0x7F800000
    bits = np.where(nan & ((bits & 0x007FFFFF) != 0), np.uint64(0x7FC00000), bits)
    mask = np.uint64(0xFFFFFFFF)

    def mul(a: np.ndarray, b: int) -> np.ndarray:
        return (a * np.uint64(b)) & mask

    def rotl(a: np.ndarray, r: int) -> np.ndarray:
        return ((a << np.uint64(r)) | (a >> np.uint64(32 - r))) & mask

    def scramble(word: np.ndarray) -> np.ndarray:
        return mul(rotl(mul(word, 0xCC9E2D51), 15), 0x1B873593)

    def step(h: np.ndarray, k: np.ndarray) -> np.ndarray:
        return (mul(rotl(h ^ k, 13), 5) + np.uint64(0xE6546B64)) & mask

    def fmix(h: np.ndarray) -> np.ndarray:
        h = h ^ (h >> np.uint64(16))
        h = mul(h, 0x85EBCA6B)
        h = h ^ (h >> np.uint64(13))
        h = mul(h, 0xC2B2AE35)
        mixed: np.ndarray = h ^ (h >> np.uint64(16))
        return mixed

    zero = scramble(np.zeros(cells, dtype=np.uint64))
    words = [scramble(bits[:, axis]) if axis < width else zero for axis in range(3)]
    position = np.arange(cells, dtype=np.uint64) if positions is None else np.asarray(positions, dtype=np.uint64)
    scrambled = scramble(position & mask)
    lanes = []
    for seed in (0x9E3779B1, 0x85EBCA6B):
        h = np.full(cells, seed, dtype=np.uint64)
        h = step(h, scrambled)
        for word in words:
            h = step(h, word)
        lanes.append(int(fmix(h ^ np.uint64(16)).sum() & 0xFFFFFFFF))
    low, high = lanes

    def fmix_one(value: int) -> int:
        return int(fmix(np.array([value & 0xFFFFFFFF], dtype=np.uint64))[0])

    first = fmix_one(high ^ fmix_one(cells ^ 0x297A2D39))
    second = fmix_one(low ^ fmix_one(cells ^ 0x2C1B3C6D))
    return f"{first:08x}{second:08x}"


def _check_cell_order(fingerprint: dict[str, Any], shown: Any, kept: np.ndarray | None) -> None:
    """The cell-order digest of the embedding the session names, over the
    cells it was saved on, when the object has that embedding: a mismatch
    means the rows are other cells (or in another order), so every cell
    position the session holds would name the wrong cell."""
    order = fingerprint["cellOrder"]
    key = order["embedding"]["key"]
    columns = list(order["embedding"]["columns"])
    obsm = getattr(shown, "obsm", None)
    if obsm is None or key not in obsm:
        return
    values = np.asarray(obsm[key])
    if values.ndim != 2 or max(columns) >= values.shape[1]:
        return
    rows = values[:, columns] if kept is None else values[np.asarray(kept, dtype=np.int64)][:, columns]
    digest = cell_order_digest(rows, kept)
    if digest != order["digest"]:
        raise ValueError(
            f"This object's cells are not the cells the session was saved on, in the same order: the cell-order "
            f"digest of obsm[{key!r}] (columns {', '.join(map(str, columns))}) is {digest}, and the session's is "
            f"{order['digest']}. Put the cells in their original order, or apply the session to the object it was "
            "saved on."
        )


def required_fields(state: Mapping[str, Any]) -> dict[str, list[str]]:
    """The dataset fields a step's state uses (``collectRequiredFields``): every
    obs column and gene a view colours by or filters on, or a highlight group
    is bound to, by the key the dataset gives it; fields the session made
    itself are not listed. Sorted as JavaScript sorts text."""
    required: dict[str, set[str]] = {"obs": set(), "var": set()}

    def add(ref: Any) -> None:
        if isinstance(ref, Mapping) and isinstance(ref.get("key"), str) and ref.get("source") in required:
            required[ref["source"]].add(ref["key"])

    for domain in ("filters", "colors"):
        value = state.get(domain)
        for view in (value or {}).get("views", []) if isinstance(value, Mapping) else []:
            for entry in view or []:
                if isinstance(entry, Mapping):
                    add(entry.get("field"))
    active = state.get("active-field")
    for ref in (active or {}).get("views", []) if isinstance(active, Mapping) else []:
        add(ref)
    highlights = state.get("highlights")
    for page in (highlights or {}).get("pages", []) if isinstance(highlights, Mapping) else []:
        for group in page.get("groups", []):
            add(group.get("field"))

    def utf16(text: str) -> bytes:
        return text.encode("utf-16-be", "surrogatepass")

    return {source: sorted(keys, key=utf16) for source, keys in required.items()}


def _require_session_fields(shown: Any, requires: Mapping[str, list[str]], mapping: dict[str, Any], step: str) -> None:
    """Refuse an object that lacks a field the step uses, naming all of them."""
    obs_keys = set(shown.obs.columns)
    missing_obs = [key for key in requires["obs"] if key not in obs_keys]
    missing_var: list[str] = []
    if requires["var"]:
        gene_names = set(resolve_gene_names(shown, mapping).names)
        missing_var = [key for key in requires["var"] if key not in gene_names]
    if not missing_obs and not missing_var:
        return
    parts: list[str] = []
    if missing_obs:
        parts.append(f"the obs {plural(len(missing_obs), 'column')} " + ", ".join(repr(key) for key in missing_obs))
    if missing_var:
        parts.append(f"the {plural(len(missing_var), 'gene')} " + ", ".join(repr(key) for key in missing_var))
    raise ValueError(f"The state at step {step} uses " + " and ".join(parts) + ", which this dataset does not have.")


def _read_gene_sets(value: Any) -> list[dict[str, Any]]:
    """The gene sets of a ``gene-sets`` value."""
    record = _require_exact_keys(value, GENE_SETS_KEYS, label="gene-sets value")
    sets: list[dict[str, Any]] = []
    for index, raw in enumerate(_require_list(record["sets"], label="gene-sets value sets")):
        label = f"gene-sets value sets[{index}]"
        entry = _require_exact_keys(raw, GENE_SET_KEYS, label=label)
        name = _require_text(entry["name"], label=f"{label}.name")
        if not isinstance(entry["description"], str):
            raise ValueError(f"This session's {label}.description must be a text.")
        genes = [
            _require_text(gene, label=f"{label}.genes[{position}]")
            for position, gene in enumerate(_require_list(entry["genes"], label=f"{label}.genes"))
        ]
        sets.append({"name": name, "description": entry["description"], "genes": genes})
    return sets


# ---------------------------------------------------------------------------
# What earlier applies wrote


[docs] def history_applied(adata: Any) -> list[dict[str, Any]]: """Every apply recorded on an object (``uns['cellucid']['history_applied']``), oldest first, each the model's ``Provenance`` as JSON.""" uns = getattr(adata, "uns", None) if not isinstance(uns, Mapping): raise TypeError("adata.uns must be a dictionary") record = uns.get("cellucid") if record is None: return [] if not isinstance(record, Mapping): raise TypeError("adata.uns['cellucid'] must be a dictionary if present") text = record.get(HISTORY_APPLIED_KEY) if text is None: return [] if not isinstance(text, str): raise ValueError("adata.uns['cellucid']['history_applied'] must be JSON text (a list of apply records).") try: entries = json.loads(text) except ValueError as error: raise ValueError("adata.uns['cellucid']['history_applied'] is not JSON.") from error if not isinstance(entries, list): raise ValueError("adata.uns['cellucid']['history_applied'] must be a list of apply records.") for index, entry in enumerate(entries): try: _rules.check_record("Provenance", entry, f"history_applied[{index}]") except _rules.HistoryModelError as error: raise ValueError(f"adata.uns['cellucid']['history_applied'] is not as an apply writes it: {error}.") from error return entries
def _same_column(existing: pd.Series, planned: pd.Series) -> bool: return bool(existing.reset_index(drop=True).equals(planned.reset_index(drop=True))) # --------------------------------------------------------------------------- # The plan @dataclass(frozen=True) class _VarColumn: """One gene set's ``bool`` column of the ``var`` its data mapping reads.""" name: str values: np.ndarray @dataclass(frozen=True) class _Writes: """What applying a plan writes: obs columns (added, replaced, rebuilt with renamed categories, renamed), gene sets, and their ``var`` columns (on ``adata.var``, or on modality ``var_modality``'s of a MuData), with what was left out of those (``var_notes``, warned when written).""" columns: list[_ColumnPlan] replaced: list[_ColumnPlan] rename: dict[str, str] gene_sets: dict[str, dict[str, Any]] renamed: list[dict[str, Any]] var_columns: list[_VarColumn] var_modality: str | None var_notes: list[str]
[docs] @dataclass(frozen=True) class ApplyPlan(model.ApplyPlan): """What applying one step to an object would change, written nowhere yet. Every field of the history model's ``ApplyPlan`` (``session_sha256``, ``tracker``, ``branch``, ``step``, ``requires``, ``rows``); ``table`` is the rows as a DataFrame; :meth:`apply` writes them into the object the plan was made for. """ @property def table(self) -> pd.DataFrame: """One row per change (``kind``, ``domain``, ``name``, ``column``, ``from``, ``cells``, ``status``, ``note``), in domain order.""" return pd.DataFrame( [[row.kind, row.domain, row.name, row.column, list(row.from_), row.cells, row.status, row.note] for row in self.rows], columns=["kind", "domain", "name", "column", "from", "cells", "status", "note"], ) @property def writes_anything(self) -> bool: """Whether applying it would write anything (a row to write or replace).""" return any(row.status in ("write", "replace") for row in self.rows)
[docs] def apply(self) -> ApplyReport: """Write the plan into the object it was made for, all at once, and record it in ``uns['cellucid']['history_applied']``. The plan is made again first: an object changed since (another apply, a column added) is refused rather than half-written. """ context = cast(_PlanContext, getattr(self, _CONTEXT)) again = _plan(context.session, context.adata, tracker=context.tracker, branch=context.branch, step=context.step, overwrite=context.overwrite, keep_session=False) if again.rows != self.rows: raise ValueError("The object changed since this plan was made; make the plan again (dry_run=True) and apply that.") object.__setattr__(again, _CONTEXT, replace(cast(_PlanContext, getattr(again, _CONTEXT)), kept=context.kept)) return _write(again)
def __repr__(self) -> str: counts = {status: sum(1 for row in self.rows if row.status == status) for status in model.APPLY_ROW_STATUSES} summary = ", ".join(f"{count} {status}" for status, count in counts.items() if count) return f"ApplyPlan(step {self.step} of branch {self.branch} of tracker {self.tracker}: {summary or 'no change'})"
[docs] @dataclass(frozen=True) class ApplyReport(model.ApplyReport): """What an apply did: its plan, whether it wrote anything (``applied``), and the record it wrote (``provenance``, None when nothing was written). ``adata`` is the object it was applied to.""" @property def adata(self) -> Any: return getattr(self, _ADATA)
@dataclass(frozen=True) class _PlanContext: session: CellucidSession adata: Any tracker: str | None branch: str | None step: str | None overwrite: bool # The session file's bytes, read once when the plan is made with # keep_session (so the file may go before the plan is applied). kept: bytes | None writes: _Writes def _choose(session: CellucidSession, *, tracker: str | None, branch: str | None, step: str | None) -> tuple[Tracker, str, Step]: """The tracker, branch and step an apply takes: the step asked for (on the branch asked for, when both are), a branch's tip, or the tracker's head on its active branch.""" found = session.tracker(tracker) if step is not None: chosen = found.step(step) if branch is None: return found, chosen.branch, chosen on = found.branch(branch) if chosen.id not in {item.id for item in on.path}: raise ValueError( f'Step {chosen.id} is not on the branch "{on.name}" (its path runs from the root to step {on.tip}); ' f'it was made on "{found.branch(chosen.branch).name}".' ) return found, on.id, chosen if branch is not None: on = found.branch(branch) return found, on.id, found.step(on.tip) return found, found.active_branch, found.step(found.head_id) def _row(kind: str, domain: str, name: str, status: str, *, column: str | None = None, from_: list[str] | None = None, cells: int | None = None, note: str | None = None) -> dict[str, Any]: return {"kind": kind, "domain": domain, "name": name, "column": column, "from": list(from_ or []), "cells": cells, "status": status, "note": note} def _session_bytes(session: CellucidSession) -> bytes: """The session file's bytes, which must still be the file that was read.""" data = session.path.read_bytes() if hashlib.sha256(data).hexdigest() != session.sha256: raise ValueError(f"{session.path.name} changed since it was read; read it again and make the plan again.") return data def _plan( session: CellucidSession, adata: Any, *, tracker: str | None, branch: str | None, step: str | None, overwrite: bool, keep_session: bool, ) -> ApplyPlan: """Check the object and plan every row, writing nothing.""" if session.scope.content == "steps": raise ValueError(steps_only_message()) chosen_tracker, branch_id, chosen = _choose(session, tracker=tracker, branch=branch, step=step) if not hasattr(adata, "obs") or not hasattr(adata, "var") or not hasattr(adata, "uns"): raise TypeError("adata must be an AnnData-compatible object") if type(getattr(adata, "isbacked", None)) is not bool: raise TypeError("adata.isbacked must be exactly True or False") # An AnnData read from a file in priority order holds its cells in the # stored order; a session names cells in their original order. from ..prepare_data._priority import stored_order_of stored = stored_order_of(adata) if stored.notice is not None: warnings.warn(stored.notice, UserWarning, stacklevel=4) if stored.positions is not None: raise ValueError( "adata holds its cells in a Cellucid-ready file's stored order (uns['cellucid']['priority']), " "and a session names cells in their original order. Put them back first: " "adata = cellucid.restore_original_order(adata)." ) n_obs = int(adata.n_obs) saved_on = session.cells open_cells = n_obs kept = None if saved_on is not None: if n_obs != saved_on["spec"]["nObs"]: raise ValueError( f"This session was saved on {saved_on['spec']['k']} of {saved_on['spec']['nObs']} " f"{plural(saved_on['spec']['nObs'], 'cell')}, and this object has {n_obs}. Apply it to the dataset it was saved on." ) kept = saved_on["kept"] open_cells = int(kept.size) fingerprint = session.dataset_fingerprint shown = _check_fingerprint(fingerprint, adata, open_cells=open_cells) _check_cell_order(fingerprint, shown, kept) mapping = {key: value for key, value in fingerprint["dataMapping"].items() if key != MODALITY_KEY} raw = chosen_tracker._raw_state(chosen.id) root = chosen_tracker._hashes(chosen_tracker.root_id) hashes = chosen_tracker._hashes(chosen.id) for domain in DATA_DOMAINS: if domain not in raw: raise ValueError(f"This session's state at step {chosen.id} holds no {domain} value.") plain = chosen.state() requires = required_fields(plain) _require_session_fields(shown, requires, mapping, chosen.id) applied = history_applied(adata) owners: dict[str, str] = {} for record in applied: for column in record["columns"]: owners[column] = record["step"] earlier_renames = [entry for record in applied for entry in record["renamed"]] known = known_cells(saved_on=saved_on, cells_value=raw[CELLS_DOMAIN], shown=shown, n_obs=n_obs, step=chosen.id) overlays = _require_exact_keys(raw[FIELD_OVERLAYS_DOMAIN], OVERLAY_KEYS, label="field-overlays value") modality_columns = set(map(str, shown.obs.columns)) - set(map(str, adata.obs.columns)) labels = _plan_labels(overlays, adata.obs, modality_columns=modality_columns, earlier=earlier_renames) present_after = (set(map(str, adata.obs.columns)) - set(labels.rename)) | set(labels.rename.values()) columns: list[_ColumnPlan] = [] def settle(candidate: _Candidate) -> dict[str, Any]: """A column's row: written, already there, replaced, or why not.""" name = candidate.name if candidate.values is None: return _row(candidate.kind, candidate.domain, name, "not-applied", cells=candidate.cells, note=candidate.note) if name not in present_after: columns.append(_ColumnPlan(name=name, values=candidate.values, metadata=candidate.metadata)) return _row(candidate.kind, candidate.domain, name, "write", cells=candidate.cells) existing_name = next((old for old, new in labels.rename.items() if new == name), name) existing = adata.obs[existing_name] if name not in owners: return _row(candidate.kind, candidate.domain, name, "not-applied", cells=candidate.cells, note=taken_column_note(name)) if _same_column(existing, candidate.values): return _row(candidate.kind, candidate.domain, name, "unchanged", cells=candidate.cells) if overwrite: columns.append(_ColumnPlan(name=name, values=candidate.values, metadata=candidate.metadata)) return _row(candidate.kind, candidate.domain, name, "replace", cells=candidate.cells, note=f"Written by an earlier apply (step {owners[name]}); replaced.") return _row(candidate.kind, candidate.domain, name, "not-applied", cells=candidate.cells, note=owned_note(name, owners[name])) field_rows = [settle(candidate) for candidate in _plan_user_fields(overlays["userFields"], shown, mapping=mapping, known=known)] step_sets = _read_gene_sets(raw[GENE_SETS_DOMAIN]) gene_set_rows, gene_sets = _plan_gene_sets(step_sets, adata, session, overwrite=overwrite) var_columns, var_notes = _plan_gene_set_columns(step_sets, gene_set_rows, gene_sets, shown=shown, mapping=mapping) highlight_rows = [settle(candidate) for candidate in _plan_highlights(raw[HIGHLIGHTS_DOMAIN], n_obs=n_obs, index=adata.obs_names, known=known)] _refuse_original_position(columns, labels.replaced, labels.rename) presentation = { domain: _row("presentation", domain, DOMAIN_NAMES[domain], "not-applied", note=PRESENTATION_NOTE) for domain in model.DOMAINS if domain not in DATA_DOMAINS and hashes.get(domain) != root.get(domain) } by_domain: dict[str, list[dict[str, Any]]] = { FIELD_OVERLAYS_DOMAIN: [*labels.rows, *field_rows], GENE_SETS_DOMAIN: gene_set_rows, HIGHLIGHTS_DOMAIN: highlight_rows, } rows = [row for domain in model.DOMAINS for row in by_domain.get(domain, [presentation[domain]] if domain in presentation else [])] plan = ApplyPlan( session_sha256=session.sha256, tracker=chosen_tracker.id, branch=branch_id, step=chosen.id, requires=model.Requires(obs=tuple(requires["obs"]), var=tuple(requires["var"])), rows=tuple(model.build("ApplyPlanRow", row) for row in rows), ) writes = _Writes( columns=columns, replaced=labels.replaced, rename=labels.rename, gene_sets=gene_sets, renamed=labels.renamed, var_columns=var_columns, var_modality=fingerprint["dataMapping"][MODALITY_KEY] if is_mudata(adata) else None, var_notes=var_notes, ) object.__setattr__(plan, _CONTEXT, _PlanContext( session=session, adata=adata, tracker=tracker, branch=branch, step=step, overwrite=overwrite, kept=_session_bytes(session) if keep_session else None, writes=writes, )) return plan def _plan_gene_sets( sets: list[dict[str, Any]], adata: Any, session: CellucidSession, *, overwrite: bool, ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: """A row per gene set of the step, and the entries to write.""" record = adata.uns.get("cellucid") current = record.get(GENE_SETS_KEY) if isinstance(record, Mapping) else None if current is not None and not isinstance(current, Mapping): raise TypeError("adata.uns['cellucid']['gene_sets'] must be a dictionary if present") current = current or {} rows: list[dict[str, Any]] = [] writes: dict[str, dict[str, Any]] = {} for entry in sets: name = entry["name"] written = {"genes": list(entry["genes"]), "description": entry["description"], "origin": "session", "pulled_from": session.path.name, "id": name} held = current.get(name) if held is None: writes[name] = written rows.append(_row("gene-set", GENE_SETS_DOMAIN, name, "write")) continue same = isinstance(held, Mapping) and list(held.get("genes", [])) == written["genes"] and held.get("description") == written["description"] owned = isinstance(held, Mapping) and held.get("origin") == "session" if same: rows.append(_row("gene-set", GENE_SETS_DOMAIN, name, "unchanged")) elif not owned: rows.append(_row("gene-set", GENE_SETS_DOMAIN, name, "not-applied", note=taken_set_note(name))) elif overwrite: writes[name] = written rows.append(_row("gene-set", GENE_SETS_DOMAIN, name, "replace", note="Written by an earlier apply; replaced.")) else: rows.append(_row("gene-set", GENE_SETS_DOMAIN, name, "not-applied", note=f'An earlier apply wrote the gene set "{name}" with other genes; overwrite replaces it.')) return rows, writes def gene_set_columns(names: list[str]) -> dict[str, str]: """Each gene set's ``var`` column, by name: ``cellucid_gene_set__<name>`` (the highlight columns' rule, ``column_part``: each run of characters other than ASCII letters and digits one underscore, none at either end; ``gene_set`` when nothing is left), ``_2``, ``_3``... on a clash. Every set of the step is named, in its order, so a set's column is the same whichever of them an apply writes.""" taken: set[str] = set() return {name: unique_column(f"{GENE_SET_PREFIX}{column_part(name, 'gene_set')}", taken) for name in names} def _plan_gene_set_columns( sets: list[dict[str, Any]], rows: list[dict[str, Any]], writes: dict[str, dict[str, Any]], *, shown: Any, mapping: Mapping[str, Any], ) -> tuple[list[_VarColumn], list[str]]: """The ``var`` column of each gene set an apply writes or replaces, and what is left out of them. The columns go to the ``var`` of the genes the data mapping reads (``X`` or a layer: ``var``, a MuData modality's for a MuData); a mapping that reads ``raw`` or no expression has none, and the sets stay in ``uns`` only. A column of a set written (no apply wrote it before) whose name ``var`` holds already is the dataset's own and is not written; a set replaced replaces its column. ``True`` on each row whose gene, as the mapping names genes, the set lists; a gene the dataset lacks is left out. """ if not writes: return [], [] if mapping["expression"] in (None, "raw"): return [], [GENE_SETS_UNS_ONLY_NOTE] names = resolve_gene_names(shown, mapping).names row_of = {gene: position for position, gene in enumerate(names)} held = set(shown.var.columns) status = {row["name"]: row["status"] for row in rows} column_of = gene_set_columns([entry["name"] for entry in sets]) columns: list[_VarColumn] = [] notes: list[str] = [] for name, entry in writes.items(): column = column_of[name] if column in held and status[name] != "replace": notes.append(own_var_column_note(name, column)) continue values = np.zeros(len(names), dtype=bool) missing: list[str] = [] for gene in entry["genes"]: position = row_of.get(gene) if position is None: missing.append(gene) else: values[position] = True if missing: notes.append(missing_genes_note(name, missing)) columns.append(_VarColumn(name=column, values=values)) return columns, notes def _refuse_original_position(columns: list[_ColumnPlan], replaced: list[_ColumnPlan], rename: Mapping[str, str]) -> None: """Refuse a plan that would write, replace or rename ``obs['cellucid_original_position']`` (or rename a column onto it): the column a file stored in priority order keeps each row's original position in (:func:`~cellucid.restore_original_order` reads and drops it).""" from ..prepare_data._priority import ORIGINAL_POSITION_COLUMN touched = {plan.name for plan in (*columns, *replaced)} | set(rename) | set(rename.values()) if ORIGINAL_POSITION_COLUMN in touched: raise ValueError(ORIGINAL_POSITION_MESSAGE) def _iso_now() -> str: now = datetime.now(UTC) return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z" def _write(plan: ApplyPlan) -> ApplyReport: """Write a plan made just now: every obs column at once, the gene sets' ``var`` columns, then ``uns``; then warn what the ``var`` columns left out.""" context = cast(_PlanContext, getattr(plan, _CONTEXT)) target = context.adata if not plan.writes_anything: report = ApplyReport(plan=plan, applied=False, provenance=None) object.__setattr__(report, _ADATA, target) return report writes = context.writes provenance = { "session_sha256": plan.session_sha256, "tracker": plan.tracker, "branch": plan.branch, "step": plan.step, "applied_at": _iso_now(), "cellucid_version": package_version(), "package": "python", "columns": [row.name for row in plan.rows if row.kind in ("user-field", "highlight") and row.status in ("write", "replace")], "renamed": copy.deepcopy(writes.renamed), "skipped": [{"kind": row.kind, "name": row.name, "note": row.note or ""} for row in plan.rows if row.status == "not-applied"], "session": None if context.kept is None else base64.b64encode(context.kept).decode("ascii"), } _rules.check_record("Provenance", provenance, "provenance") uns = target.uns existing = uns.get("cellucid") record = copy.deepcopy(dict(existing)) if isinstance(existing, Mapping) else {} if writes.gene_sets: sets = dict(record.get(GENE_SETS_KEY) or {}) sets.update(writes.gene_sets) record[GENE_SETS_KEY] = sets entries = history_applied(target) record[HISTORY_APPLIED_KEY] = json.dumps([*entries, provenance], ensure_ascii=False, allow_nan=False, separators=(",", ":")) # A new column goes after var's columns; a replaced one keeps its place. var_owner = target if writes.var_modality is None else target.mod[writes.var_modality] next_var = None if writes.var_columns: next_var = var_owner.var.copy(deep=False) for column in writes.var_columns: next_var[column.name] = column.values write_columns(target, writes.columns, replace=writes.replaced, rename=writes.rename) if next_var is not None: var_owner.var = next_var target.uns["cellucid"] = record for note in writes.var_notes: warnings.warn(note, UserWarning, stacklevel=3) report = ApplyReport(plan=plan, applied=True, provenance=model.build("Provenance", provenance)) object.__setattr__(report, _ADATA, target) return report def _require_options(**flags: object) -> None: for label, flag in flags.items(): if type(flag) is not bool: raise TypeError(f"{label} must be exactly True or False")
[docs] def apply_cellucid_session_to_anndata( adata: Any, session: CellucidSession | str | Path, *, tracker: str | None = None, branch: str | None = None, step: str | None = None, dry_run: bool = True, overwrite: bool = False, keep_session: bool = False, ) -> ApplyPlan | ApplyReport: """What applying one step of a session to its AnnData would change, or the change made. A session file holds the history of every step a person, an agent or a notebook took (:class:`~cellucid.CellucidSession`); this applies the state one step left -- its labels, user-defined fields, gene sets and highlight groups -- to the object it was saved on. With ``dry_run=True`` (the default) nothing is written: the returned :class:`ApplyPlan` lists every change, one row each, with what would happen to it; ``plan.apply()`` then writes it. With ``dry_run=False`` the plan is applied at once and the :class:`ApplyReport` returned. Every check runs before anything is written, and everything is written at once, so a failed apply leaves the object as it was. What is checked (only what the chosen step needs): - the dataset: the cell count (of the subsample the session was saved on, when it was), the data mapping's keys and gene count, the dataset id when the object names one (``uns['cellucid']['id']``), and the cell order: when the object has the embedding the session names, the cell-order digest of its stored values over the session's cells must be the session's (the rows are those cells, in that order); a digest is a sum over the cells, so a mismatch says the cells differ, not which; - the fields the chosen step's state uses (the obs columns and genes its views colour by or filter on, and its highlight groups are bound to); the session's own ``requires`` is the shown state's. What a plan's rows are, in domain order: ``column-renamed`` (an obs column renamed in place), ``categories-renamed`` and ``categories-merged`` (a category renamed, or renamed into one the column has: the two become one), ``field-deleted`` (recorded, never applied: the column stays), ``user-field`` (a column per field the session made: a categorical, or ``float32`` values; a field deleted in the session is not applied), ``gene-set`` (``uns['cellucid']['gene_sets']``, and a ``bool`` ``var`` column, below), ``highlight`` (one ``bool`` column per group, ``cellucid_highlight__<page>__<group>`` from the page's name and the group's label, ASCII letters and digits with every other run one underscore, ``_2``, ``_3``... when two read alike) and ``presentation`` (a domain the step changed that is how the data was shown -- filters, colours, views, the camera, analyses -- never applied). What each row's status is: ``write``; ``unchanged`` (the object holds it already: applying the same step again changes nothing and reports so); ``replace`` (a column or gene set an earlier apply wrote, holding other values, with ``overwrite=True``); ``not-applied`` with the reason in ``note`` -- a column or gene set the person made is never replaced. Each gene set written or replaced also gets a ``bool`` column in the ``var`` of the genes the data mapping reads (``adata.var``; for a MuData, the ``var`` of the modality the mapping names), named ``cellucid_gene_set__<name>`` by the highlight columns' rule over every set of the step (``_2``, ``_3``... on a clash), ``True`` on each row whose gene, as the mapping names genes, the set lists. A new column goes after ``var``'s columns; a replaced set's column is replaced in place. Warned (``UserWarning``) when written: genes the dataset lacks (left out of the column), a ``var`` column of that name beside a set no apply wrote (the dataset's own: kept, no column written), and a mapping that reads ``raw`` or no expression (no ``var`` to mark: the sets stay in ``uns`` only). Nothing is removed: a gene set the step no longer holds keeps its record and its column. ``obs['cellucid_original_position']`` (each row's original position in a file stored in priority order) is never written, renamed or replaced: a plan that would is refused. A session saved on a subsample, or a step taken while the viewer had a subsample open, knows the state of those cells only: a cell it did not open is ``<NA>`` in a highlight column (the nullable ``boolean`` dtype) and missing in a field's -- unknown, not "not selected". Parameters ---------- adata : anndata.AnnData or mudata.MuData The dataset the session was saved on, its cells in their original order (an AnnData read from a Cellucid-ready file in its stored priority order is refused: :func:`~cellucid.restore_original_order` puts it back). For a MuData the session is checked against what the viewer showed -- the cells with every modality placed among them, and the modality its data mapping names -- and labels and columns apply to the global ``mdata.obs``, gene sets' ``var`` columns to that modality's ``var``; ``mdata.uns`` records the apply. Written in place. session : CellucidSession, str, or Path The session, or the path of a ``.cellucid-session`` file (read and checked whole first). One shared as its steps only cannot be applied. tracker : str, optional A tracker by its name or its id. Default: the one the session shows. branch : str, optional A branch by its name or its id: its tip is applied, or ``step`` when it is on the branch's path. step : str, optional A step by its id. Default: the branch's tip, or with no branch the tracker's head (what the viewer showed). dry_run : bool Plan only (the default); False applies the plan at once. overwrite : bool Replace a column or gene set an earlier apply wrote with other values. keep_session : bool Keep the session file itself (base64) in the apply's record, so the history can be rebuilt from the object alone. A record holds no ``source`` (where the viewer opened the dataset, which may be a private link); the whole file kept this way does, unless it was shared without one. Returns ------- ApplyPlan or ApplyReport The plan (``dry_run=True``), or the report of the apply: its plan, whether anything was written, and the record written to ``uns['cellucid']['history_applied']`` (JSON text; read it with :func:`cellucid.history_applied`). Raises ------ TypeError An argument of the wrong type. ValueError A session file that is damaged, of another format, written by a newer Cellucid, or shared as its steps only; a tracker, branch or step it does not hold, or a step not on the branch named; an object that is not the dataset the session was saved on, or lacks a field the step uses; a plan that would write, rename or replace ``obs['cellucid_original_position']``; a value this version does not read. """ _require_options(dry_run=dry_run, overwrite=overwrite, keep_session=keep_session) for label, value in (("tracker", tracker), ("branch", branch), ("step", step)): if value is not None and not isinstance(value, str): raise TypeError(f"{label} must be a string (a name or an id) or None") plan = _plan(_require_session(session), adata, tracker=tracker, branch=branch, step=step, overwrite=overwrite, keep_session=keep_session) if dry_run: return plan return _write(plan)
def session_columns(session: Any, adata: Any) -> tuple[list[_Candidate], list[dict[str, Any]]]: """The highlight groups and user-defined fields a session shows (its active tracker's head) as columns of ``adata``, and its gene sets: what :func:`~cellucid.pull` writes from a session file, checked as an apply checks it.""" found = _require_session(session) plan = _plan(found, adata, tracker=None, branch=None, step=None, overwrite=False, keep_session=False) chosen = found.tracker().step(plan.step) raw = found.tracker()._raw_state(chosen.id) saved_on = found.cells shown = adata if not is_mudata(adata) else MuDataSessionView(adata, found.dataset_fingerprint["dataMapping"][MODALITY_KEY]) known = known_cells(saved_on=saved_on, cells_value=raw[CELLS_DOMAIN], shown=shown, n_obs=int(adata.n_obs), step=chosen.id) mapping = {key: value for key, value in found.dataset_fingerprint["dataMapping"].items() if key != MODALITY_KEY} overlays = _require_exact_keys(raw[FIELD_OVERLAYS_DOMAIN], OVERLAY_KEYS, label="field-overlays value") candidates = [ *_plan_highlights(raw[HIGHLIGHTS_DOMAIN], n_obs=int(adata.n_obs), index=adata.obs_names, known=known), *_plan_user_fields(overlays["userFields"], shown, mapping=mapping, known=known), ] return [candidate for candidate in candidates if candidate.values is not None], _read_gene_sets(raw[GENE_SETS_DOMAIN])