Annie Voigt
style: apply ruff lint --fix + ruff format across the tree
c3b49d6
Raw
History Blame Contribute Delete
9.04 kB
"""Draft a manifest from an uploaded file (ADR-0011 friction mitigation).
The gate requires a manifest (item 2), which is the main friction for a
non-coding uploader. This helper does the ADR's "drafts the manifest from the
file + a few prompts": it opens the file with the SAME vetted loaders the
validator uses (``scanpy.read_h5ad`` / ``pandas.read_csv`` — never
``exec``/``eval``/``pickle``), infers what it safely can (data level, feature-ID
type, sample/feature counts, candidate grouping columns), and returns a manifest
skeleton plus an explicit ``todo`` list of the fields a human must still confirm.
It does NOT weaken the gate: a drafted manifest is just a starting point that
still has to clear :func:`validate_upload` (which re-checks these same
inferences against the data) and admin registration. It only removes the
blank-page problem, so the honest workflow is:
draft = draft_manifest(path) # inspect + pre-fill
# uploader reviews draft.todo, edits draft.manifest
rec = stage_upload(path, manifest=draft.manifest, ...)
validate_upload(rec) # re-verifies the inferences
A backing UI (local Gradio panel) can wrap this to present ``manifest`` as an
editable form with ``todo`` as the required-fields checklist; the inference is
here so the UI stays a thin shell.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
# detected data_type (microarray classifier) → manifest data_level vocabulary.
_DATA_LEVEL_MAP = {
"raw_counts": "raw_counts",
"log_expression": "log_expression",
"log_ratio_microarray": "log_ratio",
"unknown": None,
}
# detected feature pattern (check_feature_id_type) → manifest feature_id_type.
_FEATURE_MAP = {
"gene_symbol": "gene_symbol",
"ensembl": "ensembl_gene_id",
"entrez": "entrez_id",
"probe_id_or_unknown": None,
}
_TABULAR_SUFFIXES = (".csv", ".tsv", ".txt", ".csv.gz", ".tsv.gz", ".txt.gz")
_MAX_X = 50_000
_TODO = "TODO"
@dataclass
class DraftResult:
"""A drafted manifest plus what still needs a human."""
manifest: dict = field(default_factory=dict)
inferred: dict = field(default_factory=dict) # field → (value, evidence)
todo: list[str] = field(default_factory=list) # required fields still unfilled
notes: list[str] = field(default_factory=list)
def _kind_of(name: str) -> str | None:
low = name.lower()
if low.endswith(".h5ad"):
return "h5ad"
if low.endswith(_TABULAR_SUFFIXES):
return "tabular"
return None
def _cap_flat(X_flat):
import numpy as np
if len(X_flat) > _MAX_X:
return np.random.default_rng(0).choice(X_flat, size=_MAX_X, replace=False)
return X_flat
def _infer_data_level(X_flat) -> tuple[str | None, str]:
from src.workflows.microarray import classify_expression_data_type
info = classify_expression_data_type(X_flat)
detected = info["data_type"]
level = _DATA_LEVEL_MAP.get(detected)
ev = (
f"detected '{detected}' (integer={info['is_integer']}, "
f"min={info['value_min']}, max={info['value_max']})"
)
return level, ev
def _infer_feature_id_type(var_index: list[str]) -> tuple[str | None, str]:
from src.workflows.manifest_data_validation import check_feature_id_type
# declared arg only affects wording; detected_pattern is computed from the data.
res = check_feature_id_type(var_index, "gene_symbol")
pattern = res.get("detected_pattern", "probe_id_or_unknown")
return _FEATURE_MAP.get(pattern), f"var.index looks like '{pattern}' — {res['message']}"
def _candidate_group_columns(obs) -> list[str]:
"""obs columns that look like usable grouping factors (2–12 categories)."""
n = len(obs)
out: list[str] = []
for col in obs.columns:
try:
nu = int(obs[col].astype(str).nunique())
except Exception: # noqa: BLE001
continue
if 2 <= nu <= 12 and nu < n:
out.append(str(col))
return out
def _base_manifest(
dataset_id: str,
*,
organism: str,
modality: str,
data_level: str | None,
feature_id_type: str | None,
group_columns: list[str],
embedded_obs: bool,
title: str | None,
) -> dict:
return {
"dataset_id": dataset_id,
"title": title or f"{_TODO}: descriptive title for {dataset_id}",
"accession": _TODO,
"organism": organism,
"modality": modality,
"platform": _TODO,
"data_level": data_level or _TODO,
"feature_id_type": feature_id_type or _TODO,
"expression_source": {"type": "local"},
"metadata_source": {"type": "local", "embedded": embedded_obs},
"group_columns": group_columns,
"valid_workflows": [],
"limitations": [
"Manifest auto-drafted from the uploaded file — review every field before registering.",
],
}
def draft_manifest(
path: str | Path,
*,
dataset_id: str | None = None,
title: str | None = None,
organism: str = "human",
) -> DraftResult:
"""Inspect an upload and return a pre-filled manifest skeleton + a todo list.
Parameters mirror the few prompts a UI would ask (``dataset_id``, ``title``,
``organism``); everything else is inferred from the file or left as ``TODO``.
Raises ``ValueError`` for an unsupported file type or an unreadable file — the
caller surfaces that to the uploader.
"""
path = Path(path)
kind = _kind_of(path.name)
if kind is None:
raise ValueError(
f"Cannot draft a manifest for '{path.name}': supported types are "
".h5ad and flat matrices (.csv/.tsv/.txt, optionally .gz)."
)
dataset_id = dataset_id or _slug(path.name)
inferred: dict[str, Any] = {}
notes: list[str] = []
if kind == "h5ad":
import scanpy as sc
adata = sc.read_h5ad(path) # vetted loader — never exec
X = adata.X
if hasattr(X, "toarray"):
X = X.toarray()
X_flat = _cap_flat(X.flatten())
var_index = [str(v) for v in adata.var.index]
data_level, dl_ev = _infer_data_level(X_flat)
feat, feat_ev = _infer_feature_id_type(var_index)
group_cols = _candidate_group_columns(adata.obs)
n_samples, n_features = int(adata.n_obs), int(adata.n_vars)
modality = "sc_rnaseq" if n_samples > 2000 else "bulk_rnaseq"
notes.append(
f"modality guessed '{modality}' from {n_samples} obs — confirm "
"(single-cell vs bulk changes the analysis path)."
)
inferred["group_columns"] = (
group_cols,
f"low-cardinality obs columns among {list(adata.obs.columns)}",
)
embedded_obs = True
else:
import pandas as pd
name = path.name.lower()
base = name[:-3] if name.endswith(".gz") else name
sep = "\t" if base.endswith((".tsv", ".txt")) else ","
df = pd.read_csv(path, index_col=0, sep=sep) # vetted reader — never exec
X_flat = _cap_flat(df.to_numpy(dtype=float).flatten())
var_index = [str(c) for c in df.columns]
data_level, dl_ev = _infer_data_level(X_flat)
feat, feat_ev = _infer_feature_id_type(var_index)
group_cols = []
n_samples, n_features = int(df.shape[0]), int(df.shape[1])
modality = "bulk_rnaseq"
notes.append(
"Flat matrix has no sample metadata — group_columns left empty. "
"Re-upload as .h5ad with embedded obs to draft grouping/contrasts."
)
notes.append("Assumed orientation: samples as rows, genes as columns (index_col=0).")
embedded_obs = False
inferred["data_level"] = (data_level, dl_ev)
inferred["feature_id_type"] = (feat, feat_ev)
inferred["n_samples"] = (n_samples, "")
inferred["n_features"] = (n_features, "")
manifest = _base_manifest(
dataset_id,
organism=organism,
modality=modality,
data_level=data_level,
feature_id_type=feat,
group_columns=group_cols,
embedded_obs=embedded_obs,
title=title,
)
# Everything still needing a human: unfilled required fields + workflows.
todo = [k for k, v in manifest.items() if v == _TODO]
if not manifest["valid_workflows"]:
todo.append("valid_workflows")
if not manifest["group_columns"] and kind == "h5ad":
notes.append("No obvious grouping column found — set group_columns manually.")
return DraftResult(manifest=manifest, inferred=inferred, todo=todo, notes=notes)
def _slug(filename: str) -> str:
stem = filename.lower()
for suf in (".csv.gz", ".tsv.gz", ".txt.gz", ".h5ad", ".csv", ".tsv", ".txt"):
if stem.endswith(suf):
stem = stem[: -len(suf)]
break
keep = [c if (c.isalnum() or c == "_") else "_" for c in stem]
slug = "".join(keep).strip("_") or "uploaded_dataset"
return slug