Skip to content
Open
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
123 changes: 123 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
name: Tests

on:
workflow_call:
inputs:
ref:
description: Exact commit to validate
required: false
type: string
push:
branches: [main, v2.0]
paths-ignore:
- "*.md"
- "LICENSE"
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test-locked:
name: locked py${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.4"
python-version: ${{ matrix.python-version }}

- name: Check lockfile
run: uv lock --check

- name: Install test environment
run: uv sync --locked --extra test --extra dev

- name: Verify Python version
run: >
uv run --no-sync python -c
"import sys; v = sys.version_info;
expected = tuple(map(int, '${{ matrix.python-version }}'.split('.')));
assert (v.major, v.minor) == expected, sys.version;
assert v.releaselevel == 'final', sys.version;
print(sys.version)"

- name: Require nonzero test collection
run: uv run --no-sync pytest --collect-only -q tests

- name: Run tests
run: uv run --no-sync pytest -q --cov --cov-branch --cov-report=term-missing tests

package:
name: package inspection
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.4"
python-version: "3.11"

- name: Install inspection environment
run: uv sync --locked --extra test

- name: Build distributions
run: uv build

- name: Inspect distributions
run: uv run --no-sync python scripts/inspect_distribution.py dist

- name: Test installed wheel
run: |
uv venv --clear .wheel-venv --python 3.11
uv pip install --python .wheel-venv/bin/python dist/*.whl
.wheel-venv/bin/python -c "from importlib.metadata import entry_points; eps = [ep for ep in entry_points(group='copick.inference.commands') if ep.name == 'easymode']; assert len(eps) == 1; eps[0].load()"
.wheel-venv/bin/copick inference easymode --help

tagged-easymode:
name: tagged easymode compatibility
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.sha }}

- name: Install uv
uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0
with:
version: "0.12.4"
python-version: "3.12"

- name: Install locked runtime
run: uv sync --locked --extra test

- name: Install immutable easymode source without its conflicting dependencies
run: >
uv pip install --python .venv/bin/python --no-deps
"easymode @ git+https://github.com/mgflast/easymode.git@a42377e0b887364050bf47c63700a3dd1c0fa0d0"

- name: Verify tagged private inference boundary
run: >
uv run --no-sync python -c
"from copick_easymode.core.easymode_adapter import get_inference_functions;
functions = get_inference_functions();
assert callable(functions.pad_volume);
assert callable(functions.segment_tomogram_instance)"
29 changes: 14 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,22 @@ If you use this plugin, please cite the easymode preprint (see [Citation](#citat

## Installation

easymode 1.0.0 is not published to PyPI (only older `0.0.x` releases are), so it is installed
from GitHub. Its packaging metadata also (incorrectly) pins `numpy<2` / `tensorflow<2.12`, which
conflicts with copick's `numpy>=2` — even though easymode runs fine on `numpy>=2`. To avoid that
conflict, install copick-easymode and its dependencies **first** (this brings in copick and
`numpy>=2`), then install easymode from GitHub with `--no-deps` so its bad pins are ignored:
The `v2.0` development line targets copick 2, OME-Zarr 0.5, and Zarr v3. It supports Python
3.11-3.13 and reads legacy OME-Zarr 0.4 / Zarr v2 projects through copick's compatibility layer.

easymode 1.0.0 is not published to PyPI, and its source metadata pins versions that conflict
with copick's NumPy 2 stack. Install this repository and its dependencies first, then install the
immutable easymode 1.0.0 source revision with `--no-deps`:

```bash
git clone https://github.com/copick/copick-easymode.git
git clone --branch v2.0 https://github.com/copick/copick-easymode.git
cd copick-easymode

# 1. Install copick-easymode + dependencies (copick, numpy>=2, tensorflow>=2.16, easymode's runtime deps)
# 1. Install copick-easymode + dependencies (copick 2 alpha, NumPy 2, TensorFlow 2.20+).
pip install -e .

# 2. Install easymode from GitHub WITHOUT dependency resolution.
# --no-deps keeps your numpy>=2 stack intact, and also upgrades over any older easymode
# (e.g. a 0.0.x already installed from PyPI).
pip install --no-deps git+https://github.com/mgflast/easymode.git
# 2. Install the audited easymode-1.0.0 commit WITHOUT dependency resolution.
pip install --no-deps "easymode @ git+https://github.com/mgflast/easymode.git@a42377e0b887364050bf47c63700a3dd1c0fa0d0"
```

Verify the install:
Expand Down Expand Up @@ -124,11 +123,11 @@ Each segmentation is stored as a zarr array with OME-Zarr metadata.

## Requirements

- Python >= 3.10, < 3.13
- copick >= 1.24.1
- Python >= 3.11, < 3.14
- copick >= 2.0.0a1, < 3
- numpy >= 2.0.2
- TensorFlow >= 2.16
- easymode (installed separately from GitHub — see [Installation](#installation))
- TensorFlow >= 2.20, < 3
- easymode 1.0.0 commit `a42377e0b887364050bf47c63700a3dd1c0fa0d0` (installed separately; see [Installation](#installation))

## Citation

Expand Down
37 changes: 32 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ build-backend = "hatchling.build"

[project]
name = "copick-easymode"
requires-python = ">=3.9,<3.12"
requires-python = ">=3.11,<3.14"
classifiers = [
"Development Status :: 3 - Alpha",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Programming Language :: Python",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
]
dynamic = ["version"]
dependencies = [
"click>=8.1.8",
"copick>=1.24.1",
"copick>=2.0.0a1,<3",
"numpy>=2.0.2",
"scipy>=1.10.0",
"tensorflow>=2.16",
"tensorflow>=2.20,<3",
# easymode itself is installed separately from GitHub with `--no-deps` (see README): it is
# not on PyPI, and its published metadata wrongly pins numpy<2 / tensorflow<2.12, which
# conflicts with copick (numpy>=2). easymode runs fine on numpy>=2, so we install copick +
Expand Down Expand Up @@ -45,10 +51,13 @@ Issues = "https://github.com/copick/copick-easymode/issues"
[project.optional-dependencies]
test = [
"pytest>=8.0.0",
"pytest-cov>=4.0.0",
"pytest-cov>=6.2.1",
]
dev = [
"black>=25.1.0",
"hatchling>=1.25.0",
"hatch-vcs>=0.4.0",
"pre-commit>=4.2.0",
"ruff>=0.12.0",
]

Expand Down Expand Up @@ -107,5 +116,23 @@ known-first-party = ["copick_easymode"]
docstring-quotes = "double"

[tool.pytest.ini_options]
minversion = "6.0"
minversion = "8.0"
testpaths = ["tests"]
filterwarnings = [
'error:get_tomogram is deprecated, use get_tomograms instead.*:DeprecationWarning',
'error:Input .* contains invalid characters\. It has been sanitized to .*:UserWarning',
]

[tool.coverage.run]
source_pkgs = ["copick_easymode", "tests"]
branch = true

[tool.coverage.paths]
copick_easymode = ["src/copick_easymode", "*/copick-easymode/src/copick_easymode"]
tests = ["tests", "*/copick-easymode/tests"]

[tool.coverage.report]
exclude_lines = [
"no cov",
"if TYPE_CHECKING:",
]
47 changes: 47 additions & 0 deletions scripts/inspect_distribution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Fail builds whose artifacts do not expose the package contract."""

import argparse
import tarfile
import zipfile
from pathlib import Path


def inspect_distributions(dist_dir: Path) -> tuple[Path, Path]:
wheels = list(dist_dir.glob("*.whl"))
sdists = list(dist_dir.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
raise ValueError(f"Expected one wheel and one source distribution, found {wheels!r} and {sdists!r}")

wheel = wheels[0]
with zipfile.ZipFile(wheel) as archive:
metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")]
entry_point_names = [name for name in archive.namelist() if name.endswith(".dist-info/entry_points.txt")]
if len(metadata_names) != 1 or len(entry_point_names) != 1:
raise ValueError("Wheel must contain exactly one METADATA and one entry_points.txt file")

entry_points = archive.read(entry_point_names[0]).decode()
if (
"[copick.inference.commands]" not in entry_points
or "easymode = copick_easymode.cli.inference:easymode" not in entry_points
):
raise ValueError("Wheel does not register the copick inference easymode command")

sdist = sdists[0]
with tarfile.open(sdist, "r:gz") as archive:
names = archive.getnames()
if not any(name.endswith("/uv.lock") for name in names):
raise ValueError("Source distribution does not contain uv.lock")

return wheel, sdist


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("dist_dir", type=Path)
args = parser.parse_args()
wheel, sdist = inspect_distributions(args.dist_dir)
print(f"Validated {wheel.name} and {sdist.name}")


if __name__ == "__main__":
main()
9 changes: 9 additions & 0 deletions src/copick_easymode/cli/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,15 @@ def easymode(
if tta < 1 or tta > 16:
logger.critical(f"TTA must be between 1 and 16, got {tta}")
ctx.fail(f"TTA must be between 1 and 16, got {tta}")
if batch_size < 1:
logger.critical(f"Batch size must be at least 1, got {batch_size}")
ctx.fail(f"Batch size must be at least 1, got {batch_size}")
if threshold < 0.0 or threshold > 1.0:
logger.critical(f"Threshold must be between 0.0 and 1.0, got {threshold}")
ctx.fail(f"Threshold must be between 0.0 and 1.0, got {threshold}")
if voxel_size <= 0:
logger.critical(f"Voxel size must be positive, got {voxel_size}")
ctx.fail(f"Voxel size must be positive, got {voxel_size}")

# Load copick project
try:
Expand Down
80 changes: 80 additions & 0 deletions src/copick_easymode/core/easymode_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Compatibility boundary for the immutable easymode 1.0.0 source revision."""

from dataclasses import dataclass
from typing import Any, Callable

EASYMODE_COMMIT = "a42377e0b887364050bf47c63700a3dd1c0fa0d0"
EASYMODE_INSTALL = (
f"python -m pip install --no-deps 'easymode @ git+https://github.com/mgflast/easymode.git@{EASYMODE_COMMIT}'"
)


class EasymodeCompatibilityError(RuntimeError):
"""Raised when the supported easymode source API is unavailable."""


@dataclass(frozen=True)
class InferenceFunctions:
"""Private easymode inference functions used by this integration."""

pad_volume: Callable
segment_tomogram_instance: Callable


@dataclass(frozen=True)
class EasymodeRuntime:
"""Heavy runtime components loaded only when inference starts."""

tensorflow: Any
get_model: Callable
load_model: Callable


def _compatibility_error(detail: str) -> EasymodeCompatibilityError:
return EasymodeCompatibilityError(
"The supported easymode 1.0.0 runtime is missing or incompatible "
f"({detail}). Install its immutable source revision with: {EASYMODE_INSTALL}",
)


def get_inference_functions() -> InferenceFunctions:
"""Load and validate the private inference functions used by the adapter."""
try:
from easymode.segmentation.inference import _pad_volume, _segment_tomogram_instance
except (ImportError, AttributeError) as error:
raise _compatibility_error(str(error)) from error

if not callable(_pad_volume) or not callable(_segment_tomogram_instance):
raise _compatibility_error("required inference attributes are not callable")
return InferenceFunctions(_pad_volume, _segment_tomogram_instance)


def load_runtime() -> EasymodeRuntime:
"""Load TensorFlow and easymode's public model distribution functions."""
try:
import tensorflow as tf
from easymode.core.distribution import get_model, load_model
except (ImportError, AttributeError) as error:
raise _compatibility_error(str(error)) from error

if not callable(get_model) or not callable(load_model):
raise _compatibility_error("required model distribution attributes are not callable")
return EasymodeRuntime(tf, get_model, load_model)


def list_remote_models():
"""Return upstream model records without exposing its import path elsewhere."""
try:
from easymode.core.distribution import list_remote_models as upstream_list_remote_models
except (ImportError, AttributeError) as error:
raise _compatibility_error(str(error)) from error
return upstream_list_remote_models()


def get_model(name: str, *, silent: bool = True):
"""Resolve one upstream model through the supported distribution API."""
try:
from easymode.core.distribution import get_model as upstream_get_model
except (ImportError, AttributeError) as error:
raise _compatibility_error(str(error)) from error
return upstream_get_model(name, silent=silent)
Loading
Loading