From 5f49c0173cd800b6ce0041615d65cb6d7d170fdb Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 19:05:43 -0700 Subject: [PATCH 1/6] Export type hints from openslide package Now that we've fully populated type hints, add py.typed marker (and also "Typing :: Typed" Trove classifier) so type checkers will use them. Signed-off-by: Benjamin Gilbert --- openslide/py.typed | 0 pyproject.toml | 1 + setup.py | 3 +++ 3 files changed, 4 insertions(+) create mode 100644 openslide/py.typed 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'], + }, ) From f9a42529ec6afc7a32547d4002b0a6292577ef3f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 19:54:59 -0700 Subject: [PATCH 2/6] tests: reparent abstract base classes as a member of an _Abstract class To prevent abstract base classes from being found by test discovery, we've been avoiding making them subclasses of TestCase, and then using multiple inheritance in the concrete subclasses. However, this confuses type checking when the ABC calls TestCase methods. Instead, make the ABC a TestCase subclass, but make it a member of another class so test discovery won't find it. Suggested-by: https://stackoverflow.com/a/50176291/981954 Signed-off-by: Benjamin Gilbert --- tests/test_deepzoom.py | 167 ++++++++++++++++++++------------------- tests/test_imageslide.py | 16 ++-- tests/test_openslide.py | 20 ++--- 3 files changed, 106 insertions(+), 97 deletions(-) diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index af1cd503..80f3e951 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -27,90 +27,95 @@ 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): + 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(_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..81f84921 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -80,15 +80,17 @@ 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): + def setUp(self): + self.osr = ImageSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self): + self.osr.close() -class TestImage(_SlideTest, unittest.TestCase): +class TestImage(_Abstract.SlideTest): FILENAME = 'boxes.png' def test_repr(self): @@ -142,7 +144,7 @@ def test_set_cache(self): 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): diff --git a/tests/test_openslide.py b/tests/test_openslide.py index b863fb97..55768fc5 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -98,15 +98,17 @@ def test_context_manager(self): 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): + def setUp(self): + self.osr = OpenSlide(file_path(self.FILENAME)) - def tearDown(self): - self.osr.close() + def tearDown(self): + self.osr.close() -class TestSlide(_SlideTest, unittest.TestCase): +class TestSlide(_Abstract.SlideTest): FILENAME = 'boxes.tiff' def test_repr(self): @@ -186,7 +188,7 @@ def test_set_cache(self): self.assertRaises(TypeError, lambda: self.osr.set_cache(3)) -class TestAperioSlide(_SlideTest, unittest.TestCase): +class TestAperioSlide(_Abstract.SlideTest): FILENAME = 'small.svs' def test_associated_images(self): @@ -220,7 +222,7 @@ 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): @@ -232,7 +234,7 @@ 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): From 26ef392f57f02765d8341458e82daef56742b6ae Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 22:37:00 -0700 Subject: [PATCH 3/6] tests: add type hints Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 2 +- tests/common.py | 4 +- tests/test_base.py | 4 +- tests/test_deepzoom.py | 32 ++++++++-------- tests/test_imageslide.py | 42 +++++++++++---------- tests/test_openslide.py | 80 ++++++++++++++++++++++++---------------- 6 files changed, 94 insertions(+), 70 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dca2fb10..d164f030 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,7 @@ repos: - id: mypy name: Check Python types additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|tests/.*|examples/deepzoom/.*)$" + exclude: "^(doc/.*|examples/deepzoom/.*)$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 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 80f3e951..1c67ab06 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -30,24 +30,26 @@ class _Abstract: # nested class to prevent the test runner from finding it class BoxesDeepZoomTest(unittest.TestCase): - def setUp(self): + 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): + def tearDown(self) -> None: self.osr.close() - def test_repr(self): + def test_repr(self) -> None: self.assertEqual( repr(self.dz), - ( - 'DeepZoomGenerator(%r, tile_size=254, overlap=1, ' - + 'limit_bounds=False)' - ) + 'DeepZoomGenerator(%r, tile_size=254, overlap=1, limit_bounds=False)' % self.osr, ) - def test_metadata(self): + def test_metadata(self) -> None: self.assertEqual(self.dz.level_count, 10) self.assertEqual(self.dz.tile_count, 11) self.assertEqual( @@ -81,31 +83,31 @@ def test_metadata(self): ), ) - def test_get_tile(self): + def test_get_tile(self) -> None: self.assertEqual(self.dz.get_tile(9, (1, 0)).size, (47, 250)) - def test_tile_color_profile(self): + 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): + 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): + 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): + 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): + def test_get_tile_dimensions(self) -> None: self.assertEqual(self.dz.get_tile_dimensions(9, (1, 0)), (47, 250)) - def test_get_dzi(self): + def test_get_dzi(self) -> None: self.assertTrue( 'http://schemas.microsoft.com/deepzoom/2008' in self.dz.get_dzi('jpeg') ) diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index 81f84921..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 @@ -83,20 +83,23 @@ def test_context_manager(self): class _Abstract: # nested class to prevent the test runner from finding it class SlideTest(unittest.TestCase): - def setUp(self): + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.FILENAME is not None self.osr = ImageSlide(file_path(self.FILENAME)) - def tearDown(self): + def tearDown(self) -> None: self.osr.close() 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)) @@ -108,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 @@ -117,29 +121,29 @@ 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)) @@ -147,7 +151,7 @@ def test_set_cache(self): 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 55768fc5..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,7 +100,7 @@ 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) @@ -101,20 +110,23 @@ def test_context_manager(self): class _Abstract: # nested class to prevent the test runner from finding it class SlideTest(unittest.TestCase): - def setUp(self): + FILENAME: str | None = None + + def setUp(self) -> None: + assert self.FILENAME is not None self.osr = OpenSlide(file_path(self.FILENAME)) - def tearDown(self): + def tearDown(self) -> None: self.osr.close() 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)) @@ -130,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__ @@ -144,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 @@ -153,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)) ) @@ -172,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(_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__ @@ -200,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( @@ -208,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 @@ -225,7 +242,8 @@ def test_color_profile(self): 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'] @@ -237,7 +255,7 @@ def test_color_profile(self): 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)) @@ -247,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 From 9eb3919eb49518ff7b8d6d2d913b2e15496ca64b Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 6 Oct 2024 18:26:31 -0700 Subject: [PATCH 4/6] examples/deepzoom: add type hints Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 4 +- examples/deepzoom/deepzoom_multiserver.py | 84 +++++++++++----- examples/deepzoom/deepzoom_server.py | 57 ++++++++--- examples/deepzoom/deepzoom_tile.py | 115 +++++++++++++++------- 4 files changed, 184 insertions(+), 76 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d164f030..293bbeb7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -58,8 +58,8 @@ repos: hooks: - id: mypy name: Check Python types - additional_dependencies: [openslide-bin, pillow, types-setuptools] - exclude: "^(doc/.*|examples/deepzoom/.*)$" + additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] + exclude: "^doc/.*$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 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() From 7c885b297c8bd8b17561e716759e7b90a230d77f Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Sun, 20 Oct 2024 08:58:41 -0700 Subject: [PATCH 5/6] doc: drop settings for unused Sphinx output formats Signed-off-by: Benjamin Gilbert --- doc/conf.py | 92 ----------------------------------------------------- 1 file changed, 92 deletions(-) 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 = { From 8acd4d9f64c83b622fa8f79b551f509bd6c63c1a Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 16 Oct 2024 08:51:44 -0700 Subject: [PATCH 6/6] doc: add type hints to Python sources Signed-off-by: Benjamin Gilbert --- .pre-commit-config.yaml | 1 - doc/jekyll_fix.py | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 293bbeb7..7d43bcbd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -59,7 +59,6 @@ repos: - id: mypy name: Check Python types additional_dependencies: [flask, openslide-bin, pillow, types-setuptools] - exclude: "^doc/.*$" - repo: https://github.com/rstcheck/rstcheck rev: v6.2.4 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)