From e84d6710c0c9b69191a4a8ef7e05fe45224fb187 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Apr 2026 16:37:34 -0500 Subject: [PATCH 1/5] Quotation cleanups Signed-off-by: Benjamin Gilbert --- .github/maintainer/mkmaintainer.py | 4 ++-- examples/deepzoom/deepzoom_tile.py | 2 +- openslide/__init__.py | 22 +++++++++++----------- openslide/deepzoom.py | 4 ++-- openslide/lowlevel.py | 30 +++++++++++++++--------------- tests/test_base.py | 2 +- tests/test_deepzoom.py | 2 +- tests/test_imageslide.py | 2 +- tests/test_openslide.py | 8 ++++---- 9 files changed, 38 insertions(+), 38 deletions(-) diff --git a/.github/maintainer/mkmaintainer.py b/.github/maintainer/mkmaintainer.py index 0fba403e..9ff9a85d 100755 --- a/.github/maintainer/mkmaintainer.py +++ b/.github/maintainer/mkmaintainer.py @@ -23,5 +23,5 @@ 'labels': ','.join(info.get('labels', [])), } ) - url = f"https://github.com/{info['repo']}/issues/new?{args}" - fh.write(f"- [{info['link-text']}]({url})\n") + url = f'https://github.com/{info["repo"]}/issues/new?{args}' + fh.write(f'- [{info["link-text"]}]({url})\n') diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 65b75b3e..b14dfbb4 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -231,7 +231,7 @@ def _tile_done(self) -> None: count, total = self._processed, self._dz.tile_count if count % 100 == 0 or count == total: print( - "Tiling %s: wrote %d/%d tiles" + 'Tiling %s: wrote %d/%d tiles' % (self._associated or 'slide', count, total), end='\r', file=sys.stderr, diff --git a/openslide/__init__.py b/openslide/__init__.py index 9332d242..6710d39f 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -457,9 +457,9 @@ def read_region( if self._image is None: raise ValueError('Cannot read from a closed slide') if level != 0: - raise OpenSlideError("Invalid level") + raise OpenSlideError('Invalid level') if ['fail' for s in size if s < 0]: - raise OpenSlideError(f"Size {size} must be non-negative") + raise OpenSlideError(f'Size {size} must be non-negative') # Any corner of the requested region may be outside the bounds of # the image. Create a transparent tile of the correct size and # paste the valid part of the region into the correct location. @@ -470,7 +470,7 @@ def read_region( max(0, min(l + s - 1, limit - 1)) for l, s, limit in zip(location, size, self._image.size) ] - tile = Image.new("RGBA", size, (0,) * 4) + tile = Image.new('RGBA', size, (0,) * 4) if not [ 'fail' for tl, br in zip(image_topleft, image_bottomright) if br - tl < 0 ]: # "< 0" not a typo @@ -500,12 +500,12 @@ def open_slide(filename: lowlevel.Filename) -> OpenSlide | ImageSlide: if __name__ == '__main__': import sys - print("OpenSlide vendor:", OpenSlide.detect_format(sys.argv[1])) - print("PIL format:", ImageSlide.detect_format(sys.argv[1])) + print('OpenSlide vendor:', OpenSlide.detect_format(sys.argv[1])) + print('PIL format:', ImageSlide.detect_format(sys.argv[1])) with open_slide(sys.argv[1]) as _slide: - print("Dimensions:", _slide.dimensions) - print("Levels:", _slide.level_count) - print("Level dimensions:", _slide.level_dimensions) - print("Level downsamples:", _slide.level_downsamples) - print("Properties:", _slide.properties) - print("Associated images:", _slide.associated_images) + print('Dimensions:', _slide.dimensions) + print('Levels:', _slide.level_count) + print('Level dimensions:', _slide.level_dimensions) + print('Level downsamples:', _slide.level_downsamples) + print('Properties:', _slide.properties) + print('Associated images:', _slide.associated_images) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index fe690b44..60db6622 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -208,10 +208,10 @@ def _get_tile_info( ) -> tuple[tuple[tuple[int, int], int, tuple[int, int]], tuple[int, int]]: # Check parameters if dz_level < 0 or dz_level >= self._dz_levels: - raise ValueError("Invalid level") + raise ValueError('Invalid level') for t, t_lim in zip(t_location, self._t_dimensions[dz_level]): if t < 0 or t >= t_lim: - raise ValueError("Invalid address") + raise ValueError('Invalid address') # Get preferred slide level slide_level = self._slide_from_dz_level[dz_level] diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index f4304829..1d6d94ba 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -89,10 +89,10 @@ def try_load(names: list[str]) -> CDLL: except FileNotFoundError as exc: raise ModuleNotFoundError( "Couldn't locate OpenSlide DLL. " - "Try `pip install openslide-bin`, " + 'Try `pip install openslide-bin`, ' "or if you're using an OpenSlide binary package, " "ensure you've called os.add_dll_directory(). " - "https://openslide.org/api/python/#installing" + 'https://openslide.org/api/python/#installing' ) from exc elif platform.system() == 'Darwin': try: @@ -107,8 +107,8 @@ def try_load(names: list[str]) -> CDLL: if lib is None: raise ModuleNotFoundError( "Couldn't locate OpenSlide dylib. " - "Try `pip install openslide-bin`. " - "https://openslide.org/api/python/#installing" + 'Try `pip install openslide-bin`. ' + 'https://openslide.org/api/python/#installing' ) from exc return cdll.LoadLibrary(lib) else: @@ -117,8 +117,8 @@ def try_load(names: list[str]) -> CDLL: except OSError as exc: raise ModuleNotFoundError( "Couldn't locate OpenSlide shared library. " - "Try `pip install openslide-bin`. " - "https://openslide.org/api/python/#installing" + 'Try `pip install openslide-bin`. ' + 'https://openslide.org/api/python/#installing' ) from exc @@ -170,11 +170,11 @@ def invalidate(self) -> None: @classmethod def from_param(cls, obj: _OpenSlide) -> _OpenSlide: if obj.__class__ != cls: - raise ValueError("Not an OpenSlide reference") + raise ValueError('Not an OpenSlide reference') if not obj._as_parameter_: - raise ValueError("Passing undefined slide object") + raise ValueError('Passing undefined slide object') if not obj._valid: - raise ValueError("Passing closed slide object") + raise ValueError('Passing closed slide object') return obj @@ -193,9 +193,9 @@ def __del__(self) -> None: @classmethod def from_param(cls, obj: _OpenSlideCache) -> _OpenSlideCache: if obj.__class__ != cls: - raise ValueError("Not an OpenSlide cache reference") + raise ValueError('Not an OpenSlide cache reference') if not obj._as_parameter_: - raise ValueError("Passing undefined cache object") + raise ValueError('Passing undefined cache object') return obj @@ -247,7 +247,7 @@ def from_param(cls, obj: int) -> c_size_t: def _load_image(buf: _Buffer, size: tuple[int, int]) -> Image.Image: - '''buf must be a mutable buffer.''' + """buf must be a mutable buffer.""" _convert.argb2rgba(buf) return Image.frombuffer('RGBA', size, buf, 'raw', 'RGBA', 0, 1) @@ -255,7 +255,7 @@ def _load_image(buf: _Buffer, size: tuple[int, int]) -> Image.Image: # check for errors opening an image file and wrap the resulting handle def _check_open(result: int | None, _func: Any, _args: Any) -> _OpenSlide: if result is None: - raise OpenSlideUnsupportedFormatError("Unsupported or missing image file") + raise OpenSlideUnsupportedFormatError('Unsupported or missing image file') slide = _OpenSlide(c_void_p(result)) err = get_error(slide) if err is not None: @@ -303,7 +303,7 @@ def _check_name_list(result: _Pointer[c_char_p], func: Any, args: Any) -> list[s class _FunctionUnavailable: - '''Standin for a missing optional function. Fails when called.''' + """Standin for a missing optional function. Fails when called.""" def __init__(self, minimum_version: str): self._minimum_version = minimum_version @@ -434,7 +434,7 @@ def read_region( # OpenSlide would catch this, but not before we tried to allocate # a negative-size buffer raise OpenSlideError( - "negative width (%d) or negative height (%d) not allowed" % (w, h) + 'negative width (%d) or negative height (%d) not allowed' % (w, h) ) if w == 0 or h == 0: # Image.frombuffer() would raise an exception diff --git a/tests/test_base.py b/tests/test_base.py index 9b3b3867..4105e66b 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -34,7 +34,7 @@ def test_open_slide(self) -> None: self.assertTrue(isinstance(osr, ImageSlide)) def test_lowlevel_available(self) -> None: - '''Ensure all exported functions have an 'available' attribute.''' + """Ensure all exported functions have an 'available' attribute.""" for name in dir(lowlevel): attr = getattr(lowlevel, name) # ignore classes and unexported functions diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index 33b98688..0387d698 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -87,7 +87,7 @@ def test_get_tile(self) -> None: 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.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: diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index dd3fe6f9..e7b4d061 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -141,7 +141,7 @@ def test_read_region_bad_size(self) -> None: 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") + @unittest.skipUnless(lowlevel.cache_create.available, 'requires OpenSlide 4.0.0') 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)) diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 26077efe..7012f369 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -36,7 +36,7 @@ class TestCache(unittest.TestCase): - @unittest.skipUnless(lowlevel.cache_create.available, "requires OpenSlide 4.0.0") + @unittest.skipUnless(lowlevel.cache_create.available, 'requires OpenSlide 4.0.0') def test_create_cache(self) -> None: OpenSlideCache(0) OpenSlideCache(1) @@ -153,7 +153,7 @@ def test_properties(self) -> None: ) @unittest.skipUnless( - lowlevel.read_icc_profile.available, "requires OpenSlide 4.0.0" + lowlevel.read_icc_profile.available, 'requires OpenSlide 4.0.0' ) def test_color_profile(self) -> None: assert self.osr.color_profile is not None # for type inference @@ -192,7 +192,7 @@ def _test_read_region_2GB(self) -> None: 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") + @unittest.skipUnless(lowlevel.cache_create.available, 'requires OpenSlide 4.0.0') 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)) @@ -236,7 +236,7 @@ def test_color_profile(self) -> None: # Requires DICOM support in OpenSlide. Use associated image ICC support as # a proxy. @unittest.skipUnless( - lowlevel.read_associated_image_icc_profile.available, "requires OpenSlide 4.0.0" + lowlevel.read_associated_image_icc_profile.available, 'requires OpenSlide 4.0.0' ) class TestDicomSlide(_Abstract.SlideTest): FILENAME = 'boxes_0.dcm' From cac2c95350f616aa7f21cbbe43daef8a1695ad19 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Apr 2026 16:43:17 -0500 Subject: [PATCH 2/5] Autofix and reformat with Ruff Signed-off-by: Benjamin Gilbert --- doc/jekyll_fix.py | 2 +- examples/deepzoom/deepzoom_multiserver.py | 4 ++-- examples/deepzoom/deepzoom_server.py | 4 ++-- examples/deepzoom/deepzoom_tile.py | 2 +- openslide/__init__.py | 10 +++------- openslide/deepzoom.py | 8 +------- tests/common.py | 2 +- tests/test_imageslide.py | 2 +- tests/test_openslide.py | 9 ++++++--- 9 files changed, 18 insertions(+), 25 deletions(-) diff --git a/doc/jekyll_fix.py b/doc/jekyll_fix.py index d7fe8bf2..b9547fab 100644 --- a/doc/jekyll_fix.py +++ b/doc/jekyll_fix.py @@ -38,7 +38,7 @@ } FILES = { # Added in Sphinx 5.0.0, scheduled to be removed in Sphinx 6 - 'static/_sphinx_javascript_frameworks_compat.js': 'static/sphinx_javascript_frameworks_compat.js', # noqa: E501 + 'static/_sphinx_javascript_frameworks_compat.js': 'static/sphinx_javascript_frameworks_compat.js', } REWRITE_EXTENSIONS = {'.html', '.js'} diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 0f43680e..661b8536 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -31,8 +31,8 @@ from typing import TYPE_CHECKING, Any, Literal import zlib -from PIL import Image, ImageCms from flask import Flask, Response, abort, make_response, render_template, url_for +from PIL import Image, ImageCms if TYPE_CHECKING: # Python 3.10+ @@ -41,7 +41,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] import openslide else: import openslide diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index bde5a879..d33ec70b 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -31,8 +31,8 @@ from unicodedata import normalize import zlib -from PIL import Image, ImageCms from flask import Flask, Response, abort, make_response, render_template, url_for +from PIL import Image, ImageCms if TYPE_CHECKING: # Python 3.10+ @@ -41,7 +41,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] import openslide else: import openslide diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index b14dfbb4..3d40284f 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -47,7 +47,7 @@ if os.name == 'nt': _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] import openslide else: import openslide diff --git a/openslide/__init__.py b/openslide/__init__.py index 6710d39f..1148f01c 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -35,16 +35,12 @@ from openslide import lowlevel # Re-exports for the benefit of library users -from openslide._version import ( # noqa: F401 module-imported-but-unused - __version__ as __version__, -) +from openslide._version import __version__ as __version__ +from openslide.lowlevel import OpenSlideError as OpenSlideError from openslide.lowlevel import ( OpenSlideUnsupportedFormatError as OpenSlideUnsupportedFormatError, ) -from openslide.lowlevel import ( # noqa: F401 module-imported-but-unused - OpenSlideVersionError as OpenSlideVersionError, -) -from openslide.lowlevel import OpenSlideError as OpenSlideError +from openslide.lowlevel import OpenSlideVersionError as OpenSlideVersionError __library_version__ = lowlevel.get_version() diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 60db6622..6489224b 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -147,13 +147,7 @@ def tiles(z_lim: int) -> int: ) def __repr__(self) -> str: - return '{}({!r}, tile_size={!r}, overlap={!r}, limit_bounds={!r})'.format( - self.__class__.__name__, - self._osr, - self._z_t_downsample, - self._z_overlap, - self._limit_bounds, - ) + return f'{self.__class__.__name__}({self._osr!r}, tile_size={self._z_t_downsample!r}, overlap={self._z_overlap!r}, limit_bounds={self._limit_bounds!r})' @property def level_count(self) -> int: diff --git a/tests/common.py b/tests/common.py index aaa86f7f..a042732c 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,7 +29,7 @@ # environment. _dll_path = os.getenv('OPENSLIDE_PATH') if _dll_path is not None: - with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] # noqa: E501 + with os.add_dll_directory(_dll_path): # type: ignore[attr-defined,unused-ignore] import openslide # noqa: F401 module-imported-but-unused diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index e7b4d061..af9c9a9a 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -21,8 +21,8 @@ import sys import unittest -from PIL import Image from common import file_path +from PIL import Image from openslide import ImageSlide, OpenSlideCache, OpenSlideError, lowlevel diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 7012f369..5b7fdaa1 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -43,7 +43,8 @@ def test_create_cache(self) -> None: OpenSlideCache(4 << 20) self.assertRaises(ArgumentError, lambda: OpenSlideCache(-1)) self.assertRaises( - ArgumentError, lambda: OpenSlideCache(1.3) # type: ignore[arg-type] + ArgumentError, + lambda: OpenSlideCache(1.3), # type: ignore[arg-type] ) @@ -197,10 +198,12 @@ 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) # type: ignore[arg-type] + TypeError, + lambda: self.osr.set_cache(None), # type: ignore[arg-type] ) self.assertRaises( - TypeError, lambda: self.osr.set_cache(3) # type: ignore[arg-type] + TypeError, + lambda: self.osr.set_cache(3), # type: ignore[arg-type] ) From 7ba6e906b8d4d131cb9b2308d311f274c0ac0255 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Apr 2026 17:00:59 -0500 Subject: [PATCH 3/5] Fix Ruff lints Signed-off-by: Benjamin Gilbert --- examples/deepzoom/deepzoom_multiserver.py | 2 +- examples/deepzoom/deepzoom_server.py | 2 +- examples/deepzoom/deepzoom_tile.py | 5 ++-- openslide/__init__.py | 17 +++++++++---- openslide/deepzoom.py | 30 ++++++++++++++--------- openslide/lowlevel.py | 4 +-- tests/test_deepzoom.py | 3 +-- tests/test_imageslide.py | 4 +-- tests/test_openslide.py | 12 ++++----- 9 files changed, 45 insertions(+), 34 deletions(-) diff --git a/examples/deepzoom/deepzoom_multiserver.py b/examples/deepzoom/deepzoom_multiserver.py index 661b8536..2dc00e99 100755 --- a/examples/deepzoom/deepzoom_multiserver.py +++ b/examples/deepzoom/deepzoom_multiserver.py @@ -192,7 +192,7 @@ def tile(path: str, level: int, col: int, row: int, format: str) -> Response: icc_profile=tile.info.get('icc_profile'), ) resp = make_response(buf.getvalue()) - resp.mimetype = 'image/%s' % format + resp.mimetype = f'image/{format}' return resp return app diff --git a/examples/deepzoom/deepzoom_server.py b/examples/deepzoom/deepzoom_server.py index d33ec70b..43bb7c54 100755 --- a/examples/deepzoom/deepzoom_server.py +++ b/examples/deepzoom/deepzoom_server.py @@ -193,7 +193,7 @@ def tile(slug: str, level: int, col: int, row: int, format: str) -> Response: icc_profile=tile.info.get('icc_profile'), ) resp = make_response(buf.getvalue()) - resp.mimetype = 'image/%s' % format + resp.mimetype = f'image/{format}' return resp return app diff --git a/examples/deepzoom/deepzoom_tile.py b/examples/deepzoom/deepzoom_tile.py index 3d40284f..3da7b810 100755 --- a/examples/deepzoom/deepzoom_tile.py +++ b/examples/deepzoom/deepzoom_tile.py @@ -231,8 +231,7 @@ def _tile_done(self) -> None: count, total = self._processed, self._dz.tile_count if count % 100 == 0 or count == total: print( - 'Tiling %s: wrote %d/%d tiles' - % (self._associated or 'slide', count, total), + f'Tiling {self._associated or "slide"}: wrote {count}/{total} tiles', end='\r', file=sys.stderr, ) @@ -320,7 +319,7 @@ def _url_for(self, associated: str | None) -> str: base = VIEWER_SLIDE_NAME else: base = self._slugify(associated) - return '%s.dzi' % base + return f'{base}.dzi' def _write_html(self) -> None: import jinja2 diff --git a/openslide/__init__.py b/openslide/__init__.py index 1148f01c..2683b9f9 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -169,7 +169,9 @@ def get_thumbnail(self, size: tuple[int, int]) -> Image.Image: """Return a PIL.Image containing an RGB thumbnail of the image. size: the maximum size of the thumbnail.""" - downsample = max(dim / thumb for dim, thumb in zip(self.dimensions, size)) + downsample = max( + dim / thumb for dim, thumb in zip(self.dimensions, size, strict=True) + ) level = self.get_best_level_for_downsample(downsample) tile = self.read_region((0, 0), level, self.level_dimensions[level]) # Apply on solid background @@ -460,20 +462,25 @@ def read_region( # the image. Create a transparent tile of the correct size and # paste the valid part of the region into the correct location. image_topleft = [ - max(0, min(l, limit - 1)) for l, limit in zip(location, self._image.size) + max(0, min(l, limit - 1)) + for l, limit in zip(location, self._image.size, strict=True) ] image_bottomright = [ max(0, min(l + s - 1, limit - 1)) - for l, s, limit in zip(location, size, self._image.size) + for l, s, limit in zip(location, size, self._image.size, strict=True) ] tile = Image.new('RGBA', size, (0,) * 4) if not [ - 'fail' for tl, br in zip(image_topleft, image_bottomright) if br - tl < 0 + 'fail' + for tl, br in zip(image_topleft, image_bottomright, strict=True) + if br - tl < 0 ]: # "< 0" not a typo # Crop size is greater than zero in both dimensions. # PIL thinks the bottom right is the first *excluded* pixel crop_box = tuple(image_topleft + [d + 1 for d in image_bottomright]) - tile_offset = tuple(il - l for il, l in zip(image_topleft, location)) + tile_offset = tuple( + il - l for il, l in zip(image_topleft, location, strict=True) + ) assert len(crop_box) == 4 and len(tile_offset) == 2 crop = self._image.crop(crop_box) tile.paste(crop, tile_offset) diff --git a/openslide/deepzoom.py b/openslide/deepzoom.py index 6489224b..27613278 100644 --- a/openslide/deepzoom.py +++ b/openslide/deepzoom.py @@ -88,13 +88,15 @@ def __init__( # Slide level dimensions scale factor in each axis size_scale = tuple( int(osr.properties.get(prop, l0_lim)) / l0_lim - for prop, l0_lim in zip(self.BOUNDS_SIZE_PROPS, osr.dimensions) + for prop, l0_lim in zip( + self.BOUNDS_SIZE_PROPS, osr.dimensions, strict=True + ) ) # Dimensions of active area self._l_dimensions = tuple( tuple( - int(math.ceil(l_lim * scale)) - for l_lim, scale in zip(l_size, size_scale) + math.ceil(l_lim * scale) + for l_lim, scale in zip(l_size, size_scale, strict=True) ) for l_size in osr.level_dimensions ) @@ -106,14 +108,14 @@ def __init__( z_size = self._l0_dimensions z_dimensions = [z_size] while z_size[0] > 1 or z_size[1] > 1: - z_size = tuple(max(1, int(math.ceil(z / 2))) for z in z_size) + z_size = tuple(max(1, math.ceil(z / 2)) for z in z_size) z_dimensions.append(z_size) # Narrow the type, for self.level_dimensions self._z_dimensions = self._pairs_from_n_tuples(tuple(reversed(z_dimensions))) # Tile def tiles(z_lim: int) -> int: - return int(math.ceil(z_lim / self._z_t_downsample)) + return math.ceil(z_lim / self._z_t_downsample) self._t_dimensions = tuple( (tiles(z_w), tiles(z_h)) for z_w, z_h in self._z_dimensions @@ -203,7 +205,7 @@ def _get_tile_info( # Check parameters if dz_level < 0 or dz_level >= self._dz_levels: raise ValueError('Invalid level') - for t, t_lim in zip(t_location, self._t_dimensions[dz_level]): + for t, t_lim in zip(t_location, self._t_dimensions[dz_level], strict=True): if t < 0 or t >= t_lim: raise ValueError('Invalid address') @@ -214,14 +216,18 @@ def _get_tile_info( z_overlap_tl = tuple(self._z_overlap * int(t != 0) for t in t_location) z_overlap_br = tuple( self._z_overlap * int(t != t_lim - 1) - for t, t_lim in zip(t_location, self.level_tiles[dz_level]) + for t, t_lim in zip(t_location, self.level_tiles[dz_level], strict=True) ) # Get final size of the tile z_size = tuple( min(self._z_t_downsample, z_lim - self._z_t_downsample * t) + z_tl + z_br for t, z_lim, z_tl, z_br in zip( - t_location, self._z_dimensions[dz_level], z_overlap_tl, z_overlap_br + t_location, + self._z_dimensions[dz_level], + z_overlap_tl, + z_overlap_br, + strict=True, ) ) @@ -229,16 +235,18 @@ def _get_tile_info( z_location = [self._z_from_t(t) for t in t_location] l_location = [ self._l_from_z(dz_level, z - z_tl) - for z, z_tl in zip(z_location, z_overlap_tl) + for z, z_tl in zip(z_location, z_overlap_tl, strict=True) ] # Round location down and size up, and add offset of active area l0_location = tuple( int(self._l0_from_l(slide_level, l) + l0_off) - for l, l0_off in zip(l_location, self._l0_offset) + for l, l0_off in zip(l_location, self._l0_offset, strict=True) ) l_size = tuple( int(min(math.ceil(self._l_from_z(dz_level, dz)), l_lim - math.ceil(l))) - for l, dz, l_lim in zip(l_location, z_size, self._l_dimensions[slide_level]) + for l, dz, l_lim in zip( + l_location, z_size, self._l_dimensions[slide_level], strict=True + ) ) # Return read_region() parameters plus tile size for final scaling diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 1d6d94ba..64d9697d 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -379,7 +379,7 @@ def decorator(fn: Callable[_P, _T]) -> _Func[_P, _T]: 'openslide_detect_vendor', c_char_p, [_filename_p], _check_string ) except AttributeError: - raise OpenSlideVersionError('3.4.0') + raise OpenSlideVersionError('3.4.0') from None open: _Func[[Filename], _OpenSlide] = _func( 'openslide_open', c_void_p, [_filename_p], _check_open @@ -434,7 +434,7 @@ def read_region( # OpenSlide would catch this, but not before we tried to allocate # a negative-size buffer raise OpenSlideError( - 'negative width (%d) or negative height (%d) not allowed' % (w, h) + f'negative width ({w}) or negative height ({h}) not allowed' ) if w == 0 or h == 0: # Image.frombuffer() would raise an exception diff --git a/tests/test_deepzoom.py b/tests/test_deepzoom.py index 0387d698..b434960b 100644 --- a/tests/test_deepzoom.py +++ b/tests/test_deepzoom.py @@ -44,8 +44,7 @@ def tearDown(self) -> None: def test_repr(self) -> None: self.assertEqual( repr(self.dz), - 'DeepZoomGenerator(%r, tile_size=254, overlap=1, limit_bounds=False)' - % self.osr, + f'DeepZoomGenerator({self.osr!r}, tile_size=254, overlap=1, limit_bounds=False)', ) def test_metadata(self) -> None: diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index af9c9a9a..8a26850c 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -42,7 +42,7 @@ def test_open_image(self) -> None: with Image.open(file_path('boxes.png')) as img: with ImageSlide(img) as osr: self.assertEqual(osr.dimensions, (300, 250)) - self.assertEqual(repr(osr), 'ImageSlide(%r)' % img) + self.assertEqual(repr(osr), f'ImageSlide({img!r})') @unittest.skipUnless( sys.getfilesystemencoding() == 'utf-8', @@ -96,7 +96,7 @@ class TestImage(_Abstract.SlideTest): FILENAME = 'boxes.png' def test_repr(self) -> None: - self.assertEqual(repr(self.osr), 'ImageSlide(%r)' % file_path('boxes.png')) + self.assertEqual(repr(self.osr), f'ImageSlide({file_path("boxes.png")!r})') def test_metadata(self) -> None: self.assertEqual(self.osr.level_count, 1) diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 5b7fdaa1..00522813 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -124,7 +124,7 @@ class TestSlide(_Abstract.SlideTest): FILENAME = 'boxes.tiff' def test_repr(self) -> None: - self.assertEqual(repr(self.osr), 'OpenSlide(%r)' % file_path('boxes.tiff')) + self.assertEqual(repr(self.osr), f'OpenSlide({file_path("boxes.tiff")!r})') def test_basic_metadata(self) -> None: self.assertEqual(self.osr.level_count, 4) @@ -146,11 +146,9 @@ 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__ + self.assertEqual(len(list(self.osr.properties)), len(self.osr.properties)) self.assertEqual( - len([v for v in self.osr.properties]), len(self.osr.properties) - ) - self.assertEqual( - repr(self.osr.properties), '<_PropertyMap %r>' % dict(self.osr.properties) + repr(self.osr.properties), f'<_PropertyMap {dict(self.osr.properties)!r}>' ) @unittest.skipUnless( @@ -215,7 +213,7 @@ def test_associated_images(self) -> None: self.assertRaises(KeyError, lambda: self.osr.associated_images['__missing']) # test __len__ and __iter__ self.assertEqual( - len([v for v in self.osr.associated_images]), + len(list(self.osr.associated_images)), len(self.osr.associated_images), ) @@ -224,7 +222,7 @@ def mangle_repr(o: Any) -> str: self.assertEqual( mangle_repr(self.osr.associated_images), - '<_AssociatedImageMap %s>' % mangle_repr(dict(self.osr.associated_images)), + f'<_AssociatedImageMap {mangle_repr(dict(self.osr.associated_images))}>', ) def test_color_profile(self) -> None: From 8590bc2f95e96c03aae06cefd4a729c3669c8873 Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Apr 2026 17:02:35 -0500 Subject: [PATCH 4/5] pre-commit: switch to Ruff Drop blacken-docs; it doesn't appear to work and I'm not sure if it ever did. Ruff can't yet replace it, but it's easier not to use two different formatters in the same repo. Signed-off-by: Benjamin Gilbert --- .github/maintainer/README.md | 2 +- .github/maintainer/update-python.md | 2 +- .pre-commit-config.yaml | 42 +++++------------------------ pyproject.toml | 27 ++++++++----------- 4 files changed, 19 insertions(+), 54 deletions(-) diff --git a/.github/maintainer/README.md b/.github/maintainer/README.md index 9cf08722..9b22579f 100644 --- a/.github/maintainer/README.md +++ b/.github/maintainer/README.md @@ -3,4 +3,4 @@ ## Maintainer issue templates - [Release checklist](https://github.com/openslide/openslide-python/issues/new?title=Release+X.Y.Z&body=%23+OpenSlide+Python+release+process%0A%0A-+%5B+%5D+Update+%60CHANGELOG.md%60+and+version+in+%60openslide%2F_version.py%60%0A-+%5B+%5D+Create+and+push+signed+tag%0A-+%5B+%5D+Find+the+%5Bworkflow+run%5D%28https%3A%2F%2Fgithub.com%2Fopenslide%2Fopenslide-python%2Factions%2Fworkflows%2Fpython.yml%29+for+the+tag%0A++-+%5B+%5D+Once+the+build+finishes%2C+approve+deployment+to+PyPI%0A++-+%5B+%5D+Download+the+docs+artifact%0A-+%5B+%5D+Verify+that+the+workflow+created+a+%5BPyPI+release%5D%28https%3A%2F%2Fpypi.org%2Fp%2Fopenslide-python%29+with+a+description%2C+source+tarball%2C+and+wheels%0A-+%5B+%5D+Verify+that+the+workflow+created+a+%5BGitHub+release%5D%28https%3A%2F%2Fgithub.com%2Fopenslide%2Fopenslide-python%2Freleases%29+with+release+notes%2C+source+tarballs%2C+and+wheels%0A-+%5B+%5D+%60cd%60+into+website+checkout%3B+%60rm+-r+api%2Fpython+%26%26+unzip+%2Fpath%2Fto%2Fdownloaded%2Fopenslide-python-docs.zip+%26%26+mv+openslide-python-docs-%2A+api%2Fpython%60%0A-+%5B+%5D+Update+website%3A+%60_data%2Freleases.yaml%60%2C+%60_includes%2Fnews.md%60%0A-+%5B+%5D+Start+a+%5BCI+build%5D%28https%3A%2F%2Fgithub.com%2Fopenslide%2Fopenslide.github.io%2Factions%2Fworkflows%2Fretile.yml%29+of+the+demo+site%0A-+%5B+%5D+Update+Ubuntu+PPA%0A-+%5B+%5D+Update+Fedora+and+possibly+EPEL+packages%0A-+%5B+%5D+Check+that+%5BCopr+package%5D%28https%3A%2F%2Fcopr.fedorainfracloud.org%2Fcoprs%2Fg%2Fopenslide%2Fopenslide%2Fbuilds%2F%29+built+successfully%0A-+%5B+%5D+Send+mail+to+-announce+and+-users%0A-+%5B+%5D+Post+to+%5Bforum.image.sc%5D%28https%3A%2F%2Fforum.image.sc%2Fc%2Fannouncements%2F10%29%0A-+%5B+%5D+Update+MacPorts+package&labels=release) -- [Update checklist for a Python minor release](https://github.com/openslide/openslide-python/issues/new?title=Add+Python+X.Y&body=%23+Adding+a+new+Python+release%0A%0A-+Update+Git+main%0A++-+%5B+%5D+%60git+checkout+main%60%0A++-+%5B+%5D+In+%60pyproject.toml%60%2C+add+classifier+for+new+Python+version+and+update+%60tool.black.target-version%60%0A++-+%5B+%5D+In+%60.github%2Fworkflows%2Fpython.yml%60%2C+update+hardcoded+Python+versions+and+add+new+version+to+lists%0A++-+%5B+%5D+Commit+and+open+a+PR%0A++-+%5B+%5D+Merge+the+PR+when+CI+passes%0A++-+%5B+%5D+Add+new+Python+jobs+to+%5Bbranch+protection+required+checks%5D%28https%3A%2F%2Fgithub.com%2Fopenslide%2Fopenslide-python%2Fsettings%2Fbranches%29%0A-+%5B+%5D+Update+MacPorts+package%0A-+%5B+%5D+Update+website%3A+Python+3+versions+in+%60download%2Findex.md%60&labels=release) +- [Update checklist for a Python minor release](https://github.com/openslide/openslide-python/issues/new?title=Add+Python+X.Y&body=%23+Adding+a+new+Python+release%0A%0A-+Update+Git+main%0A++-+%5B+%5D+%60git+checkout+main%60%0A++-+%5B+%5D+In+%60pyproject.toml%60%2C+add+classifier+for+new+Python+version%0A++-+%5B+%5D+In+%60.github%2Fworkflows%2Fpython.yml%60%2C+update+hardcoded+Python+versions+and+add+new+version+to+lists%0A++-+%5B+%5D+Commit+and+open+a+PR%0A++-+%5B+%5D+Merge+the+PR+when+CI+passes%0A++-+%5B+%5D+Add+new+Python+jobs+to+%5Bbranch+protection+required+checks%5D%28https%3A%2F%2Fgithub.com%2Fopenslide%2Fopenslide-python%2Fsettings%2Fbranches%29%0A-+%5B+%5D+Update+MacPorts+package%0A-+%5B+%5D+Update+website%3A+Python+3+versions+in+%60download%2Findex.md%60&labels=release) diff --git a/.github/maintainer/update-python.md b/.github/maintainer/update-python.md index c1a8080c..25735026 100644 --- a/.github/maintainer/update-python.md +++ b/.github/maintainer/update-python.md @@ -9,7 +9,7 @@ labels: [release] - Update Git main - [ ] `git checkout main` - - [ ] In `pyproject.toml`, add classifier for new Python version and update `tool.black.target-version` + - [ ] In `pyproject.toml`, add classifier for new Python version - [ ] In `.github/workflows/python.yml`, update hardcoded Python versions and add new version to lists - [ ] Commit and open a PR - [ ] Merge the PR when CI passes diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 130f4829..22adc2e7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,43 +15,13 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.10 hooks: - - id: pyupgrade - name: Modernize python code - args: ["--py310-plus"] - - - repo: https://github.com/PyCQA/isort - rev: 8.0.1 - hooks: - - id: isort - name: Reorder python imports with isort - - - repo: https://github.com/psf/black - rev: 26.3.1 - hooks: - - id: black - name: Format python code with black - - - repo: https://github.com/asottile/blacken-docs - rev: 1.20.0 - hooks: - - id: blacken-docs - name: Format python code in documentation - - - repo: https://github.com/asottile/yesqa - rev: v1.5.0 - hooks: - - id: yesqa - additional_dependencies: [flake8-bugbear, Flake8-pyproject] - - - repo: https://github.com/PyCQA/flake8 - rev: 7.3.0 - hooks: - - id: flake8 - name: Lint python code with flake8 - additional_dependencies: [flake8-bugbear, Flake8-pyproject] + - id: ruff-check + types_or: [python, pyi, pyproject] + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.20.0 diff --git a/pyproject.toml b/pyproject.toml index 66f1016f..1c3c14c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,28 +50,12 @@ version = {attr = "openslide._version.__version__"} [tool.setuptools.package-data] openslide = ["py.typed", "*.pyi"] -[tool.black] -skip-string-normalization = true -target-version = ["py310", "py311", "py312", "py313", "py314"] - # Ref: https://github.com/codespell-project/codespell#using-a-config-file [tool.codespell] check-hidden = true # ignore-regex = "" # ignore-words-list = "" -# https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html#flake8 -# also ignore: -# - E741 ambiguous variable name -# requires Flake8-pyproject -[tool.flake8] -max-line-length = 88 -extend-ignore = ["E203", "E741"] - -[tool.isort] -profile = "black" -force_sort_within_sections = true - [tool.mypy] python_version = "3.10" strict = true @@ -87,6 +71,17 @@ pythonpath = "tests" [tool.rstcheck] ignore_messages = "(Hyperlink target \".*\" is not referenced\\.$)" +[tool.ruff.format] +quote-style = "single" + +[tool.ruff.lint] +extend-select = ["B", "C4", "FLY", "I", "RUF", "UP"] +# ambiguous-variable-name +ignore = ["E741"] + +[tool.ruff.lint.isort] +force-sort-within-sections = true + [build-system] requires = ["setuptools >= 77.0.0"] build-backend = "setuptools.build_meta" From b7fd964f7761fe886156d654030ab4716767d90c Mon Sep 17 00:00:00 2001 From: Benjamin Gilbert Date: Wed, 15 Apr 2026 17:27:07 -0500 Subject: [PATCH 5/5] pre-commit: switch to prek The config should be backward-compatible to pre-commit, with warnings. Signed-off-by: Benjamin Gilbert --- .github/workflows/python.yml | 8 ++++---- .pre-commit-config.yaml | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e9918e45..3464e242 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -28,14 +28,14 @@ jobs: with: python-version: '3.14' - name: Install dependencies - run: python -m pip install pre-commit + run: python -m pip install prek uv - name: Cache pre-commit environments uses: actions/cache@v5 with: - path: ~/.cache/pre-commit - key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} + path: ~/.cache/prek + key: prek|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} - name: Run pre-commit hooks - run: pre-commit run -a --show-diff-on-failure --color=always + run: prek run -a --show-diff-on-failure --color=always - name: Define artifact paths id: paths run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 22adc2e7..7f30c38d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,8 @@ +# priorities: +# 0 - read-only +# 1000 - mutually non-conflicting fixers +# none - other fixers + # exclude vendored files exclude: '^(COPYING\.LESSER|examples/deepzoom/static/.*\.js)$' @@ -6,10 +11,15 @@ repos: rev: v6.0.0 hooks: - id: check-added-large-files + priority: 0 - id: check-merge-conflict + priority: 0 - id: check-toml + priority: 0 - id: check-vcs-permalinks + priority: 0 - id: check-yaml + priority: 0 - id: end-of-file-fixer - id: fix-byte-order-marker - id: mixed-line-ending @@ -28,6 +38,7 @@ repos: hooks: - id: mypy name: Check Python types + priority: 0 # Pillow is ignored in dependabot.yml additional_dependencies: [flask, openslide-bin, pillow >= 12.1.0, types-PyYAML, types-setuptools] @@ -36,6 +47,7 @@ repos: hooks: - id: rstcheck name: Validate reStructuredText syntax + priority: 0 additional_dependencies: [sphinx, toml] - repo: https://github.com/codespell-project/codespell @@ -43,18 +55,22 @@ repos: hooks: - id: codespell name: Check spelling with codespell + priority: 0 additional_dependencies: - tomli # Python < 3.11 - repo: meta hooks: - id: check-hooks-apply + priority: 0 - id: check-useless-excludes + priority: 0 - repo: local hooks: - id: mkmaintainer name: Sync maintainer issue templates + priority: 1000 entry: .github/maintainer/mkmaintainer.py files: .github/maintainer/ language: python @@ -62,6 +78,7 @@ repos: - id: annotations name: Require "from __future__ import annotations" + priority: 0 language: pygrep types: [python] # exclude config-like files