diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dca2fb10..7d43bcbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,8 +58,7 @@ repos: hooks: - id: mypy name: Check Python types - additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|tests/.*|examples/deepzoom/.*)$" + additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 diff --git a/doc/conf.py b/doc/conf.py index 4cdc306d..79718126 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -174,98 +174,6 @@ # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None -# Output file base name for HTML help builder. -htmlhelp_basename = 'OpenSlidePythondoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # 'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ( - 'index', - 'OpenSlidePython.tex', - 'OpenSlide Python Documentation', - 'OpenSlide project', - 'manual', - ), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# latex_use_parts = False - -# If true, show page references after internal links. -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# latex_appendices = [] - -# If false, no module index is generated. -# latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ( - 'index', - 'openslidepython', - 'OpenSlide Python Documentation', - ['OpenSlide project'], - 1, - ) -] - -# If true, show URL addresses after external links. -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - 'index', - 'OpenSlidePython', - 'OpenSlide Python Documentation', - 'OpenSlide project', - 'OpenSlidePython', - 'One line description of project.', - 'Miscellaneous', - ), -] - -# Documents to append as an appendix to all manuals. -# texinfo_appendices = [] - -# If false, no module index is generated. -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# texinfo_show_urls = 'footnote' - # intersphinx intersphinx_mapping = { diff --git a/doc/jekyll_fix.py b/doc/jekyll_fix.py index b479e00e..798b8897 100644 --- a/doc/jekyll_fix.py +++ b/doc/jekyll_fix.py @@ -27,6 +27,7 @@ import os +from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.console import bold @@ -41,7 +42,7 @@ REWRITE_EXTENSIONS = {'.html', '.js'} -def remove_path_underscores(app, exception): +def remove_path_underscores(app: Sphinx, exception: Exception | None) -> None: if exception: return # Get logger @@ -82,5 +83,5 @@ def remove_path_underscores(app, exception): logger.info('done') -def setup(app): +def setup(app: Sphinx) -> None: app.connect('build-finished', remove_path_underscores) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index de0c2061..82b4734a 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -24,18 +24,24 @@ from argparse import ArgumentParser import base64 from collections import OrderedDict +from collections.abc import Callable from io import BytesIO import os from threading import Lock +from typing import TYPE_CHECKING, Any, Literal import zlib -from PIL import ImageCms -from flask import Flask, abort, make_response, render_template, url_for +from PIL import Image, ImageCms +from flask import Flask, Response, abort, make_response, render_template, url_for + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] import openslide else: import openslide @@ -62,10 +68,36 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + Transform: TypeAlias = Callable[[Image.Image], None] + + +class DeepZoomMultiServer(Flask): + basedir: str + cache: _SlideCache + + +class AnnotatedDeepZoomGenerator(DeepZoomGenerator): + filename: str + mpp: float + transform: Transform + -def create_app(config=None, config_file=None): +def create_app( + config: dict[str, Any] | None = None, + config_file: str | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomMultiServer(__name__) app.config.from_mapping( SLIDE_DIR='.', SLIDE_CACHE_SIZE=10, @@ -99,7 +131,7 @@ def create_app(config=None, config_file=None): ) # Helper functions - def get_slide(path): + def get_slide(path: str) -> AnnotatedDeepZoomGenerator: path = os.path.abspath(os.path.join(app.basedir, path)) if not path.startswith(app.basedir + os.path.sep): # Directory traversal @@ -115,11 +147,11 @@ def get_slide(path): # Set up routes @app.route('/') - def index(): + def index() -> str: return render_template('files.html', root_dir=_Directory(app.basedir)) @app.route('/') - def slide(path): + def slide(path: str) -> str: slide = get_slide(path) slide_url = url_for('dzi', path=path) return render_template( @@ -130,7 +162,7 @@ def slide(path): ) @app.route('/.dzi') - def dzi(path): + def dzi(path: str) -> Response: slide = get_slide(path) format = app.config['DEEPZOOM_FORMAT'] resp = make_response(slide.get_dzi(format)) @@ -138,7 +170,7 @@ def dzi(path): return resp @app.route('/_files//_.') - def tile(path, level, col, row, format): + def tile(path: str, level: int, col: int, row: int, format: str) -> Response: slide = get_slide(path) format = format.lower() if format != 'jpeg' and format != 'png': @@ -165,19 +197,27 @@ def tile(path, level, col, row, format): class _SlideCache: - def __init__(self, cache_size, tile_cache_mb, dz_opts, color_mode): + def __init__( + self, + cache_size: int, + tile_cache_mb: int, + dz_opts: dict[str, Any], + color_mode: ColorMode, + ): self.cache_size = cache_size self.dz_opts = dz_opts self.color_mode = color_mode self._lock = Lock() - self._cache = OrderedDict() + self._cache: OrderedDict[str, AnnotatedDeepZoomGenerator] = OrderedDict() # Share a single tile cache among all slide handles, if supported try: - self._tile_cache = OpenSlideCache(tile_cache_mb * 1024 * 1024) + self._tile_cache: OpenSlideCache | None = OpenSlideCache( + tile_cache_mb * 1024 * 1024 + ) except OpenSlideVersionError: self._tile_cache = None - def get(self, path): + def get(self, path: str) -> AnnotatedDeepZoomGenerator: with self._lock: if path in self._cache: # Move to end of LRU @@ -188,7 +228,7 @@ def get(self, path): osr = OpenSlide(path) if self._tile_cache is not None: osr.set_cache(self._tile_cache) - slide = DeepZoomGenerator(osr, **self.dz_opts) + slide = AnnotatedDeepZoomGenerator(osr, **self.dz_opts) try: mpp_x = osr.properties[openslide.PROPERTY_NAME_MPP_X] mpp_y = osr.properties[openslide.PROPERTY_NAME_MPP_Y] @@ -204,7 +244,7 @@ def get(self, path): self._cache[path] = slide return slide - def _get_transform(self, image): + def _get_transform(self, image: OpenSlide) -> Transform: if image.color_profile is None: return lambda img: None mode = self.color_mode @@ -215,7 +255,7 @@ def _get_transform(self, image): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -232,10 +272,10 @@ def _get_transform(self, image): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we # don't embed the profile. Pillow's serialization is larger, so @@ -246,9 +286,9 @@ def xfrm(img): class _Directory: - def __init__(self, basedir, relpath=''): + def __init__(self, basedir: str, relpath: str = ''): self.name = os.path.basename(relpath) - self.children = [] + self.children: list[_Directory | _SlideFile] = [] for name in sorted(os.listdir(os.path.join(basedir, relpath))): cur_relpath = os.path.join(relpath, name) cur_path = os.path.join(basedir, cur_relpath) @@ -261,7 +301,7 @@ def __init__(self, basedir, relpath=''): class _SlideFile: - def __init__(self, relpath): + def __init__(self, relpath: str): self.name = os.path.basename(relpath) self.url_path = relpath diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index 0b82aeda..1512460f 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -23,26 +23,32 @@ from argparse import ArgumentParser import base64 +from collections.abc import Callable from io import BytesIO import os import re +from typing import TYPE_CHECKING, Any, Literal, Mapping from unicodedata import normalize import zlib -from PIL import ImageCms -from flask import Flask, abort, make_response, render_template, url_for +from PIL import Image, ImageCms +from flask import Flask, Response, abort, make_response, render_template, url_for + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] import openslide else: import openslide else: import openslide -from openslide import ImageSlide, open_slide +from openslide import AbstractSlide, ImageSlide, open_slide from openslide.deepzoom import DeepZoomGenerator SLIDE_NAME = 'slide' @@ -64,10 +70,33 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + Transform: TypeAlias = Callable[[Image.Image], None] + + +class DeepZoomServer(Flask): + slides: dict[str, DeepZoomGenerator] + transforms: dict[str, Transform] + slide_properties: Mapping[str, str] + associated_images: list[str] + slide_mpp: float + -def create_app(config=None, config_file=None): +def create_app( + config: dict[str, Any] | None = None, + config_file: str | None = None, +) -> Flask: # Create and configure app - app = Flask(__name__) + app = DeepZoomServer(__name__) app.config.from_mapping( DEEPZOOM_SLIDE=None, DEEPZOOM_FORMAT='jpeg', @@ -117,7 +146,7 @@ def create_app(config=None, config_file=None): # Set up routes @app.route('/') - def index(): + def index() -> str: slide_url = url_for('dzi', slug=SLIDE_NAME) associated_urls = { name: url_for('dzi', slug=slugify(name)) for name in app.associated_images @@ -131,7 +160,7 @@ def index(): ) @app.route('/.dzi') - def dzi(slug): + def dzi(slug: str) -> Response: format = app.config['DEEPZOOM_FORMAT'] try: resp = make_response(app.slides[slug].get_dzi(format)) @@ -142,7 +171,7 @@ def dzi(slug): abort(404) @app.route('/_files//_.') - def tile(slug, level, col, row, format): + def tile(slug: str, level: int, col: int, row: int, format: str) -> Response: format = format.lower() if format != 'jpeg' and format != 'png': # Not supported by Deep Zoom @@ -170,12 +199,12 @@ def tile(slug, level, col, row, format): return app -def slugify(text): +def slugify(text: str) -> str: text = normalize('NFKD', text.lower()).encode('ascii', 'ignore').decode() return re.sub('[^a-z0-9]+', '-', text) -def get_transform(image, mode): +def get_transform(image: AbstractSlide, mode: ColorMode) -> Transform: if image.color_profile is None: return lambda img: None if mode == 'ignore': @@ -185,7 +214,7 @@ def get_transform(image, mode): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -202,10 +231,10 @@ def get_transform(image, mode): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we don't # embed the profile. Pillow's serialization is larger, so use ours. diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index aad834d9..211b9111 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -25,29 +25,36 @@ from argparse import ArgumentParser import base64 +from collections.abc import Callable from io import BytesIO import json from multiprocessing import JoinableQueue, Process +import multiprocessing.queues import os import re import shutil import sys +from typing import TYPE_CHECKING, Literal from unicodedata import normalize import zlib -from PIL import ImageCms +from PIL import Image, ImageCms + +if TYPE_CHECKING: + # Python 3.10+ + from typing import TypeAlias if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] import openslide else: import openslide else: import openslide -from openslide import ImageSlide, open_slide +from openslide import AbstractSlide, ImageSlide, open_slide from openslide.deepzoom import DeepZoomGenerator VIEWER_SLIDE_NAME = 'slide' @@ -69,12 +76,34 @@ ) SRGB_PROFILE = ImageCms.getOpenProfile(BytesIO(SRGB_PROFILE_BYTES)) +if TYPE_CHECKING: + ColorMode: TypeAlias = Literal[ + 'default', + 'absolute-colorimetric', + 'perceptual', + 'relative-colorimetric', + 'saturation', + 'embed', + 'ignore', + ] + TileQueue: TypeAlias = multiprocessing.queues.JoinableQueue[ + tuple[str | None, int, tuple[int, int], str] | None + ] + Transform: TypeAlias = Callable[[Image.Image], None] + class TileWorker(Process): """A child process that generates and writes tiles.""" def __init__( - self, queue, slidepath, tile_size, overlap, limit_bounds, quality, color_mode + self, + queue: TileQueue, + slidepath: str, + tile_size: int, + overlap: int, + limit_bounds: bool, + quality: int, + color_mode: ColorMode, ): Process.__init__(self, name='TileWorker') self.daemon = True @@ -85,9 +114,9 @@ def __init__( self._limit_bounds = limit_bounds self._quality = quality self._color_mode = color_mode - self._slide = None + self._slide: AbstractSlide | None = None - def run(self): + def run(self) -> None: self._slide = open_slide(self._slidepath) last_associated = None dz, transform = self._get_dz_and_transform() @@ -107,9 +136,12 @@ def run(self): ) self._queue.task_done() - def _get_dz_and_transform(self, associated=None): + def _get_dz_and_transform( + self, associated: str | None = None + ) -> tuple[DeepZoomGenerator, Transform]: + assert self._slide is not None if associated is not None: - image = ImageSlide(self._slide.associated_images[associated]) + image: AbstractSlide = ImageSlide(self._slide.associated_images[associated]) else: image = self._slide dz = DeepZoomGenerator( @@ -117,7 +149,7 @@ def _get_dz_and_transform(self, associated=None): ) return dz, self._get_transform(image) - def _get_transform(self, image): + def _get_transform(self, image: AbstractSlide) -> Transform: if image.color_profile is None: return lambda img: None mode = self._color_mode @@ -128,7 +160,7 @@ def _get_transform(self, image): # embed ICC profile in tiles return lambda img: None elif mode == 'default': - intent = ImageCms.getDefaultIntent(image.color_profile) + intent = ImageCms.Intent(ImageCms.getDefaultIntent(image.color_profile)) elif mode == 'absolute-colorimetric': intent = ImageCms.Intent.ABSOLUTE_COLORIMETRIC elif mode == 'relative-colorimetric': @@ -145,10 +177,10 @@ def _get_transform(self, image): 'RGB', 'RGB', intent, - 0, + ImageCms.Flags(0), ) - def xfrm(img): + def xfrm(img: Image.Image) -> None: ImageCms.applyTransform(img, transform, True) # Some browsers assume we intend the display's color space if we # don't embed the profile. Pillow's serialization is larger, so @@ -161,7 +193,14 @@ def xfrm(img): class DeepZoomImageTiler: """Handles generation of tiles and metadata for a single image.""" - def __init__(self, dz, basename, format, associated, queue): + def __init__( + self, + dz: DeepZoomGenerator, + basename: str, + format: str, + associated: str | None, + queue: TileQueue, + ): self._dz = dz self._basename = basename self._format = format @@ -169,11 +208,11 @@ def __init__(self, dz, basename, format, associated, queue): self._queue = queue self._processed = 0 - def run(self): + def run(self) -> None: self._write_tiles() self._write_dzi() - def _write_tiles(self): + def _write_tiles(self) -> None: for level in range(self._dz.level_count): tiledir = os.path.join("%s_files" % self._basename, str(level)) if not os.path.exists(tiledir): @@ -188,7 +227,7 @@ def _write_tiles(self): self._queue.put((self._associated, level, (col, row), tilename)) self._tile_done() - def _tile_done(self): + def _tile_done(self) -> None: self._processed += 1 count, total = self._processed, self._dz.tile_count if count % 100 == 0 or count == total: @@ -201,11 +240,11 @@ def _tile_done(self): if count == total: print(file=sys.stderr) - def _write_dzi(self): + def _write_dzi(self) -> None: with open('%s.dzi' % self._basename, 'w') as fh: fh.write(self.get_dzi()) - def get_dzi(self): + def get_dzi(self) -> str: return self._dz.get_dzi(self._format) @@ -214,16 +253,16 @@ class DeepZoomStaticTiler: def __init__( self, - slidepath, - basename, - format, - tile_size, - overlap, - limit_bounds, - quality, - color_mode, - workers, - with_viewer, + slidepath: str, + basename: str, + format: str, + tile_size: int, + overlap: int, + limit_bounds: bool, + quality: int, + color_mode: ColorMode, + workers: int, + with_viewer: bool, ): if with_viewer: # Check extra dependency before doing a bunch of work @@ -234,11 +273,11 @@ def __init__( self._tile_size = tile_size self._overlap = overlap self._limit_bounds = limit_bounds - self._queue = JoinableQueue(2 * workers) + self._queue: TileQueue = JoinableQueue(2 * workers) self._workers = workers self._color_mode = color_mode self._with_viewer = with_viewer - self._dzi_data = {} + self._dzi_data: dict[str, str] = {} for _i in range(workers): TileWorker( self._queue, @@ -250,7 +289,7 @@ def __init__( color_mode, ).start() - def run(self): + def run(self) -> None: self._run_image() if self._with_viewer: for name in self._slide.associated_images: @@ -259,7 +298,7 @@ def run(self): self._write_static() self._shutdown() - def _run_image(self, associated=None): + def _run_image(self, associated: str | None = None) -> None: """Run a single image from self._slide.""" if associated is None: image = self._slide @@ -277,14 +316,14 @@ def _run_image(self, associated=None): tiler.run() self._dzi_data[self._url_for(associated)] = tiler.get_dzi() - def _url_for(self, associated): + def _url_for(self, associated: str | None) -> str: if associated is None: base = VIEWER_SLIDE_NAME else: base = self._slugify(associated) return '%s.dzi' % base - def _write_html(self): + def _write_html(self) -> None: import jinja2 # https://docs.python.org/3/reference/import.html#main-spec @@ -321,13 +360,13 @@ def _write_html(self): with open(os.path.join(self._basename, 'index.html'), 'w') as fh: fh.write(data) - def _write_static(self): + def _write_static(self) -> None: basesrc = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') basedst = os.path.join(self._basename, 'static') self._copydir(basesrc, basedst) self._copydir(os.path.join(basesrc, 'images'), os.path.join(basedst, 'images')) - def _copydir(self, src, dest): + def _copydir(self, src: str, dest: str) -> None: if not os.path.exists(dest): os.makedirs(dest) for name in os.listdir(src): @@ -336,11 +375,11 @@ def _copydir(self, src, dest): shutil.copy(srcpath, os.path.join(dest, name)) @classmethod - def _slugify(cls, text): + def _slugify(cls, text: str) -> str: text = normalize('NFKD', text.lower()).encode('ascii', 'ignore').decode() return re.sub('[^a-z0-9]+', '_', text) - def _shutdown(self): + def _shutdown(self) -> None: for _i in range(self._workers): self._queue.put(None) self._queue.join() diff --git a/openslide/py.typed b/openslide/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index 8fd7be02..42ee741b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: Bio-Informatics", + "Typing :: Typed", ] requires-python = ">= 3.8" dependencies = ["Pillow"] diff --git a/setup.py b/setup.py index a4702e72..4a5e532c 100644 --- a/setup.py +++ b/setup.py @@ -21,4 +21,7 @@ # tag wheel for Limited API 'bdist_wheel': {'py_limited_api': 'cp311'} if _abi3 else {}, }, + package_data={ + 'openslide': ['py.typed'], + }, ) diff --git a/tests/common.py b/tests/common.py index 9efd1c26..28bc8bfb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -30,9 +30,9 @@ # environment. _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined] import openslide # noqa: F401 module-imported-but-unused -def file_path(name): +def file_path(name: str) -> Path: return Path(__file__).parent / 'fixtures' / name diff --git a/tests/test_base.py b/tests/test_base.py index d03ce7cf..2ed9116e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -28,13 +28,13 @@ class TestLibrary(unittest.TestCase): - def test_open_slide(self): + def test_open_slide(self) -> None: with open_slide(file_path('boxes.tiff')) as osr: self.assertTrue(isinstance(osr, OpenSlide)) with open_slide(file_path('boxes.png')) as osr: self.assertTrue(isinstance(osr, ImageSlide)) - def test_lowlevel_available(self): + def test_lowlevel_available(self) -> None: '''Ensure all exported functions have an 'available' attribute.''' for name in dir(lowlevel): attr = getattr(lowlevel, name) diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index af1cd503..1c67ab06 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -27,90 +27,97 @@ from openslide.deepzoom import DeepZoomGenerator -class _BoxesDeepZoomTest: - def setUp(self): - self.osr = self.CLASS(file_path(self.FILENAME)) - self.dz = DeepZoomGenerator(self.osr, 254, 1) - - def tearDown(self): - self.osr.close() - - def test_repr(self): - self.assertEqual( - repr(self.dz), - ('DeepZoomGenerator(%r, tile_size=254, overlap=1, ' + 'limit_bounds=False)') - % self.osr, - ) - - def test_metadata(self): - self.assertEqual(self.dz.level_count, 10) - self.assertEqual(self.dz.tile_count, 11) - self.assertEqual( - self.dz.level_tiles, - ( - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (1, 1), - (2, 1), - ), - ) - self.assertEqual( - self.dz.level_dimensions, - ( - (1, 1), - (2, 1), - (3, 2), - (5, 4), - (10, 8), - (19, 16), - (38, 32), - (75, 63), - (150, 125), - (300, 250), - ), - ) - - def test_get_tile(self): - self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) - - def test_tile_color_profile(self): - if self.CLASS is OpenSlide and not lowlevel.read_icc_profile.available: - self.skipTest("requires OpenSlide 4.0.0") - self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info['icc_profile']), 588) - - def test_get_tile_bad_level(self): - self.assertRaises(ValueError, lambda: self.dz.get_tile(-1, (0, 0))) - self.assertRaises(ValueError, lambda: self.dz.get_tile(10, (0, 0))) - - def test_get_tile_bad_address(self): - self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (-1, 0))) - self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (1, 0))) - - def test_get_tile_coordinates(self): - self.assertEqual( - self.dz.get_tile_coordinates(9, (1, 0)), ((253, 0), 0, (47, 250)) - ) - - def test_get_tile_dimensions(self): - self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) - - def test_get_dzi(self): - self.assertTrue( - 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') - ) - - -class TestSlideDeepZoom(_BoxesDeepZoomTest, unittest.TestCase): +class _Abstract: + # nested class to prevent the test runner from finding it + class BoxesDeepZoomTest(unittest.TestCase): + CLASS: type | None = None + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.CLASS is not None + assert self.FILENAME is not None + self.osr = self.CLASS(file_path(self.FILENAME)) + self.dz = DeepZoomGenerator(self.osr, 254, 1) + + def tearDown(self) -> None: + self.osr.close() + + def test_repr(self) -> None: + self.assertEqual( + repr(self.dz), + 'DeepZoomGenerator(%r, tile_size=254, overlap=1, limit_bounds=False)' + % self.osr, + ) + + def test_metadata(self) -> None: + self.assertEqual(self.dz.level_count, 10) + self.assertEqual(self.dz.tile_count, 11) + self.assertEqual( + self.dz.level_tiles, + ( + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (1, 1), + (2, 1), + ), + ) + self.assertEqual( + self.dz.level_dimensions, + ( + (1, 1), + (2, 1), + (3, 2), + (5, 4), + (10, 8), + (19, 16), + (38, 32), + (75, 63), + (150, 125), + (300, 250), + ), + ) + + def test_get_tile(self) -> None: + self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) + + def test_tile_color_profile(self) -> None: + if self.CLASS is OpenSlide and not lowlevel.read_icc_profile.available: + self.skipTest("requires OpenSlide 4.0.0") + self.assertEqual(len(self.dz.get_tile(9, (1, 0)).info['icc_profile']), 588) + + def test_get_tile_bad_level(self) -> None: + self.assertRaises(ValueError, lambda: self.dz.get_tile(-1, (0, 0))) + self.assertRaises(ValueError, lambda: self.dz.get_tile(10, (0, 0))) + + def test_get_tile_bad_address(self) -> None: + self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (-1, 0))) + self.assertRaises(ValueError, lambda: self.dz.get_tile(0, (1, 0))) + + def test_get_tile_coordinates(self) -> None: + self.assertEqual( + self.dz.get_tile_coordinates(9, (1, 0)), ((253, 0), 0, (47, 250)) + ) + + def test_get_tile_dimensions(self) -> None: + self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) + + def test_get_dzi(self) -> None: + self.assertTrue( + 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') + ) + + +class TestSlideDeepZoom(_Abstract.BoxesDeepZoomTest): CLASS = OpenSlide FILENAME = 'boxes.tiff' -class TestImageDeepZoom(_BoxesDeepZoomTest, unittest.TestCase): +class TestImageDeepZoom(_Abstract.BoxesDeepZoomTest): CLASS = ImageSlide FILENAME = 'boxes.png' diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index e577851f..051263ee 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -29,16 +29,16 @@ class TestImageWithoutOpening(unittest.TestCase): - def test_detect_format(self): + def test_detect_format(self) -> None: self.assertTrue(ImageSlide.detect_format(file_path('__missing_file')) is None) self.assertTrue(ImageSlide.detect_format(file_path('../setup.py')) is None) self.assertEqual(ImageSlide.detect_format(file_path('boxes.png')), 'PNG') - def test_open(self): + def test_open(self) -> None: self.assertRaises(OSError, lambda: ImageSlide(file_path('__does_not_exist'))) self.assertRaises(OSError, lambda: ImageSlide(file_path('../setup.py'))) - def test_open_image(self): + def test_open_image(self) -> None: # passing PIL.Image to ImageSlide with Image.open(file_path('boxes.png')) as img: with ImageSlide(img) as osr: @@ -49,18 +49,18 @@ def test_open_image(self): sys.getfilesystemencoding() == 'utf-8', 'Python filesystem encoding is not UTF-8', ) - def test_unicode_path(self): + def test_unicode_path(self) -> None: path = file_path('😐.png') for arg in path, str(path): self.assertEqual(ImageSlide.detect_format(arg), 'PNG') self.assertEqual(ImageSlide(arg).dimensions, (300, 250)) - def test_unicode_path_bytes(self): + def test_unicode_path_bytes(self) -> None: arg = str(file_path('😐.png')).encode('UTF-8') self.assertEqual(ImageSlide.detect_format(arg), 'PNG') self.assertEqual(ImageSlide(arg).dimensions, (300, 250)) - def test_operations_on_closed_handle(self): + def test_operations_on_closed_handle(self) -> None: with Image.open(file_path('boxes.png')) as img: osr = ImageSlide(img) osr.close() @@ -72,7 +72,7 @@ def test_operations_on_closed_handle(self): # shouldn't close it self.assertEqual(img.getpixel((0, 0)), 3) - def test_context_manager(self): + def test_context_manager(self) -> None: osr = ImageSlide(file_path('boxes.png')) with osr: pass @@ -80,21 +80,26 @@ def test_context_manager(self): self.assertRaises(ValueError, lambda: osr.level_dimensions) -class _SlideTest: - def setUp(self): - self.osr = ImageSlide(file_path(self.FILENAME)) +class _Abstract: + # nested class to prevent the test runner from finding it + class SlideTest(unittest.TestCase): + FILENAME: str | None = None - def tearDown(self): - self.osr.close() + def setUp(self) -> None: + assert self.FILENAME is not None + self.osr = ImageSlide(file_path(self.FILENAME)) + def tearDown(self) -> None: + self.osr.close() -class TestImage(_SlideTest, unittest.TestCase): + +class TestImage(_Abstract.SlideTest): FILENAME = 'boxes.png' - def test_repr(self): + def test_repr(self) -> None: self.assertEqual(repr(self.osr), 'ImageSlide(%r)' % file_path('boxes.png')) - def test_metadata(self): + def test_metadata(self) -> None: self.assertEqual(self.osr.level_count, 1) self.assertEqual(self.osr.level_dimensions, ((300, 250),)) self.assertEqual(self.osr.dimensions, (300, 250)) @@ -106,7 +111,8 @@ def test_metadata(self): self.assertEqual(self.osr.properties, {}) self.assertEqual(self.osr.associated_images, {}) - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') self.assertEqual( len(self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile']), 588 @@ -115,37 +121,37 @@ def test_color_profile(self): len(self.osr.get_thumbnail((100, 100)).info['icc_profile']), 588 ) - def test_read_region(self): + def test_read_region(self) -> None: self.assertEqual( self.osr.read_region((-10, -10), 0, (400, 400)).size, (400, 400) ) - def test_read_region_size_dimension_zero(self): + def test_read_region_size_dimension_zero(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 0, (400, 0)).size, (400, 0)) - def test_read_region_bad_level(self): + def test_read_region_bad_level(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 1, (100, 100)) ) - def test_read_region_bad_size(self): + def test_read_region_bad_size(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 0, (400, -5)) ) - def test_thumbnail(self): + def test_thumbnail(self) -> None: self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83)) @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_set_cache(self): + def test_set_cache(self) -> None: self.osr.set_cache(OpenSlideCache(64 << 10)) self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400)) -class TestNoIccImage(_SlideTest, unittest.TestCase): +class TestNoIccImage(_Abstract.SlideTest): FILENAME = 'boxes-no-icc.png' - def test_color_profile(self): + def test_color_profile(self) -> None: self.assertIsNone(self.osr.color_profile) self.assertNotIn( 'icc_profile', self.osr.read_region((0, 0), 0, (100, 100)).info diff --git a/tests/test_openslide.py b/tests/test_openslide.py index b863fb97..2be656fd 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -22,6 +22,7 @@ from ctypes import ArgumentError import re import sys +from typing import Any import unittest from common import file_path @@ -37,31 +38,39 @@ class TestCache(unittest.TestCase): @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_create_cache(self): + def test_create_cache(self) -> None: OpenSlideCache(0) OpenSlideCache(1) OpenSlideCache(4 << 20) self.assertRaises(ArgumentError, lambda: OpenSlideCache(-1)) - self.assertRaises(ArgumentError, lambda: OpenSlideCache(1.3)) + self.assertRaises( + ArgumentError, lambda: OpenSlideCache(1.3) # type: ignore[arg-type] + ) class TestSlideWithoutOpening(unittest.TestCase): - def test_detect_format(self): + def test_detect_format(self) -> None: self.assertTrue(OpenSlide.detect_format(file_path('__missing_file')) is None) self.assertTrue(OpenSlide.detect_format(file_path('../setup.py')) is None) self.assertEqual( OpenSlide.detect_format(file_path('boxes.tiff')), 'generic-tiff' ) - def test_open(self): + def test_open(self) -> None: self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('__does_not_exist') ) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('setup.py') ) - self.assertRaises(ArgumentError, lambda: OpenSlide(None)) - self.assertRaises(ArgumentError, lambda: OpenSlide(3)) + self.assertRaises( + ArgumentError, + lambda: OpenSlide(None), # type: ignore[arg-type] + ) + self.assertRaises( + ArgumentError, + lambda: OpenSlide(3), # type: ignore[arg-type] + ) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('unopenable.tiff') ) @@ -70,18 +79,18 @@ def test_open(self): sys.getfilesystemencoding() == 'utf-8', 'Python filesystem encoding is not UTF-8', ) - def test_unicode_path(self): + def test_unicode_path(self) -> None: path = file_path('😐.svs') for arg in path, str(path): self.assertEqual(OpenSlide.detect_format(arg), 'aperio') self.assertEqual(OpenSlide(arg).dimensions, (16, 16)) - def test_unicode_path_bytes(self): + def test_unicode_path_bytes(self) -> None: arg = str(file_path('😐.svs')).encode('UTF-8') self.assertEqual(OpenSlide.detect_format(arg), 'aperio') self.assertEqual(OpenSlide(arg).dimensions, (16, 16)) - def test_operations_on_closed_handle(self): + def test_operations_on_closed_handle(self) -> None: osr = OpenSlide(file_path('boxes.tiff')) props = osr.properties associated = osr.associated_images @@ -91,28 +100,33 @@ def test_operations_on_closed_handle(self): self.assertRaises(ArgumentError, lambda: props['openslide.vendor']) self.assertRaises(ArgumentError, lambda: associated['label']) - def test_context_manager(self): + def test_context_manager(self) -> None: osr = OpenSlide(file_path('boxes.tiff')) with osr: self.assertEqual(osr.level_count, 4) self.assertRaises(ArgumentError, lambda: osr.level_count) -class _SlideTest: - def setUp(self): - self.osr = OpenSlide(file_path(self.FILENAME)) +class _Abstract: + # nested class to prevent the test runner from finding it + class SlideTest(unittest.TestCase): + FILENAME: str | None = None - def tearDown(self): - self.osr.close() + def setUp(self) -> None: + assert self.FILENAME is not None + self.osr = OpenSlide(file_path(self.FILENAME)) + def tearDown(self) -> None: + self.osr.close() -class TestSlide(_SlideTest, unittest.TestCase): + +class TestSlide(_Abstract.SlideTest): FILENAME = 'boxes.tiff' - def test_repr(self): + def test_repr(self) -> None: self.assertEqual(repr(self.osr), 'OpenSlide(%r)' % file_path('boxes.tiff')) - def test_basic_metadata(self): + def test_basic_metadata(self) -> None: self.assertEqual(self.osr.level_count, 4) self.assertEqual( self.osr.level_dimensions, ((300, 250), (150, 125), (75, 62), (37, 31)) @@ -128,7 +142,7 @@ def test_basic_metadata(self): self.assertEqual(self.osr.get_best_level_for_downsample(3), 1) self.assertEqual(self.osr.get_best_level_for_downsample(37), 3) - def test_properties(self): + def test_properties(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'generic-tiff') self.assertRaises(KeyError, lambda: self.osr.properties['__does_not_exist']) # test __len__ and __iter__ @@ -142,7 +156,8 @@ def test_properties(self): @unittest.skipUnless( lowlevel.read_icc_profile.available, "requires OpenSlide 4.0.0" ) - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') self.assertEqual( len(self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile']), 588 @@ -151,18 +166,18 @@ def test_color_profile(self): len(self.osr.get_thumbnail((100, 100)).info['icc_profile']), 588 ) - def test_read_region(self): + def test_read_region(self) -> None: self.assertEqual( self.osr.read_region((-10, -10), 1, (400, 400)).size, (400, 400) ) - def test_read_region_size_dimension_zero(self): + def test_read_region_size_dimension_zero(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 1, (400, 0)).size, (400, 0)) - def test_read_region_bad_level(self): + def test_read_region_bad_level(self) -> None: self.assertEqual(self.osr.read_region((0, 0), 4, (100, 100)).size, (100, 100)) - def test_read_region_bad_size(self): + def test_read_region_bad_size(self) -> None: self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 1, (400, -5)) ) @@ -170,26 +185,30 @@ def test_read_region_bad_size(self): @unittest.skipIf(sys.maxsize < 1 << 32, '32-bit Python') # Disabled to avoid OOM killer on small systems, since the stdlib # doesn't provide a way to find out how much RAM we have - def _test_read_region_2GB(self): + def _test_read_region_2GB(self) -> None: self.assertEqual( self.osr.read_region((1000, 1000), 0, (32768, 16384)).size, (32768, 16384) ) - def test_thumbnail(self): + def test_thumbnail(self) -> None: self.assertEqual(self.osr.get_thumbnail((100, 100)).size, (100, 83)) @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") - def test_set_cache(self): + def test_set_cache(self) -> None: self.osr.set_cache(OpenSlideCache(64 << 10)) self.assertEqual(self.osr.read_region((0, 0), 0, (400, 400)).size, (400, 400)) - self.assertRaises(TypeError, lambda: self.osr.set_cache(None)) - self.assertRaises(TypeError, lambda: self.osr.set_cache(3)) + self.assertRaises( + TypeError, lambda: self.osr.set_cache(None) # type: ignore[arg-type] + ) + self.assertRaises( + TypeError, lambda: self.osr.set_cache(3) # type: ignore[arg-type] + ) -class TestAperioSlide(_SlideTest, unittest.TestCase): +class TestAperioSlide(_Abstract.SlideTest): FILENAME = 'small.svs' - def test_associated_images(self): + def test_associated_images(self) -> None: self.assertEqual(self.osr.associated_images['thumbnail'].size, (16, 16)) self.assertRaises(KeyError, lambda: self.osr.associated_images['__missing']) # test __len__ and __iter__ @@ -198,7 +217,7 @@ def test_associated_images(self): len(self.osr.associated_images), ) - def mangle_repr(o): + def mangle_repr(o: Any) -> str: return re.sub('0x[0-9a-fA-F]+', '(mangled)', repr(o)) self.assertEqual( @@ -206,7 +225,7 @@ def mangle_repr(o): '<_AssociatedImageMap %s>' % mangle_repr(dict(self.osr.associated_images)), ) - def test_color_profile(self): + def test_color_profile(self) -> None: self.assertIsNone(self.osr.color_profile) self.assertNotIn( 'icc_profile', self.osr.read_region((0, 0), 0, (100, 100)).info @@ -220,10 +239,11 @@ def test_color_profile(self): @unittest.skipUnless( lowlevel.read_associated_image_icc_profile.available, "requires OpenSlide 4.0.0" ) -class TestDicomSlide(_SlideTest, unittest.TestCase): +class TestDicomSlide(_Abstract.SlideTest): FILENAME = 'boxes_0.dcm' - def test_color_profile(self): + def test_color_profile(self) -> None: + assert self.osr.color_profile is not None # for type inference self.assertEqual(self.osr.color_profile.profile.device_class, 'mntr') main_profile = self.osr.read_region((0, 0), 0, (100, 100)).info['icc_profile'] associated_profile = self.osr.associated_images['thumbnail'].info['icc_profile'] @@ -232,10 +252,10 @@ def test_color_profile(self): self.assertIs(main_profile, associated_profile) -class TestUnreadableSlide(_SlideTest, unittest.TestCase): +class TestUnreadableSlide(_Abstract.SlideTest): FILENAME = 'unreadable.svs' - def test_read_bad_region(self): + def test_read_bad_region(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'aperio') self.assertRaises( OpenSlideError, lambda: self.osr.read_region((0, 0), 0, (16, 16)) @@ -245,7 +265,7 @@ def test_read_bad_region(self): OpenSlideError, lambda: self.osr.properties['openslide.vendor'] ) - def test_read_bad_associated_image(self): + def test_read_bad_associated_image(self) -> None: self.assertEqual(self.osr.properties['openslide.vendor'], 'aperio') # Prints "JPEGLib: Bogus marker length." to stderr due to # https://github.com/openslide/openslide/issues/36