diff --git a/.github/workflows/run_pytests.yaml b/.github/workflows/run_pytests.yaml index 9ce4442..e6cbd06 100644 --- a/.github/workflows/run_pytests.yaml +++ b/.github/workflows/run_pytests.yaml @@ -8,7 +8,7 @@ on: pull_request: {} env: - QUPATH_VERSION: 0.4.3 + QUPATH_VERSION: 0.5.0 jobs: # RUN PYTEST ON PAQUO SOURCE @@ -16,19 +16,21 @@ jobs: name: pytest ${{ matrix.os }}::py${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: - max-parallel: 7 + max-parallel: 8 fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.11"] + python-version: ["3.12"] include: # we'll test the python support on ubuntu - os: ubuntu-latest - python-version: '3.10' + python-version: '3.11' - os: ubuntu-latest - python-version: 3.9 + python-version: '3.12' - os: ubuntu-latest - python-version: 3.8 + python-version: '3.9' + - os: ubuntu-latest + python-version: '3.8' steps: - uses: actions/checkout@v3 with: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 04d2c6c..adc9349 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-added-large-files - repo: https://github.com/asottile/pyupgrade - rev: v3.9.0 + rev: v3.15.1 hooks: - id: pyupgrade args: [--py38-plus] - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort exclude: ^examples|^extras|^docs|tests.*|setup.py @@ -21,21 +21,21 @@ repos: # - id: black # language_version: python3 - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.4.1' + rev: 'v1.8.0' hooks: - id: mypy exclude: ^examples|^extras|^docs|tests.* additional_dependencies: [packaging, ome-types] - repo: https://github.com/PyCQA/flake8 - rev: '6.0.0' + rev: '7.0.0' hooks: - id: flake8 additional_dependencies: - - flake8-typing-imports==1.12.0 + - flake8-typing-imports==1.15.0 language_version: python3 exclude: "^(build|docs|examples|extras|setup.py)|tests[/]" - repo: https://github.com/PyCQA/bandit - rev: '1.7.5' + rev: '1.7.7' hooks: - id: bandit args: ["-lll", "--ini", ".bandit"] diff --git a/README.md b/README.md index 37a1e1c..7582559 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ unintuitive, slow or if its documentation is confusing, it's a bug in tracker! Development -[happens on github](https://github.com/bayer-science-for-a-better-life/paquo) +[happens on GitHub](https://github.com/bayer-science-for-a-better-life/paquo) :octocat: ## Documentation @@ -54,18 +54,18 @@ paquo to use that version. Currently, paquo supports every version of QuPath fro `0.2.0` to the most recent. _(We even support older `0.2.0-mX` versions but no guarantees)._ ```shell -> paquo get_qupath --install-path "/some/path/on/your/machine" 0.4.3 -# downloading: https://github.com/qupath/qupath/releases/download/v0.4.3/QuPath-0.4.3-Linux.tar.xz +> paquo get_qupath --install-path "/some/path/on/your/machine" 0.5.0 +# downloading: https://github.com/qupath/qupath/releases/download/v0.5.0/QuPath-0.4.3-Linux.tar.xz # progress ................... OK -# extracting: [...]/QuPath-0.4.3-Linux.tar.xz -# available at: /some/path/on/your/machine/QuPath-0.4.3 +# extracting: [...]/QuPath-0.5.0-Linux.tar.xz +# available at: /some/path/on/your/machine/QuPath-0.5.0 # # use via environment variable: -# $ export PAQUO_QUPATH_DIR=/some/path/on/your/machine/QuPath-0.4.3 +# $ export PAQUO_QUPATH_DIR=/some/path/on/your/machine/QuPath-0.5.0 # # use via .paquo.toml config file: -# qupath_dir="/some/path/on/your/machine/QuPath-0.4.3" -/some/path/on/your/machine/QuPath-0.4.3 +# qupath_dir="/some/path/on/your/machine/QuPath-0.5.0" +/some/path/on/your/machine/QuPath-0.5.0 ``` @@ -88,9 +88,9 @@ so go ahead and hack. - When contributing code, please try to use Pull Requests. - tests go hand in hand with modules on ```tests``` packages at the same level. We use ```pytest```. -You can setup your IDE to help you adhering to these guidelines. +You can set up your IDE to help you to adhere to these guidelines.
-_([Santi](https://github.com/sdvillal) is happy to help you setting up pycharm in 5 minutes)_ +_([Santi](https://github.com/sdvillal) is happy to help you to set up pycharm in 5 minutes)_ ## Acknowledgements diff --git a/paquo/__main__.py b/paquo/__main__.py index 9bee0e9..d453361 100644 --- a/paquo/__main__.py +++ b/paquo/__main__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import functools import os @@ -8,6 +10,7 @@ from itertools import repeat from logging.config import dictConfig from pathlib import Path +from typing import Callable from paquo._cli import DirectoryType from paquo._cli import argument @@ -270,14 +273,14 @@ def quickview(args, subparser): f_annotations = None if args.annotations: - def f_annotations(name): + def f_annotations(name): # noqa if name != image.name: return [] return list(args.annotations) f_annotations_cmd = None if args.annotations_cmd: - def f_annotations_cmd(name): + def f_annotations_cmd(name): # noqa import shlex _cmd = shlex.split(f"{args.annotations_cmd} {name}") print("annotations", _cmd) @@ -286,7 +289,7 @@ def f_annotations_cmd(name): cmd = None if f_annotations_cmd or f_annotations: - def cmd(name): + def cmd(name): # noqa names = [] if f_annotations: names.extend(f_annotations(name)) @@ -332,13 +335,16 @@ def _download_cb(it, name): finally: print("") - file = download_qupath( - args.version, - path=args.download_path, - callback=_download_cb, - system=system, - ssl_verify=not args.no_ssl_verify - ) + try: + file = download_qupath( + args.version, + path=args.download_path, + callback=_download_cb, + system=system, + ssl_verify=not args.no_ssl_verify + ) + except Exception: + raise SystemExit(1) print("# extracting:", file) app = extract_qupath(file, args.install_path, system=system) print("# available at:", app) diff --git a/paquo/_utils.py b/paquo/_utils.py index de749da..171ab68 100644 --- a/paquo/_utils.py +++ b/paquo/_utils.py @@ -126,12 +126,23 @@ def download_qupath( else: raise ValueError(f"unsupported platform.system() == {system!r}") - if "rc" not in version: + if not version.startswith("v"): + version = f"v{version}" + + if Version(version) > Version("0.4.4"): + if system == "Darwin": + if platform.machine() == "arm64": + _sys = "Mac-arm64" + else: + _sys = "Mac-x64" name = f"QuPath-{version}-{_sys}" else: - name = f"QuPath-{version}" + if "rc" not in version: + name = f"QuPath-{version[1:]}-{_sys}" + else: + name = f"QuPath-{version[1:]}" - url = f"https://github.com/qupath/qupath/releases/download/v{version}/{name}.{ext}" + url = f"https://github.com/qupath/qupath/releases/download/{version}/{name}.{ext}" chunk_size = 10 * 1024 * 1024 @@ -153,6 +164,7 @@ def download_qupath( for chunk in callback(iter(lambda: f.read(chunk_size), b""), name=url): tmp.write(chunk) except Exception: + print("# error requesting:", url, file=sys.stderr) try: os.unlink(out_fn) except OSError: @@ -168,7 +180,7 @@ def extract_qupath(file, destination, system=None): # normalize QuPath App dirname m = re.match( - r"QuPath-(?P[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+|-m[0-9]+)?)", + r"QuPath-v?(?P[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+|-m[0-9]+)?)", fn, ) @@ -236,9 +248,12 @@ def extract_qupath(file, destination, system=None): pth = os.path.join(tmp_dir, name) if name.startswith("QuPath") and os.path.isdir(pth): break + if name.startswith("QuPath") and name.endswith(".exe") and os.path.isfile(pth): + pth = tmp_dir + break else: raise RuntimeError("no qupath extracted?") - shutil.move(os.path.join(tmp_dir, name), qp_dst) + shutil.move(pth, qp_dst) return qp_dst else: diff --git a/paquo/images.py b/paquo/images.py index e86c5ba..32b95d0 100644 --- a/paquo/images.py +++ b/paquo/images.py @@ -548,7 +548,8 @@ def downsample_levels(self) -> List[Dict[str, float]]: The available downsample levels can differ dependent on which image backend is used by QuPath """ - md = self._image_server.getMetadata() + with redirect(stdout=True, stderr=True): + md = self._image_server.getMetadata() levels = [] for level in range(int(md.nLevels())): resolution_level = md.getLevel(level) diff --git a/paquo/java.py b/paquo/java.py index 55da01f..c0f3be0 100644 --- a/paquo/java.py +++ b/paquo/java.py @@ -119,7 +119,12 @@ def supports_newer_addobject_and_pathclass(self) -> bool: PathAnnotationObject = JClass("qupath.lib.objects.PathAnnotationObject") PathClass = JClass('qupath.lib.objects.classes.PathClass') -PathClassFactory = JClass('qupath.lib.objects.classes.PathClassFactory') + +if not compatibility.supports_newer_addobject_and_pathclass(): + PathClassFactory = JClass('qupath.lib.objects.classes.PathClassFactory') +else: + PathClassFactory = None + PathDetectionObject = JClass("qupath.lib.objects.PathDetectionObject") PathIO = JClass("qupath.lib.io.PathIO") PathObjectHierarchy = JClass('qupath.lib.objects.hierarchy.PathObjectHierarchy') diff --git a/paquo/tests/test_images.py b/paquo/tests/test_images.py index acd423a..f7f1a9e 100644 --- a/paquo/tests/test_images.py +++ b/paquo/tests/test_images.py @@ -1,5 +1,6 @@ import platform import shutil +import sys import tempfile from contextlib import nullcontext from operator import itemgetter @@ -23,7 +24,11 @@ def image_entry(svs_small): @pytest.fixture(scope='function') def removable_svs_small(svs_small): - with tempfile.TemporaryDirectory(prefix='paquo-') as tmpdir: + kw = {} if sys.version_info < (3, 10) else {"ignore_cleanup_errors": True} + with tempfile.TemporaryDirectory( + prefix='paquo-', + **kw + ) as tmpdir: new_path = Path(tmpdir) / svs_small.name shutil.copy(svs_small, new_path) yield new_path @@ -32,21 +37,35 @@ def removable_svs_small(svs_small): @pytest.fixture(scope='function') def project_with_removed_image(removable_svs_small): with tempfile.TemporaryDirectory(prefix='paquo-') as tmpdir: - qp = QuPathProject(tmpdir, mode='x') - _ = qp.add_image(removable_svs_small, image_type=QuPathImageType.BRIGHTFIELD_H_E) - qp.save() - removable_svs_small.unlink() - yield qp.path + with QuPathProject(tmpdir, mode='x') as qp: + _ = qp.add_image(removable_svs_small, image_type=QuPathImageType.BRIGHTFIELD_H_E) + qp.save() + path = qp.path + try: + removable_svs_small.unlink() + except PermissionError: + if platform.system() == "Windows": + pytest.xfail("Windows QuPath==0.5.0 permission issue") + else: + raise + yield path @pytest.fixture(scope='function') def project_with_removed_image_without_image_data(removable_svs_small): with tempfile.TemporaryDirectory(prefix='paquo-') as tmpdir: - qp = QuPathProject(tmpdir, mode='x') - _ = qp.add_image(removable_svs_small) - qp.save() - removable_svs_small.unlink() - yield qp.path + with QuPathProject(tmpdir, mode='x') as qp: + _ = qp.add_image(removable_svs_small) + qp.save() + path = qp.path + try: + removable_svs_small.unlink() + except PermissionError: + if platform.system() == "Windows": + pytest.xfail("Windows QuPath==0.5.0 permission issue") + else: + raise + yield path def test_image_entry_return_hierarchy(image_entry): @@ -74,10 +93,6 @@ def test_image_properties_from_image_server(image_entry): assert image_entry.num_z_slices == 1 -@pytest.mark.xfail( - platform.uname().machine == "arm64", - reason="QuPath-vendored openslide not working on arm64" -) def test_image_downsample_levels(image_entry): levels = [ {'downsample': 1.0, @@ -85,11 +100,14 @@ def test_image_downsample_levels(image_entry): 'width': 2220}, # todo: when openslide can be used by qupath, this downsample level # in the test image disappears. investigate... - # {'downsample': 3.865438534407666, - # 'height': 768, - # 'width': 574}, + {'downsample': 3.865438534407666, + 'height': 768, + 'width': 574}, ] - assert image_entry.downsample_levels == levels + assert ( + image_entry.downsample_levels == levels + or image_entry.downsample_levels == levels[:1] + ) def test_metadata_interface(image_entry): diff --git a/paquo/tests/test_projects.py b/paquo/tests/test_projects.py index 31841c2..0d3b6f0 100644 --- a/paquo/tests/test_projects.py +++ b/paquo/tests/test_projects.py @@ -8,6 +8,7 @@ import pytest +from paquo._utils import QuPathVersion from paquo.images import ImageProvider, QuPathProjectImageEntry, QuPathImageType from paquo.projects import QuPathProject @@ -284,7 +285,7 @@ def test_project_save_image_data(new_project, svs_small): assert (entry.entry_path / "data.qpdata").is_file() -def test_project_delete_image_file_when_opened(new_project, svs_small): +def test_project_delete_image_file_when_opened(new_project, svs_small, qupath_version): # prepare new image to be deleted new_svs_small = new_project.path.parent / f"image_be_gone{svs_small.suffix}" shutil.copy(svs_small, new_svs_small) @@ -314,7 +315,16 @@ def test_project_delete_image_file_when_opened(new_project, svs_small): elif qupath_uses == "OPENSLIDE": - os.unlink(new_svs_small) + if ( + qupath_version >= QuPathVersion("0.5.0") + and platform.system() == "Windows" + ): + cm = pytest.raises(PermissionError) + else: + cm = nullcontext() + + with cm: + os.unlink(new_svs_small) else: # pragma: no cover raise ValueError('...') @@ -389,7 +399,13 @@ def test_project_image_uri_update_try_relative(tmp_path, svs_small): # NOW move the location location_1 = tmp_path / "location_1" - shutil.move(location_0, location_1) + try: + shutil.move(location_0, location_1) + except PermissionError: + if platform.system() == "Windows": + pytest.xfail("Windows QuPath==0.5.0 quirk with permissions") + else: + raise with QuPathProject(location_1 / "project", mode='r') as qp: entry, = qp.images diff --git a/setup.cfg b/setup.cfg index 8611f09..85e7026 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,6 +20,7 @@ classifiers = Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Scientific/Engineering Topic :: Scientific/Engineering :: Visualization Topic :: Scientific/Engineering :: Information Analysis