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
41 changes: 41 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
version: 2

updates:
# The model stack moves fast and the floors in pyproject.toml are open-ended, so a
# breaking major (transformers 4 -> 5, for instance) reaches users silently today.
# Grouping keeps the noise to one PR per week per ecosystem.
- package-ecosystem: pip
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
model-stack:
patterns:
- torch
- lightning
- timm
- peft
- transformers
- safetensors
- huggingface_hub
scientific-python:
patterns:
- numpy
- pandas
- pillow
dev-tooling:
patterns:
- pytest*
- ruff
- build
- twine

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns:
- "*"
100 changes: 100 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Scoped to tests/ for now. Running this over src/ and examples/ currently
# reports 4 findings and would reformat 8 files, so widening the scope is a
# separate change rather than something buried in this one.
- run: pipx run ruff check tests/
- run: pipx run ruff format --check tests/

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Must stay in sync with `requires-python` in pyproject.toml.
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

# The CPU wheels keep the runner from pulling the ~2.5 GB CUDA build. torch and
# torchvision must be installed together from the same index: timm and lightning
# otherwise pull the default torchvision from PyPI, and the mismatched pair fails
# at import with "operator torchvision::nms does not exist".
- name: Install CPU-only torch
run: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu

- name: Install the package
run: pip install .[test]

# The model weights are gated on the Hugging Face Hub, so CI has no token and
# must never reach for them. Every test runs against the packaged assets or a
# randomly-initialised module, which is what keeps this job runnable on a fork.
- name: Run tests
run: pytest -q
env:
HF_HUB_OFFLINE: "1"

import-check:
# Installs from a built wheel rather than the source tree, so a missing entry in
# package-data (the assets the model reads at import time) fails here.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
- run: pip install build && python -m build
- name: Install the wheel outside the source tree
run: |
pip install dist/*.whl
cd /tmp && python -c "
import deepspotm
from deepspotm.config import config
from deepspotm.modules import StructureExpression
assert config.ALPHABET_PATH.is_file(), 'packaged vocabulary missing from wheel'
assert len(StructureExpression().gene_names_ordered) == 19338
print('wheel import OK:', deepspotm.__all__)
"

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- name: Check distribution metadata
run: twine check --strict dist/*
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
88 changes: 88 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Release

# Publishes to PyPI via Trusted Publishing (OIDC), so no API token is ever stored in
# the repository. Before the first run, register this workflow as a trusted publisher
# at https://pypi.org/manage/account/publishing/ using:
# owner: ratschlab repo: DeepSpotM workflow: release.yml environment: pypi
#
# Tag a release with `git tag v1.0.0 && git push --tags`. The tag must match the
# version in pyproject.toml; the check-version job enforces that.

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
target:
description: Where to publish
required: true
default: testpypi
type: choice
options: [testpypi, pypi]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build twine
- run: python -m build
- run: twine check --strict dist/*

- name: Tag must match the project version
if: startsWith(github.ref, 'refs/tags/v')
run: |
project_version=$(python -c "
import tomllib, pathlib
print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])
")
tag_version="${GITHUB_REF_NAME#v}"
if [ "$project_version" != "$tag_version" ]; then
echo "tag $tag_version does not match pyproject version $project_version"
exit 1
fi

- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

testpypi:
needs: build
if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/deepspotm
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/

pypi:
needs: build
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && inputs.target == 'pypi')
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/deepspotm
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
2 changes: 1 addition & 1 deletion examples/predict_tcga_skcm.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"> released model in zero-shot mode, so the values are illustrative and the maps\n",
"> will look softer than the finetuned atlas.\n",
"\n",
"Requirements are `pip install deepspotm pyvips matplotlib`. pyvips needs the system\n",
"Install with `pip install git+https://github.com/ratschlab/DeepSpotM.git` plus `pip install pyvips matplotlib`. pyvips needs the system\n",
"libvips with OpenSlide support. A GPU is recommended. The model weights are gated on\n",
"the Hugging Face Hub, so request access and log in first with `huggingface-cli login`.\n",
"Note that pyvips must be imported before torch."
Expand Down
58 changes: 52 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,20 +1,45 @@
[build-system]
requires = ["setuptools>=61.0"]
# setuptools>=77 is required for the PEP 639 `license` / `license-files` fields.
requires = ["setuptools>=77.0"]
build-backend = "setuptools.build_meta"

[project]
name = "deepspotm"
version = "1.0.0"
description = "Predicts spatial gene expression from histology images using pathology foundation models"
readme = "README.md"
# Code is released for non-commercial use only; see LICENSE (PolyForm
# Noncommercial 1.0.0). The model WEIGHTS carry a separate CC-BY-NC-SA-4.0
# license — see WEIGHTS_LICENSE.
license = { text = "PolyForm-Noncommercial-1.0.0" }
# Code is released for non-commercial use only (PolyForm Noncommercial 1.0.0).
# The model WEIGHTS carry a separate CC-BY-NC-SA-4.0 license, see WEIGHTS_LICENSE.md.
license = "PolyForm-Noncommercial-1.0.0"
license-files = ["LICENSE", "WEIGHTS_LICENSE.md", "THIRD_PARTY_LICENSES.md"]
authors = [
{ name = "Kalin Nonchev", email = "kalin.nonchev@inf.ethz.ch" }
]
requires-python = ">=3.9"
keywords = [
"spatial-transcriptomics",
"computational-pathology",
"histology",
"gene-expression",
"foundation-model",
"whole-slide-imaging",
]
# torch, transformers, peft and lightning all require >=3.10 in their current
# releases, so a 3.9 install cannot resolve a working dependency set.
requires-python = ">=3.10"

classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Science/Research",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Scientific/Engineering :: Bio-Informatics",
"Topic :: Scientific/Engineering :: Image Recognition",
]

dependencies = [
"torch>=2.0",
Expand All @@ -29,12 +54,33 @@ dependencies = [
"pillow>=9.0",
]

[project.optional-dependencies]
# Extras for examples/predict_wsi.py, which reads slides and writes AnnData.
wsi = [
"pyvips>=2.2",
"anndata>=0.10",
]
test = [
"pytest>=7.0",
]
dev = [
"deepspotm[test,wsi]",
"build>=1.0",
"ruff>=0.6",
"twine>=5.0",
]

[project.urls]
Homepage = "https://github.com/ratschlab/DeepSpotM"
Issues = "https://github.com/ratschlab/DeepSpotM/issues"
Paper = "https://www.medrxiv.org/content/10.64898/2026.06.19.26356060v1"
Weights = "https://huggingface.co/ratschlab/DeepSpotM"

[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
deepspotm = ["assets/*.csv", "assets/*.json"]

[tool.pytest.ini_options]
testpaths = ["tests"]
12 changes: 7 additions & 5 deletions src/deepspotm/config.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
from pathlib import Path
import importlib.resources

from pathlib import Path


class Config:
"""Configuration class for DeepSpotM model."""

# Gene vocabulary
# Gene vocabulary. `files()` replaces `importlib.resources.path()`, which is
# deprecated since Python 3.11 and hands back a context-managed path that is
# released on exit, so the value could outlive the guarantee it was valid.
try:
with importlib.resources.path("deepspotm.assets", "tokens.csv") as p:
ALPHABET_PATH = Path(p)
ALPHABET_PATH = Path(
str(importlib.resources.files("deepspotm.assets") / "tokens.csv")
)
except Exception:
ALPHABET_PATH = Path(__file__).resolve().parent / "assets" / "tokens.csv"

Expand Down
Loading
Loading