hca-anndata-tools 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: hca-anndata-tools
3
+ Version: 0.2.0
4
+ Summary: Library for inspection, summarization, and statistics of AnnData h5ad files
5
+ License: MIT
6
+ Author: Clever Canary
7
+ Author-email: dave@clevercanary.com
8
+ Requires-Python: >=3.10,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Dist: anndata (>=0.10,<1)
17
+ Requires-Dist: h5py (>=3.8)
18
+ Requires-Dist: matplotlib (>=3.7)
19
+ Requires-Dist: numpy (<3)
20
+ Requires-Dist: pandas (>2,<3)
21
+ Requires-Dist: pydantic (>=2,<3)
22
+ Requires-Dist: scanpy (>=1.10)
23
+ Project-URL: Homepage, https://github.com/clevercanary/hca-validation-tools
24
+ Description-Content-Type: text/markdown
25
+
26
+ # hca-anndata-tools
27
+
28
+ Library for inspection, summarization, and statistics of AnnData h5ad files.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install hca-anndata-tools
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from hca_anndata_tools import get_summary, get_descriptive_stats, locate_files
40
+
41
+ # Find h5ad files
42
+ files = locate_files("/path/to/data")
43
+
44
+ # Get structural overview
45
+ summary = get_summary("/path/to/my_atlas.h5ad")
46
+
47
+ # Get column statistics
48
+ stats = get_descriptive_stats("/path/to/my_atlas.h5ad", columns=["cell_type"], value_counts=True)
49
+ ```
50
+
51
+ ## API
52
+
53
+ - **locate_files** - Find all .h5ad files in a directory
54
+ - **get_summary** - Structural overview: cell/gene counts, columns, embeddings, layers
55
+ - **get_storage_info** - HDF5 compression, chunking, sparse format details
56
+ - **get_descriptive_stats** - Descriptive statistics and value counts for obs/var columns
57
+ - **view_data** - View slices of any attribute (obs, var, X, obsm, uns, etc.)
58
+ - **plot_embedding** - UMAP/PCA scatter plots as base64 PNG
59
+ - **get_cap_annotations** - Inspect CAP cell annotation metadata
60
+
@@ -0,0 +1,34 @@
1
+ # hca-anndata-tools
2
+
3
+ Library for inspection, summarization, and statistics of AnnData h5ad files.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install hca-anndata-tools
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from hca_anndata_tools import get_summary, get_descriptive_stats, locate_files
15
+
16
+ # Find h5ad files
17
+ files = locate_files("/path/to/data")
18
+
19
+ # Get structural overview
20
+ summary = get_summary("/path/to/my_atlas.h5ad")
21
+
22
+ # Get column statistics
23
+ stats = get_descriptive_stats("/path/to/my_atlas.h5ad", columns=["cell_type"], value_counts=True)
24
+ ```
25
+
26
+ ## API
27
+
28
+ - **locate_files** - Find all .h5ad files in a directory
29
+ - **get_summary** - Structural overview: cell/gene counts, columns, embeddings, layers
30
+ - **get_storage_info** - HDF5 compression, chunking, sparse format details
31
+ - **get_descriptive_stats** - Descriptive statistics and value counts for obs/var columns
32
+ - **view_data** - View slices of any attribute (obs, var, X, obsm, uns, etc.)
33
+ - **plot_embedding** - UMAP/PCA scatter plots as base64 PNG
34
+ - **get_cap_annotations** - Inspect CAP cell annotation metadata
@@ -0,0 +1,26 @@
1
+ [tool.poetry]
2
+ name = "hca-anndata-tools"
3
+ version = "0.2.0"
4
+ description = "Library for inspection, summarization, and statistics of AnnData h5ad files"
5
+ authors = ["Clever Canary <dave@clevercanary.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ homepage = "https://github.com/clevercanary/hca-validation-tools"
9
+ packages = [{include = "hca_anndata_tools", from = "src"}]
10
+
11
+ [tool.poetry.dependencies]
12
+ python = "^3.10"
13
+ anndata = ">=0.10,<1"
14
+ pandas = ">2,<3"
15
+ numpy = "<3"
16
+ h5py = ">=3.8"
17
+ scanpy = ">=1.10"
18
+ matplotlib = ">=3.7"
19
+ pydantic = ">=2,<3"
20
+
21
+ [tool.poetry.group.dev.dependencies]
22
+ pytest = "^8.0"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core"]
26
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,49 @@
1
+ """HCA AnnData Tools — inspection, summarization, and statistics for h5ad files."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ __all__ = [
6
+ "locate_files",
7
+ "get_summary",
8
+ "get_descriptive_stats",
9
+ "view_data",
10
+ "get_storage_info",
11
+ "get_cap_annotations",
12
+ "plot_embedding",
13
+ "write_h5ad",
14
+ "strip_timestamp",
15
+ "generate_output_path",
16
+ "generate_timestamp",
17
+ "EDIT_LOG_KEY",
18
+ "resolve_latest",
19
+ "set_uns",
20
+ "list_uns_fields",
21
+ "convert_cellxgene_to_hca",
22
+ ]
23
+
24
+ _LAZY_IMPORTS = {
25
+ "locate_files": ".files",
26
+ "get_summary": ".summary",
27
+ "get_descriptive_stats": ".stats",
28
+ "view_data": ".view",
29
+ "get_storage_info": ".storage",
30
+ "get_cap_annotations": ".cap",
31
+ "plot_embedding": ".plot",
32
+ "write_h5ad": ".write",
33
+ "strip_timestamp": ".write",
34
+ "generate_output_path": ".write",
35
+ "generate_timestamp": ".write",
36
+ "EDIT_LOG_KEY": ".write",
37
+ "resolve_latest": ".write",
38
+ "set_uns": ".edit",
39
+ "list_uns_fields": ".edit",
40
+ "convert_cellxgene_to_hca": ".convert",
41
+ }
42
+
43
+
44
+ def __getattr__(name: str):
45
+ if name in _LAZY_IMPORTS:
46
+ import importlib
47
+ module = importlib.import_module(_LAZY_IMPORTS[name], __name__)
48
+ return getattr(module, name)
49
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,27 @@
1
+ """Internal I/O utilities for AnnData file access."""
2
+
3
+ import gc
4
+ from contextlib import contextmanager
5
+
6
+ import anndata as ad
7
+
8
+
9
+ @contextmanager
10
+ def open_h5ad(path: str, backed: str | None = "r"):
11
+ """Open an h5ad file with automatic cleanup.
12
+
13
+ Args:
14
+ path: Absolute path to an .h5ad file.
15
+ backed: Backing mode. Use "r" for read-only (default), None for full in-memory read.
16
+
17
+ Yields:
18
+ An AnnData object.
19
+ """
20
+ adata = ad.read_h5ad(path, backed=backed)
21
+ try:
22
+ yield adata
23
+ finally:
24
+ if hasattr(adata, "file") and adata.file is not None:
25
+ adata.file.close()
26
+ del adata
27
+ gc.collect()
@@ -0,0 +1,24 @@
1
+ """Shared serialization utilities for converting numpy types to JSON-serializable Python types."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def make_serializable(obj):
7
+ """Recursively convert numpy types to JSON-serializable Python types."""
8
+ if isinstance(obj, (np.integer,)):
9
+ return int(obj)
10
+ if isinstance(obj, (np.floating,)):
11
+ return float(obj)
12
+ if isinstance(obj, (np.bool_,)):
13
+ return bool(obj)
14
+ if isinstance(obj, (np.str_, np.bytes_)):
15
+ return str(obj)
16
+ if isinstance(obj, np.ndarray):
17
+ return obj.tolist()
18
+ if isinstance(obj, bytes):
19
+ return obj.decode("utf-8", errors="replace")
20
+ if isinstance(obj, dict):
21
+ return {k: make_serializable(v) for k, v in obj.items()}
22
+ if isinstance(obj, (list, tuple)):
23
+ return [make_serializable(v) for v in obj]
24
+ return obj
@@ -0,0 +1,162 @@
1
+ """CAP (Cell Annotation Platform) schema inspection for AnnData files."""
2
+
3
+ from ._io import open_h5ad
4
+ from ._serialize import make_serializable as _make_serializable
5
+
6
+ # CAP obs column suffixes per the cell-annotation-schema spec
7
+ _REQUIRED_SUFFIXES = [
8
+ "", # the cell label column itself
9
+ "--cell_fullname",
10
+ "--cell_ontology_exists",
11
+ "--cell_ontology_term_id",
12
+ "--cell_ontology_term",
13
+ "--rationale",
14
+ "--marker_gene_evidence",
15
+ ]
16
+
17
+ _OPTIONAL_SUFFIXES = [
18
+ "--rationale_dois",
19
+ "--canonical_marker_genes",
20
+ "--synonyms",
21
+ "--category_fullname",
22
+ "--category_cell_ontology_term_id",
23
+ "--category_cell_ontology_term",
24
+ "--cell_ontology_assessment",
25
+ "--confidence_score",
26
+ ]
27
+
28
+ _UNS_REQUIRED_KEYS = [
29
+ "cellannotation_schema_version",
30
+ "cellannotation_metadata",
31
+ "title",
32
+ ]
33
+
34
+ _UNS_OPTIONAL_KEYS = [
35
+ "publication_timestamp",
36
+ "publication_version",
37
+ "description",
38
+ "cap_dataset_url",
39
+ "cap_publication_title",
40
+ "cap_publication_description",
41
+ "cap_publication_url",
42
+ "authors_list",
43
+ "hierarchy",
44
+ ]
45
+
46
+
47
+ def _find_annotation_sets(obs_columns: list[str]) -> list[str]:
48
+ """Identify CAP annotation set names from obs columns with -- separator."""
49
+ sets = set()
50
+ for col in obs_columns:
51
+ if "--" in col:
52
+ setname = col.split("--")[0]
53
+ sets.add(setname)
54
+ return sorted(sets)
55
+
56
+
57
+ def _get_unique_values(series, max_values: int = 50) -> list:
58
+ """Get unique values from a series, capped at max_values."""
59
+ uniques = [_make_serializable(v) for v in series.dropna().unique().tolist()]
60
+ if len(uniques) > max_values:
61
+ return uniques[:max_values] + [f"... and {len(uniques) - max_values} more"]
62
+ return uniques
63
+
64
+
65
+ def get_cap_annotations(path: str, annotation_set: str | None = None) -> dict:
66
+ """Inspect CAP (Cell Annotation Platform) annotations in an AnnData file.
67
+
68
+ Detects CAP-formatted annotation sets, lists cell labels with their ontology
69
+ mappings, marker genes, and rationale. Reports schema compliance.
70
+
71
+ Args:
72
+ path: Absolute path to an .h5ad file.
73
+ annotation_set: Specific annotation set name to inspect. If None, lists all sets found.
74
+ """
75
+ try:
76
+ with open_h5ad(path) as adata:
77
+ obs_columns = list(adata.obs.columns)
78
+ annotation_sets = _find_annotation_sets(obs_columns)
79
+
80
+ uns_keys = list(adata.uns.keys())
81
+ uns_present = [k for k in _UNS_REQUIRED_KEYS if k in uns_keys]
82
+ uns_missing = [k for k in _UNS_REQUIRED_KEYS if k not in uns_keys]
83
+ uns_optional_present = [k for k in _UNS_OPTIONAL_KEYS if k in uns_keys]
84
+
85
+ has_cap = len(annotation_sets) > 0 or "cellannotation_metadata" in uns_keys
86
+
87
+ result = {
88
+ "has_cap_annotations": has_cap,
89
+ "annotation_sets": annotation_sets,
90
+ "uns_metadata": {
91
+ "required_present": uns_present,
92
+ "required_missing": uns_missing,
93
+ "optional_present": uns_optional_present,
94
+ },
95
+ }
96
+
97
+ if not has_cap and annotation_set is None:
98
+ return result
99
+
100
+ if "cellannotation_metadata" in uns_keys:
101
+ result["cellannotation_metadata"] = _make_serializable(adata.uns["cellannotation_metadata"])
102
+
103
+ if "authors_list" in uns_keys:
104
+ result["authors_list"] = _make_serializable(adata.uns["authors_list"])
105
+
106
+ sets_to_inspect = []
107
+ if annotation_set:
108
+ if annotation_set in annotation_sets:
109
+ sets_to_inspect = [annotation_set]
110
+ else:
111
+ return {"error": f"Annotation set '{annotation_set}' not found. Available: {annotation_sets}"}
112
+ elif len(annotation_sets) <= 3:
113
+ sets_to_inspect = annotation_sets
114
+
115
+ set_details = {}
116
+ for setname in sets_to_inspect:
117
+ detail = {}
118
+
119
+ required_found = []
120
+ required_missing = []
121
+ for suffix in _REQUIRED_SUFFIXES:
122
+ col = f"{setname}{suffix}" if suffix else setname
123
+ if col in obs_columns:
124
+ required_found.append(col)
125
+ else:
126
+ required_missing.append(col)
127
+
128
+ optional_found = []
129
+ for suffix in _OPTIONAL_SUFFIXES:
130
+ col = f"{setname}{suffix}"
131
+ if col in obs_columns:
132
+ optional_found.append(col)
133
+
134
+ detail["required_columns_present"] = required_found
135
+ detail["required_columns_missing"] = required_missing
136
+ detail["optional_columns_present"] = optional_found
137
+
138
+ if setname in obs_columns:
139
+ vc = adata.obs[setname].value_counts()
140
+ detail["cell_labels"] = {str(k): int(v) for k, v in vc.items()}
141
+
142
+ ont_col = f"{setname}--cell_ontology_term_id"
143
+ if ont_col in obs_columns:
144
+ detail["ontology_terms"] = _get_unique_values(adata.obs[ont_col])
145
+
146
+ marker_col = f"{setname}--marker_gene_evidence"
147
+ if marker_col in obs_columns:
148
+ detail["marker_genes"] = _get_unique_values(adata.obs[marker_col])
149
+
150
+ rat_col = f"{setname}--rationale"
151
+ if rat_col in obs_columns:
152
+ detail["rationale"] = _get_unique_values(adata.obs[rat_col])
153
+
154
+ set_details[setname] = detail
155
+
156
+ if set_details:
157
+ result["set_details"] = set_details
158
+
159
+ return result
160
+
161
+ except Exception as e:
162
+ return {"error": str(e)}
@@ -0,0 +1,136 @@
1
+ """Convert CellxGENE h5ad files to HCA schema format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from datetime import datetime, timezone
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from ._io import open_h5ad
13
+ from ._serialize import make_serializable
14
+ from .write import generate_timestamp, write_h5ad
15
+ from . import __version__
16
+
17
+ # CellxGENE reserved uns keys — moved to cellxgene_source, not deleted
18
+ _CELLXGENE_RESERVED_UNS = ["schema_version", "schema_reference", "citation"]
19
+
20
+ # CellxGENE uns keys that need to be broadcast to obs
21
+ _UNS_TO_OBS = ["organism_ontology_term_id", "organism"]
22
+
23
+
24
+ def _slugify(text: str, max_length: int = 80) -> str:
25
+ """Convert text to a filename-safe slug."""
26
+ slug = text.lower()
27
+ slug = re.sub(r"[^a-z0-9]+", "-", slug)
28
+ slug = slug.strip("-")
29
+ if len(slug) > max_length:
30
+ slug = slug[:max_length].rstrip("-")
31
+ return slug or "untitled"
32
+
33
+
34
+ def convert_cellxgene_to_hca(
35
+ path: str,
36
+ output_dir: str | None = None,
37
+ ) -> dict:
38
+ """Convert a CellxGENE h5ad file to HCA schema format.
39
+
40
+ Preserves cellxgene provenance in uns['cellxgene_source'], broadcasts
41
+ organism from uns to obs, and renames the output from the dataset title.
42
+ Does not add missing HCA-specific fields — wranglers fill those later.
43
+
44
+ Args:
45
+ path: Path to a CellxGENE h5ad file.
46
+ output_dir: Directory for the output file. Defaults to same as source.
47
+
48
+ Returns:
49
+ Dict with 'output_path', 'source', 'title', 'conversions' on success,
50
+ or 'error' on failure.
51
+ """
52
+ try:
53
+ with open_h5ad(path, backed=None) as adata:
54
+ # Verify this is a cellxgene 6.0+ file (organism in uns, single species)
55
+ if "schema_version" not in adata.uns:
56
+ return {"error": "Not a CellxGENE file — uns['schema_version'] is missing"}
57
+
58
+ schema_ver = str(adata.uns["schema_version"]).strip()
59
+ if not schema_ver:
60
+ return {"error": "uns['schema_version'] is empty — cannot determine CellxGENE schema version"}
61
+ major = int(schema_ver.split(".")[0]) if schema_ver[0].isdigit() else 0
62
+ if major < 6:
63
+ return {
64
+ "error": (
65
+ f"CellxGENE schema {schema_ver} is not supported. "
66
+ f"Requires 6.0+ (organism in uns, single species per dataset)."
67
+ )
68
+ }
69
+
70
+ title = adata.uns.get("title", "")
71
+ if not title:
72
+ return {"error": "File has no title in uns — cannot generate output filename"}
73
+
74
+ conversions = []
75
+
76
+ # --- 1. Preserve cellxgene provenance ---
77
+ cellxgene_source = {}
78
+ for key in _CELLXGENE_RESERVED_UNS:
79
+ if key in adata.uns:
80
+ cellxgene_source[key] = make_serializable(adata.uns[key])
81
+ del adata.uns[key]
82
+
83
+ if cellxgene_source:
84
+ adata.uns["cellxgene_source"] = cellxgene_source
85
+ conversions.append(
86
+ f"Moved cellxgene reserved keys to uns['cellxgene_source']: "
87
+ f"{list(cellxgene_source.keys())}"
88
+ )
89
+
90
+ # --- 2. Broadcast organism from uns → obs ---
91
+ for key in _UNS_TO_OBS:
92
+ if key in adata.uns:
93
+ value = adata.uns[key]
94
+ adata.obs[key] = pd.Categorical.from_codes(
95
+ np.zeros(adata.n_obs, dtype=np.int8),
96
+ categories=[value],
97
+ )
98
+ del adata.uns[key]
99
+ conversions.append(f"{key}: uns → obs (broadcast '{value}')")
100
+
101
+ # --- 3. Build output path from title slug ---
102
+ slug = _slugify(title)
103
+ timestamp = generate_timestamp()
104
+ out_filename = f"{slug}-edit-{timestamp}.h5ad"
105
+ directory = output_dir if output_dir is not None else os.path.dirname(path)
106
+ output_path = os.path.join(directory, out_filename)
107
+
108
+ # --- 4. Build edit log entry ---
109
+ entry = {
110
+ "timestamp": datetime.now(timezone.utc).isoformat(),
111
+ "tool": "hca-anndata-tools",
112
+ "tool_version": __version__,
113
+ "operation": "import_cellxgene",
114
+ "description": f"Imported from CellxGENE Discover: {title}",
115
+ "details": {
116
+ "source_schema_version": cellxgene_source.get("schema_version"),
117
+ "source_citation": cellxgene_source.get("citation"),
118
+ "conversions": conversions,
119
+ },
120
+ }
121
+
122
+ # --- 5. Write ---
123
+ result = write_h5ad(adata, path, [entry], output_path=output_path)
124
+
125
+ if "error" in result:
126
+ return result
127
+
128
+ return {
129
+ **result,
130
+ "source": os.path.basename(path),
131
+ "title": title,
132
+ "conversions": conversions,
133
+ }
134
+
135
+ except Exception as e:
136
+ return {"error": str(e)}