diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b068616..7d6f7c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index a5c9611..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: tests -on: [push, pull_request] -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - run: python -m pip install -U pip - - run: pip install -e ".[dev,docs]" - - run: pytest -q - - run: python -m build - - run: sphinx-build -b html docs docs/_build/html -W --keep-going diff --git a/README.md b/README.md index 06e8b3a..543e90f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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: diff --git a/docs/changelog.md b/docs/changelog.md index 0baa1b9..3708a5f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index f2a284b..e8f1f09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] authors = [{ name = "Taobo Hu" }] @@ -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", @@ -47,7 +44,7 @@ dependencies = [ "requests>=2.31", "PyYAML>=6.0", "pyarrow>=14.0", - "aiohttp", + "aiohttp>=3.9", "click>=8.1", ] @@ -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/*", diff --git a/src/pyXenium/_topology_core.py b/src/pyXenium/_topology_core.py index e4f69ef..f82c004 100644 --- a/src/pyXenium/_topology_core.py +++ b/src/pyXenium/_topology_core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import math import warnings from pathlib import Path @@ -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: @@ -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 diff --git a/src/pyXenium/benchmarking/cci_adapters.py b/src/pyXenium/benchmarking/cci_adapters.py index 30cf85e..f67f77c 100644 --- a/src/pyXenium/benchmarking/cci_adapters.py +++ b/src/pyXenium/benchmarking/cci_adapters.py @@ -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( @@ -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()) diff --git a/src/pyXenium/io/xenium_artifacts.py b/src/pyXenium/io/xenium_artifacts.py index dee9a48..3013f8c 100644 --- a/src/pyXenium/io/xenium_artifacts.py +++ b/src/pyXenium/io/xenium_artifacts.py @@ -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( @@ -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") diff --git a/src/pyXenium/multimodal/analysis/differential.py b/src/pyXenium/multimodal/analysis/differential.py index 77a2c2e..bb0b606 100644 --- a/src/pyXenium/multimodal/analysis/differential.py +++ b/src/pyXenium/multimodal/analysis/differential.py @@ -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) diff --git a/src/pyXenium/multimodal/histoseg_lazyslide.py b/src/pyXenium/multimodal/histoseg_lazyslide.py index a3fb49b..4d9f24d 100644 --- a/src/pyXenium/multimodal/histoseg_lazyslide.py +++ b/src/pyXenium/multimodal/histoseg_lazyslide.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import subprocess import time from collections.abc import Mapping, Sequence @@ -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", @@ -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 @@ -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 @@ -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 @@ -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: @@ -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}.") @@ -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) @@ -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) @@ -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" @@ -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) @@ -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 @@ -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} diff --git a/src/pyXenium/multimodal/immune_resistance.py b/src/pyXenium/multimodal/immune_resistance.py index 1a50d8f..d2e7ce4 100644 --- a/src/pyXenium/multimodal/immune_resistance.py +++ b/src/pyXenium/multimodal/immune_resistance.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -14,6 +15,8 @@ from pyXenium.utils.name_resolver import resolve_protein_column +logger = logging.getLogger(__name__) + @dataclass(frozen=True) class MarkerPair: @@ -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