File size: 9,685 Bytes
5fb5c7d b83539c 5fb5c7d b83539c 5fb5c7d c3b49d6 5fb5c7d b83539c 5fb5c7d b83539c 5fb5c7d b83539c 5fb5c7d 1f94fe4 5fb5c7d c3b49d6 5fb5c7d b83539c c3b49d6 5fb5c7d b83539c 5fb5c7d c3b49d6 b83539c 5fb5c7d b83539c 5fb5c7d c3b49d6 5fb5c7d b83539c 5fb5c7d c3b49d6 b83539c 5fb5c7d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 | """Validate-before-availability for a quarantined upload (ADR-0011 items 3, 4, 5).
A staged file stays quarantined until it clears the SAME grounding gate a
registered dataset clears:
1. manifest schema validation (src.datasets.manifest_schema.validate_manifest)
2. against-data checks (src.workflows.manifest_data_validation)
Item 4 ("never execute uploaded content") is honoured structurally: the file is
opened ONLY by a vetted reader β ``scanpy.read_h5ad`` for h5ad, ``pandas.read_csv``
for a flat matrix β never ``exec``/``eval``/``pickle``. Passing both gates flips
the record to ``validated``; failing keeps it quarantined with the errors surfaced.
Two file shapes are supported:
- **h5ad** β carries expression AND sample metadata (obs), so the full five-check
``validate_manifest_against_data`` runs (data level, feature IDs, metadata
columns, group columns, contrasts).
- **flat matrix** (``.csv``/``.tsv``/``.txt`` + ``.gz``) β samples as rows, genes
as columns (the convention ``decoupler_inspect_data`` uses). A bare matrix has
no ``obs``, so only the two *file-content* checks that don't need metadata run
(``data_level`` + ``feature_id_type``); the obs-dependent checks are recorded
as skipped **caveats**, not silently passed. This verifies the file is what the
manifest claims (correct units, correct feature IDs) β the security-relevant
part β while stating honestly that grouping/metadata weren't validated.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Any
from src.uploads.records import (
STATUS_QUARANTINED,
STATUS_VALIDATED,
UploadRecord,
persist_record,
)
# Flat-matrix suffixes (compound .gz handled in _kind_of / _read_tabular_matrix).
_TABULAR_SUFFIXES = (".csv", ".tsv", ".txt", ".csv.gz", ".tsv.gz", ".txt.gz")
# Cap the number of expression values fed to the data-level classifier, matching
# the dataset_validate_manifest_against_data tool.
_MAX_X = 50_000
def _report(status: str, errors: list[str], **extra: Any) -> dict:
return {"status": status, "errors": errors, **extra}
def _kind_of(path: str) -> str | None:
"""Return 'h5ad' | 'tabular' | None for a staged file name."""
name = Path(path).name.lower()
if name.endswith(".h5ad"):
return "h5ad"
if name.endswith(_TABULAR_SUFFIXES):
return "tabular"
return None
def _cap_flat(X_flat):
import numpy as np
if len(X_flat) > _MAX_X:
rng = np.random.default_rng(0)
return rng.choice(X_flat, size=_MAX_X, replace=False)
return X_flat
def _h5ad_report(staged_path: str, manifest_obj) -> dict:
"""Full five-check report from an h5ad, via the vetted scanpy loader."""
import scanpy as sc
from src.workflows.manifest_data_validation import validate_manifest_against_data
adata = sc.read_h5ad(staged_path) # vetted loader β never exec
X = adata.X
if hasattr(X, "toarray"):
X = X.toarray()
X_flat = _cap_flat(X.flatten())
return validate_manifest_against_data(
X_flat, list(adata.var.index), adata.obs.copy(), manifest_obj
)
def _tabular_report(staged_path: str, manifest_obj) -> dict:
"""Content-only report from a flat matrix, via the vetted pandas reader.
Samples as rows, genes as columns (index_col=0), matching
``decoupler_inspect_data``. Only the two checks that a bare matrix can support
are run; the obs-dependent checks are surfaced as caveats.
"""
import pandas as pd
from src.workflows.manifest_data_validation import (
check_data_level,
check_feature_id_type,
)
name = Path(staged_path).name.lower()
base = name[:-3] if name.endswith(".gz") else name
sep = "\t" if base.endswith((".tsv", ".txt")) else ","
# pandas infers .gz decompression from the filename. read_csv is a safe,
# vetted reader β no code execution (unlike read_pickle).
df = pd.read_csv(staged_path, index_col=0, sep=sep)
X = df.to_numpy(dtype=float) # raises if the matrix isn't purely numeric
X_flat = _cap_flat(X.flatten())
var_index = [str(c) for c in df.columns]
checks = {
"data_level": check_data_level(X_flat, manifest_obj.data_level),
"feature_id_type": check_feature_id_type(var_index, manifest_obj.feature_id_type),
}
errors = [
f"[{name}] {c.get('message', '')}"
for name, c in checks.items()
if c.get("status") == "error"
]
warnings = [
f"[{name}] {c.get('message', '')}"
for name, c in checks.items()
if c.get("status") == "warning"
]
skipped = ["metadata_columns", "group_columns", "default_contrasts"]
caveats = [
"Flat matrix carries no sample metadata (obs), so the obs-dependent checks "
f"({', '.join(skipped)}) were NOT run β re-upload as an .h5ad with embedded "
"obs to validate grouping and contrasts.",
"Orientation assumed samples-as-rows, genes-as-columns (index_col=0); if the "
"feature_id_type check flags a mismatch, the matrix may be transposed.",
]
return {
"dataset_id": manifest_obj.dataset_id,
"n_samples": int(df.shape[0]),
"n_features": int(df.shape[1]),
"overall_valid": len(errors) == 0,
"n_errors": len(errors),
"n_warnings": len(warnings),
"checks": checks,
"errors": errors,
"warnings": warnings,
"skipped_checks": skipped,
"caveats": caveats,
}
def validate_upload(record: UploadRecord) -> tuple[UploadRecord, dict]:
"""Run the manifest-schema + against-data gate on a quarantined upload.
Returns ``(record, report)``. The record's ``status`` becomes ``validated``
only when both gates pass; otherwise it stays ``quarantined`` and the reasons
are in ``record.errors`` / ``report['errors']``. A flat-matrix upload that
validates carries ``record.caveats`` naming the checks that could not run.
"""
if record.status != STATUS_QUARANTINED:
return record, _report(
"error",
[f"Upload is '{record.status}', not 'quarantined' β nothing to validate."],
)
if not record.staged_path or not Path(record.staged_path).is_file():
record.errors = [f"Staged file missing: {record.staged_path}"]
persist_record(record)
return record, _report("error", record.errors)
# ββ Gate 0: content scan must run and clear before validation ββββββββββββ
# ADR-0011: "the optional malware scan runs on the staged object *before*
# validation." The structural magic-byte check is unconditional; the AV pass
# is best-effort (skipped-with-caveat unless UPLOAD_SCAN_REQUIRED). An
# infected/disguised file never reaches the loaders below.
from src.uploads.scanning import SCAN_INFECTED, scan_upload
if record.scan_status is None:
record, scan_report = scan_upload(record)
if record.scan_status == SCAN_INFECTED:
return record, _report("error", record.errors, scan=record.scan_detail)
scan_caveats = list(record.caveats) # a skipped-AV caveat, if any
# ββ Gate 1: manifest schema validity βββββββββββββββββββββββββββββββββββββ
from src.datasets.manifest_schema import DatasetManifest, validate_manifest
schema_result = validate_manifest(record.manifest)
if not schema_result.valid:
record.errors = [f"manifest schema: {e}" for e in schema_result.errors]
persist_record(record)
return record, _report("error", record.errors, schema_warnings=schema_result.warnings)
manifest_obj = DatasetManifest.from_dict(record.manifest)
# ββ Gate 2: against-data validation via the VETTED reader only (item 4) ββ
kind = _kind_of(record.staged_path)
if kind is None:
record.errors = [f"Unsupported file type for validation: {Path(record.staged_path).name}."]
persist_record(record)
return record, _report("error", record.errors)
try:
if kind == "h5ad":
data_report = _h5ad_report(record.staged_path, manifest_obj)
else:
data_report = _tabular_report(record.staged_path, manifest_obj)
except Exception as exc: # noqa: BLE001 β a load/validate failure is a rejection
record.errors = [f"against-data validation failed to run: {exc}"]
persist_record(record)
return record, _report("error", record.errors)
caveats = scan_caveats + [c for c in data_report.get("caveats", []) if c not in scan_caveats]
if not data_report.get("overall_valid"):
record.errors = list(data_report.get("errors", [])) or [
"against-data validation reported the manifest inconsistent with the file."
]
record.caveats = caveats
persist_record(record)
return record, _report(
"error",
record.errors,
against_data=data_report,
schema_warnings=schema_result.warnings,
)
# ββ Passed both gates ββββββββββββββββββββββββββββββββββββββββββββββββββββ
record.status = STATUS_VALIDATED
record.validated_at = datetime.now().isoformat(timespec="seconds")
record.errors = []
record.caveats = caveats
persist_record(record)
return record, _report(
"pass",
[],
against_data=data_report,
schema_warnings=schema_result.warnings,
caveats=caveats,
)
|