diff --git a/openslide/__init__.py b/openslide/__init__.py index 53c70756..65022b41 100644 --- a/openslide/__init__.py +++ b/openslide/__init__.py @@ -26,7 +26,6 @@ from __future__ import annotations from io import BytesIO -from pathlib import Path from types import TracebackType from typing import Iterator, Literal, Mapping, TypeVar @@ -82,7 +81,7 @@ def __exit__( return False @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" @@ -189,11 +188,11 @@ class OpenSlide(AbstractSlide): operations on the OpenSlide object, other than close(), will fail. """ - def __init__(self, filename: str | Path): + def __init__(self, filename: lowlevel.Filename): """Open a whole-slide image.""" AbstractSlide.__init__(self) self._filename = filename - self._osr = lowlevel.open(str(filename)) + self._osr = lowlevel.open(filename) if lowlevel.read_icc_profile.available: self._profile = lowlevel.read_icc_profile(self._osr) @@ -201,11 +200,11 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._filename!r})' @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format vendor of the specified file. If the file format is not recognized, return None.""" - return lowlevel.detect_vendor(str(filename)) + return lowlevel.detect_vendor(filename) def close(self) -> None: """Close the OpenSlide object.""" @@ -358,7 +357,7 @@ def __repr__(self) -> str: class ImageSlide(AbstractSlide): """A wrapper for a PIL.Image that provides the OpenSlide interface.""" - def __init__(self, file: str | Path | Image.Image): + def __init__(self, file: lowlevel.Filename | Image.Image): """Open an image file. file can be a filename or a PIL.Image.""" @@ -376,7 +375,7 @@ def __repr__(self) -> str: return f'{self.__class__.__name__}({self._file_arg!r})' @classmethod - def detect_format(cls, filename: str | Path) -> str | None: + def detect_format(cls, filename: lowlevel.Filename) -> str | None: """Return a string describing the format of the specified file. If the file format is not recognized, return None.""" @@ -484,7 +483,7 @@ def set_cache(self, cache: OpenSlideCache) -> None: pass -def open_slide(filename: str | Path) -> OpenSlide | ImageSlide: +def open_slide(filename: lowlevel.Filename) -> OpenSlide | ImageSlide: """Open a whole-slide or regular image. Return an OpenSlide object for whole-slide images and an ImageSlide diff --git a/openslide/lowlevel.py b/openslide/lowlevel.py index 2042a9a5..159f91e1 100644 --- a/openslide/lowlevel.py +++ b/openslide/lowlevel.py @@ -2,7 +2,7 @@ # openslide-python - Python bindings for the OpenSlide library # # Copyright (c) 2010-2013 Carnegie Mellon University -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -48,6 +48,7 @@ cdll, ) from itertools import count +import os import platform from typing import TYPE_CHECKING, Any, Callable, Protocol, TypeVar, cast @@ -56,7 +57,7 @@ from . import _convert if TYPE_CHECKING: - # Python 3.10+ for ParamSpec + # Python 3.10+ from typing import ParamSpec, TypeAlias from _convert import _Buffer @@ -196,6 +197,28 @@ def from_param(cls, obj: _OpenSlideCache) -> _OpenSlideCache: return obj +if TYPE_CHECKING: + # Python 3.10+ + Filename: TypeAlias = str | bytes | os.PathLike[Any] + + +class _filename_p: + """Wrapper class to convert filename arguments to bytes.""" + + @classmethod + def from_param(cls, obj: Filename) -> bytes: + # fspath and fsencode throw TypeError on unexpected types + if platform.system() == 'Windows': + # OpenSlide 4.0.0+ requires UTF-8 on Windows + obj = os.fspath(obj) + if isinstance(obj, str): + return obj.encode('UTF-8') + else: + return obj + else: + return os.fsencode(obj) + + class _utf8_p: """Wrapper class to convert string arguments to bytes.""" @@ -350,14 +373,14 @@ def decorator(fn: Callable[_P, _T]) -> _Func[_P, _T]: try: - detect_vendor: _Func[[str], str] = _func( - 'openslide_detect_vendor', c_char_p, [_utf8_p], _check_string + detect_vendor: _Func[[Filename], str] = _func( + 'openslide_detect_vendor', c_char_p, [_filename_p], _check_string ) except AttributeError: raise OpenSlideVersionError('3.4.0') -open: _Func[[str], _OpenSlide] = _func( - 'openslide_open', c_void_p, [_utf8_p], _check_open +open: _Func[[Filename], _OpenSlide] = _func( + 'openslide_open', c_void_p, [_filename_p], _check_open ) close: _Func[[_OpenSlide], None] = _func( diff --git "a/tests/fixtures/\360\237\230\220.png" "b/tests/fixtures/\360\237\230\220.png" new file mode 100644 index 00000000..fccc4fe5 Binary files /dev/null and "b/tests/fixtures/\360\237\230\220.png" differ diff --git "a/tests/fixtures/\360\237\230\220.svs" "b/tests/fixtures/\360\237\230\220.svs" new file mode 100644 index 00000000..6d113e1d Binary files /dev/null and "b/tests/fixtures/\360\237\230\220.svs" differ diff --git a/tests/test_base.py b/tests/test_base.py index bcced6f8..d03ce7cf 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -45,7 +45,7 @@ def test_lowlevel_available(self): if getattr(attr, '__module__', None) == '__future__': continue # ignore random imports - if hasattr(ctypes, name) or name in ('count', 'platform'): + if hasattr(ctypes, name) or name in ('count', 'os', 'platform'): continue self.assertTrue( hasattr(attr, 'available'), diff --git a/tests/test_imageslide.py b/tests/test_imageslide.py index dd00ad66..e577851f 100644 --- a/tests/test_imageslide.py +++ b/tests/test_imageslide.py @@ -1,7 +1,7 @@ # # openslide-python - Python bindings for the OpenSlide library # -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -19,6 +19,7 @@ from __future__ import annotations +import sys import unittest from PIL import Image @@ -44,6 +45,21 @@ def test_open_image(self): self.assertEqual(osr.dimensions, (300, 250)) self.assertEqual(repr(osr), 'ImageSlide(%r)' % img) + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self): + 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): + 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): with Image.open(file_path('boxes.png')) as img: osr = ImageSlide(img) diff --git a/tests/test_openslide.py b/tests/test_openslide.py index 7ee86363..b2257e58 100644 --- a/tests/test_openslide.py +++ b/tests/test_openslide.py @@ -1,7 +1,7 @@ # # openslide-python - Python bindings for the OpenSlide library # -# Copyright (c) 2016-2023 Benjamin Gilbert +# Copyright (c) 2016-2024 Benjamin Gilbert # # This library is free software; you can redistribute it and/or modify it # under the terms of version 2.1 of the GNU Lesser General Public License @@ -61,12 +61,27 @@ def test_open(self): self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('setup.py') ) - self.assertRaises(OpenSlideUnsupportedFormatError, lambda: OpenSlide(None)) - self.assertRaises(OpenSlideUnsupportedFormatError, lambda: OpenSlide(3)) + self.assertRaises(ArgumentError, lambda: OpenSlide(None)) + self.assertRaises(ArgumentError, lambda: OpenSlide(3)) self.assertRaises( OpenSlideUnsupportedFormatError, lambda: OpenSlide('unopenable.tiff') ) + @unittest.skipUnless( + sys.getfilesystemencoding() == 'utf-8', + 'Python filesystem encoding is not UTF-8', + ) + def test_unicode_path(self): + 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): + 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): osr = OpenSlide(file_path('boxes.tiff')) props = osr.properties