Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,28 @@ on:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11' # 你的项目要求 >=3.8,可按需扩展测试矩阵
python-version: ${{ matrix.python-version }}

- run: python -m pip install --upgrade pip
- run: python -m pip install -e ".[dev,docs]"
- run: pytest -q

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade pip
- run: python -m pip install -e ".[dev,docs]"
- run: sphinx-build -b html docs docs/_build/html -W --keep-going
15 changes: 0 additions & 15 deletions .github/workflows/test.yml

This file was deleted.

6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pyXenium is a Python toolkit for **10x Genomics Xenium** organized around nine m
| Contour Geometry | `pyXenium.contour` | [Contour tutorial hub](https://pyxenium.readthedocs.io/en/latest/tutorials/contour_index.html) |
| GMI Inference | `pyXenium.gmi` | [Contour GMI guide](https://pyxenium.readthedocs.io/en/latest/guides/gmi-contour.html) |
| Mechanostress | `pyXenium.mechanostress` | [Mechanostress tutorial](https://pyxenium.readthedocs.io/en/latest/tutorials/mechanostress_atera_pdc.html) |
| AI-Driven Spatial Pathologist | `pyXenium.spatho` | [RTD bridge guide](https://pyxenium.readthedocs.io/en/latest/tutorials/ai_driven_spatial_pathologist.html) |
| AI-Driven Spatial Pathologist | external `spatho` package + `pyXenium.build_spatho_manifest` | [RTD bridge guide](https://pyxenium.readthedocs.io/en/latest/tutorials/ai_driven_spatial_pathologist.html) |
| SpatialPerturb Bridge | `pyXenium.perturb` | [SpatialPerturb bridge guide](https://pyxenium.readthedocs.io/en/latest/tutorials/spatialperturb_bridge.html) |

Legacy compatibility entry points under `pyXenium.analysis`, `pyXenium.validation`, and
Expand All @@ -57,7 +57,7 @@ and run separately; pyXenium does not vendor them or add them as core runtime de

## Release & Build

- Current repository version: `0.4.5`
- Current repository version: `0.4.6`
- Package index: [PyPI](https://pypi.org/project/pyXenium/)
- Documentation site: [pyxenium.readthedocs.io](https://pyxenium.readthedocs.io/en/latest/)
- Canonical build status: [GitHub Actions CI](https://github.com/hutaobo/pyXenium/actions/workflows/ci.yml)
Expand All @@ -68,7 +68,7 @@ and run separately; pyXenium does not vendor them or add them as core runtime de
## Install

```bash
pip install pyXenium==0.4.5
pip install pyXenium==0.4.6
```

For local development:
Expand Down
11 changes: 11 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ All notable changes to pyXenium are documented here.

## Unreleased

## 0.4.6 - 2026-05-20

- Add `pyXenium.perturb` as the lightweight SpatialPerturb Bridge for optional Perturb-seq reference projection handoffs without adding a core runtime dependency.
- Document AI-Driven Spatial Pathologist via the external `spatho` package as an optional workflow bridge, without adding new pyXenium runtime code or dependencies.
- Packaging cleanup: stop shipping `_vendor/Gmi/.github/**` workflow files inside the wheel; narrow `_vendor/Gmi/*` `package-data` to the R sources actually consumed at runtime.
- Pin `aiohttp>=3.9` (previously unpinned) to match the rest of the runtime-dependency hygiene.
- Tighten supported Python range to `>=3.11` so classifiers, runtime, and CI matrix agree with the floor set by `zarr>=3.1` (zarr 3.x has never shipped a wheel for Python 3.10).
- Expand CI Python matrix to `3.11`–`3.13` and remove the duplicate `test.yml` workflow.
- Fix bare `except:` in `multimodal.analysis.differential.get_rna_expr_df` so non-`AttributeError` failures surface.
- Plug fsspec handle leaks in `io.xenium_artifacts.open_text` (release on TextIOWrapper/GzipFile construction error) and in `read_cell_feature_matrix_h5` (close the fsspec fileobj when `h5py.File(fileobj, ...)` fails before falling back to a local re-open).
- Seed `np.random.random` in the SCILD CCI adapter; benchmark runs are now reproducible via a `seed` kwarg.
- Add `logger.warning(...)` / `logger.debug(...)` to 15 silent `except Exception:` sites across `multimodal/histoseg_lazyslide.py`, `multimodal/immune_resistance.py`, and `_topology_core.safe_to_parquet`.
- README: replace the self-contradicting `pyXenium.spatho` row in the features table with the actual entry point (`build_spatho_manifest` + external `spatho` package).

## 0.4.5 - 2026-05-08

Expand Down
10 changes: 2 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "pyXenium"
dynamic = ["version"]
description = "Xenium I/O, multimodal analysis, topology workflows, contour-native spatial profiling, GMI inference, mechanostress analysis, and optional external workflow bridges."
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.11"
license = "AGPL-3.0-only"
license-files = ["LICENSE"]
Comment on lines 8 to 12
authors = [{ name = "Taobo Hu" }]
Expand All @@ -20,9 +20,6 @@ classifiers = [
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
Expand All @@ -47,7 +44,7 @@ dependencies = [
"requests>=2.31",
"PyYAML>=6.0",
"pyarrow>=14.0",
"aiohttp",
"aiohttp>=3.9",
"click>=8.1",
]

Expand Down Expand Up @@ -106,9 +103,6 @@ pyXenium = [
"datasets/toy_slide/*.zip",
"notebooks/*.ipynb",
"_vendor/Gmi/*",
"_vendor/Gmi/.*",
"_vendor/Gmi/.github/*",
"_vendor/Gmi/.github/workflows/*",
"_vendor/Gmi/R/*",
"_vendor/Gmi/man/*",
"_vendor/Gmi/man/figures/*",
Expand Down
6 changes: 5 additions & 1 deletion src/pyXenium/_topology_core.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import logging
import math
import warnings
from pathlib import Path
Expand All @@ -16,6 +17,8 @@
from scipy.sparse import issparse
from sklearn.neighbors import NearestNeighbors

logger = logging.getLogger(__name__)


def ensure_output_dir(output_dir: Optional[str | Path]) -> Optional[Path]:
if output_dir is None:
Expand Down Expand Up @@ -1078,7 +1081,8 @@ def safe_to_parquet(df: pd.DataFrame, path: Path) -> bool:
try:
df.to_parquet(path, index=False)
return True
except Exception:
except Exception as exc:
logger.warning("safe_to_parquet failed for %s: %s", path, exc)
return False


Expand Down
8 changes: 5 additions & 3 deletions src/pyXenium/benchmarking/cci_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1763,7 +1763,9 @@ def run_scild_adapter(spec: MethodRunSpec) -> dict[str, Any]:
_prepend_sys_path(source_dir)
from Models.SCILD_main import SCILD # type: ignore

np.random.seed(0)
seed_value = int(os.environ.get("SCILD_SEED", "0"))
np.random.seed(seed_value)
rng = np.random.default_rng(seed_value)
cci_database = _scild_cci_database(cci_resource)
niter_max = int(os.environ.get("SCILD_NITER_MAX", "100" if spec.phase == "full" else "20"))
scild = SCILD(
Expand All @@ -1776,8 +1778,8 @@ def run_scild_adapter(spec: MethodRunSpec) -> dict[str, Any]:
plot_error=False,
)
scild.preparing()
mu0 = np.random.random(scild.nl * scild.ns).reshape(-1, 1)
v0 = np.random.random(scild.nr * scild.ns).reshape(-1, 1)
mu0 = rng.random(scild.nl * scild.ns).reshape(-1, 1)
v0 = rng.random(scild.nr * scild.ns).reshape(-1, 1)
scild.solving_optimization(mu0, v0)

labels, _, one_hot = _celltype_one_hot(adata.obs["cell_type"].astype(str).to_numpy())
Expand Down
19 changes: 16 additions & 3 deletions src/pyXenium/io/xenium_artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,16 @@ def exists(path_or_url: str) -> bool:

def open_text(path_or_url: str):
handle = fsspec.open(path_or_url, mode="rb").open()
if str(path_or_url).endswith(".gz"):
return io.TextIOWrapper(gzip.GzipFile(fileobj=handle), encoding="utf-8")
return io.TextIOWrapper(handle, encoding="utf-8")
try:
if str(path_or_url).endswith(".gz"):
return io.TextIOWrapper(gzip.GzipFile(fileobj=handle), encoding="utf-8")
return io.TextIOWrapper(handle, encoding="utf-8")
except Exception:
try:
handle.close()
except Exception:
pass
raise


def ensure_local_path(
Expand Down Expand Up @@ -615,6 +622,12 @@ def read_cell_feature_matrix_h5(h5_path: str) -> tuple[sparse.csr_matrix, pd.Dat
handle = h5py.File(fileobj, "r")
managed = True
except Exception:
if fileobj is not None:
try:
fileobj.close()
except Exception:
pass
fileobj = None
handle = h5py.File(h5_path, "r")

group = handle.get("X") or handle.get("matrix") or handle.get("cell_feature_matrix")
Expand Down
2 changes: 1 addition & 1 deletion src/pyXenium/multimodal/analysis/differential.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def get_rna_expr_df(adata, layer_key="rna"):
raise KeyError(f"adata.layers does not have {layer_key}")
try:
arr = expr.toarray()
except:
except AttributeError:
arr = expr
return pd.DataFrame(arr, index=adata.obs.index, columns=adata.var.index)

Expand Down
46 changes: 29 additions & 17 deletions src/pyXenium/multimodal/histoseg_lazyslide.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
import subprocess
import time
from collections.abc import Mapping, Sequence
Expand All @@ -25,6 +26,8 @@

from .contour_boundary_ecology import score_contour_boundary_programs

logger = logging.getLogger(__name__)

__all__ = [
"DEFAULT_LAZYSLIDE_TEXT_TERMS",
"DEFAULT_RELATIVE_PROMPT_AXES",
Expand Down Expand Up @@ -2125,8 +2128,8 @@ def _load_lazyslide_annotations(zs: Any, wsi: Any, annotations: Any, *, key_adde
try:
zs.io.load_annotations(wsi, annotations=annotations, key_added=key_added)
return
except Exception:
pass
except Exception as exc:
logger.debug("lazyslide load_annotations fallback (key=%s): %s", key_added, exc)
if not hasattr(wsi, "shapes"):
raise RuntimeError("Could not attach HistoSeg annotations to WSIData.")
wsi.shapes[key_added] = annotations
Expand All @@ -2138,7 +2141,8 @@ def _limit_lazyslide_tiles(wsi: Any, *, tile_key: str, max_tiles: int) -> None:
try:
frame = wsi.shapes[tile_key]
wsi.shapes[tile_key] = frame.iloc[:max_tiles].copy()
except Exception:
except Exception as exc:
logger.debug("limit_lazyslide_tiles skipped for %s: %s", tile_key, exc)
return


Expand Down Expand Up @@ -2246,7 +2250,8 @@ def _try_lazyslide_spatial_domain(zs: Any, wsi: Any, *, model: str, tile_key: st
feature_key = f"{_slug(model)}_{tile_key}"
zs.tl.spatial_features(wsi, feature_key, tile_key=tile_key)
zs.tl.spatial_domain(wsi, feature_key=feature_key, tile_key=tile_key)
except Exception:
except Exception as exc:
logger.warning("lazyslide spatial_domain skipped for model=%s tile_key=%s: %s", model, tile_key, exc)
return


Expand All @@ -2271,7 +2276,8 @@ def _extract_lazyslide_tile_features(
def _fetch_wsi_shape_frame(wsi: Any, key: str) -> pd.DataFrame:
try:
frame = wsi.shapes[key]
except Exception:
except Exception as exc:
logger.debug("fetch_wsi_shape_frame missing shape %s: %s", key, exc)
return pd.DataFrame(columns=["tile_id"])
output = pd.DataFrame(frame).copy()
if "tile_id" not in output.columns:
Expand All @@ -2286,15 +2292,15 @@ def _fetch_wsi_table(wsi: Any, key: str) -> ad.AnnData:
value = wsi[key]
if isinstance(value, ad.AnnData):
return value
except Exception:
pass
except Exception as exc:
logger.debug("fetch_wsi_table direct lookup failed for %s: %s", key, exc)
if hasattr(wsi, "fetch") and hasattr(wsi.fetch, "features_anndata"):
try:
value = wsi.fetch.features_anndata(key)
if isinstance(value, ad.AnnData):
return value
except Exception:
pass
except Exception as exc:
logger.debug("fetch_wsi_table features_anndata fallback failed for %s: %s", key, exc)
raise KeyError(f"Could not fetch LazySlide feature table {key!r}.")


Expand Down Expand Up @@ -2322,7 +2328,8 @@ def _fetch_optional_text_similarity(
for key in candidates:
try:
adata = _fetch_wsi_table(wsi, key)
except Exception:
except Exception as exc:
logger.debug("text similarity candidate %s not available: %s", key, exc)
continue
matrix = adata.X.toarray() if sparse.issparse(adata.X) else np.asarray(adata.X)
terms = list(text_terms)
Expand Down Expand Up @@ -2540,10 +2547,12 @@ def _cell_indices_within_geometry(
try:
candidate_indices = np.asarray(point_tree.query(geometry, predicate="covers"), dtype=int)
return np.sort(candidate_indices)
except Exception:
except Exception as exc:
logger.debug("STRtree predicate='covers' unsupported, falling back: %s", exc)
try:
candidate_indices = np.asarray(point_tree.query(geometry), dtype=int)
except Exception:
except Exception as inner_exc:
logger.warning("STRtree query failed, falling back to brute force: %s", inner_exc)
candidate_indices = np.arange(len(cell_points), dtype=int)
if candidate_indices.size == 0:
return np.asarray([], dtype=int)
Expand Down Expand Up @@ -3460,8 +3469,8 @@ def _spatial_cv_groups(frame: pd.DataFrame) -> tuple[pd.Series | None, str]:
groups = (x_bin.astype(str) + "_" + y_bin.astype(str)).rename("spatial_block")
if groups.nunique() >= 3:
return groups, "spatial_block_group_kfold"
except Exception:
pass
except Exception as exc:
logger.debug("spatial CV grouping failed, falling back to kfold: %s", exc)
return None, "kfold"


Expand All @@ -3477,7 +3486,8 @@ def _cross_validated_r2(
from sklearn.model_selection import GroupKFold, KFold
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
except Exception:
except Exception as exc:
logger.warning("sklearn imports unavailable for cross-validated R^2: %s", exc)
return np.nan

x_values = x.to_numpy(dtype=float)
Expand Down Expand Up @@ -3669,7 +3679,8 @@ def _git_commit() -> str | None:
text=True,
timeout=5,
)
except Exception:
except Exception as exc:
logger.debug("git rev-parse HEAD unavailable: %s", exc)
return None
return result.stdout.strip() or None

Expand All @@ -3683,7 +3694,8 @@ def _gpu_summary() -> dict[str, Any]:
text=True,
timeout=5,
)
except Exception:
except Exception as exc:
logger.debug("nvidia-smi unavailable: %s", exc)
return {"available": False}
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return {"available": bool(lines), "devices": lines}
Expand Down
6 changes: 5 additions & 1 deletion src/pyXenium/multimodal/immune_resistance.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass

Expand All @@ -14,6 +15,8 @@

from pyXenium.utils.name_resolver import resolve_protein_column

logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class MarkerPair:
Expand Down Expand Up @@ -202,7 +205,8 @@ def _protein_vector(adata: AnnData, protein: str, *, protein_df: pd.DataFrame |
protein_df = _protein_frame(adata) if protein_df is None else protein_df
try:
resolved = resolve_protein_column(adata, protein, "protein_norm", "protein")
except Exception:
except Exception as exc:
logger.debug("resolve_protein_column failed for %r, falling back to raw name: %s", protein, exc)
resolved = protein
if resolved not in protein_df.columns:
return None
Expand Down
Loading