diff --git a/changes/2387.feature.rst b/changes/2387.feature.rst new file mode 100644 index 0000000000..fcf7c5353a --- /dev/null +++ b/changes/2387.feature.rst @@ -0,0 +1 @@ +Toga can now be extended, via plugins, to create Toga Images from external image classes (and vice-versa). diff --git a/core/pyproject.toml b/core/pyproject.toml index 6114e183b2..14653ccbee 100644 --- a/core/pyproject.toml +++ b/core/pyproject.toml @@ -123,3 +123,6 @@ source = [ [tool.pytest.ini_options] asyncio_mode = "auto" + +[project.entry-points."toga.image_formats"] +pil = "toga.plugins.image_formats.PILConverter" diff --git a/core/src/toga/images.py b/core/src/toga/images.py index 315e990f9e..0d374ce73c 100644 --- a/core/src/toga/images.py +++ b/core/src/toga/images.py @@ -1,22 +1,23 @@ from __future__ import annotations +import importlib import sys import warnings -from io import BytesIO +from functools import lru_cache from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from warnings import warn -try: - import PIL.Image - - PIL_imported = True -except ImportError: # pragma: no cover - PIL_imported = False - import toga from toga.platform import get_platform_factory +if sys.version_info >= (3, 10): + from importlib.metadata import entry_points +else: + # Before Python 3.10, entry_points did not support the group argument; + # so, the backport package must be used on older versions. + from importlib_metadata import entry_points + # Make sure deprecation warnings are shown by default warnings.filterwarnings("default", category=DeprecationWarning) @@ -35,6 +36,48 @@ ImageLike: TypeAlias = Any ImageContent: TypeAlias = PathLike | BytesLike | ImageLike + # Define a type variable representing an image of an externally defined type. + ExternalImageT = TypeVar("ExternalImageT") + + +class ImageConverter(Protocol): + """A class to convert between an externally defined image type and + :any:`toga.Image`. + """ + + #: The base image class this plugin can interpret. + image_class: type[ExternalImageT] + + @staticmethod + def convert_from_format(image_in_format: ExternalImageT) -> BytesLike: + """Convert from :any:`image_class` to data in a :ref:`known image format + `. + + Will accept an instance of :any:`image_class`, or subclass of that class. + + :param image_in_format: An instance of :any:`image_class` (or a subclass). + :returns: The image data, in a :ref:`known image format `. + """ + ... + + @staticmethod + def convert_to_format( + data: BytesLike, + image_class: type[ExternalImageT], + ) -> ExternalImageT: + """Convert from data to :any:`image_class` or specified subclass. + + Accepts a bytes-like object representing the image in a + :ref:`known image format `, and returns an instance of the + image class specified. This image class is guaranteed to be either the + :any:`image_class` registered by the plugin, or a subclass of that class. + + :param data: Image data in a :ref:`known image format `. + :param image_class: The class of image to return. + :returns: The image, as an instance of the image class specified. + """ + ... + NOT_PROVIDED = object() @@ -61,7 +104,7 @@ def __init__( ###################################################################### num_provided = sum(arg is not NOT_PROVIDED for arg in (src, path, data)) if num_provided > 1: - raise ValueError("Received multiple arguments to constructor.") + raise TypeError("Received multiple arguments to constructor.") if num_provided == 0: raise TypeError( "Image.__init__() missing 1 required positional argument: 'src'" @@ -100,17 +143,34 @@ def __init__( elif isinstance(src, Image): self._impl = self.factory.Image(interface=self, data=src.data) - elif PIL_imported and isinstance(src, PIL.Image.Image): - buffer = BytesIO() - src.save(buffer, format="png", compress_level=0) - self._impl = self.factory.Image(interface=self, data=buffer.getvalue()) - elif isinstance(src, self.factory.Image.RAW_TYPE): self._impl = self.factory.Image(interface=self, raw=src) else: + for converter in self._converters(): + if isinstance(src, converter.image_class): + data = converter.convert_from_format(src) + self._impl = self.factory.Image(interface=self, data=data) + return + raise TypeError("Unsupported source type for Image") + @classmethod + @lru_cache(maxsize=None) + def _converters(cls): + """Return list of registered image plugin converters. Only loaded once.""" + converters = [] + + for image_plugin in entry_points(group="toga.image_formats"): + module_name, class_name = image_plugin.value.rsplit(".", 1) + module = importlib.import_module(module_name) + converter = getattr(module, class_name) + + if converter.image_class is not None: + converters.append(converter) + + return converters + @property def size(self) -> (int, int): """The size of the image, as a (width, height) tuple.""" @@ -149,18 +209,19 @@ def save(self, path: str | Path) -> None: def as_format(self, format: type[ImageT]) -> ImageT: """Return the image, converted to the image format specified. - :param format: The image class to return. Currently supports only :any:`Image`, - and :any:`PIL.Image.Image` if Pillow is installed. + :param format: Format to provide. Defaults to :class:`~toga.images.Image`; also + supports :any:`PIL.Image.Image` if Pillow is installed, as well as any image + types defined by installed :doc:`image format plugins + `. :returns: The image in the requested format :raises TypeError: If the format supplied is not recognized. """ - if isinstance(format, type) and issubclass(format, Image): - return format(self.data) - - if PIL_imported and format is PIL.Image.Image: - buffer = BytesIO(self.data) - with PIL.Image.open(buffer) as pil_image: - pil_image.load() - return pil_image + if isinstance(format, type): + if issubclass(format, Image): + return format(self.data) + + for converter in self._converters(): + if issubclass(format, converter.image_class): + return converter.convert_to_format(self.data, format) raise TypeError(f"Unknown conversion format for Image: {format}") diff --git a/core/src/toga/plugins/__init__.py b/core/src/toga/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/core/src/toga/plugins/image_formats.py b/core/src/toga/plugins/image_formats.py new file mode 100644 index 0000000000..40822049d4 --- /dev/null +++ b/core/src/toga/plugins/image_formats.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from io import BytesIO +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from toga.images import BytesLike + +# Presumably, other converter plugins will be included with, or only installed +# alongside, the packages they're for. But since this is provided in Toga, we need to +# check if Pillow is actually installed, and disable this plugin otherwise. +try: + import PIL.Image + + PIL_imported = True + +except ImportError: # pragma: no cover + PIL_imported = False + + +class PILConverter: + image_class = PIL.Image.Image if PIL_imported else None + + @staticmethod + def convert_from_format(image_in_format: PIL.Image.Image) -> bytes: + buffer = BytesIO() + image_in_format.save(buffer, format="png", compress_level=0) + return buffer.getvalue() + + @staticmethod + def convert_to_format( + data: BytesLike, + image_class: type(PIL.Image.Image), + ) -> PIL.Image.Image: + # PIL Images aren't designed to be subclassed, so no implementation is necessary + # for a supplied format. + buffer = BytesIO(data) + with PIL.Image.open(buffer) as pil_image: + pil_image.load() + return pil_image diff --git a/core/src/toga/screens.py b/core/src/toga/screens.py index dbdd061cf7..65f341cc59 100644 --- a/core/src/toga/screens.py +++ b/core/src/toga/screens.py @@ -32,9 +32,10 @@ def size(self) -> tuple[int, int]: def as_image(self, format: type[ImageT] = Image) -> ImageT: """Render the current contents of the screen as an image. - :param format: Format for the resulting image. Defaults to - :class:`~toga.images.Image`; also supports :any:`PIL.Image.Image` if Pillow - is installed + :param format: Format to provide. Defaults to :class:`~toga.images.Image`; also + supports :any:`PIL.Image.Image` if Pillow is installed, as well as any image + types defined by installed :doc:`image format plugins + `. :returns: An image containing the screen content, in the format requested. """ return Image(self._impl.get_image_data()).as_format(format) diff --git a/core/src/toga/widgets/canvas.py b/core/src/toga/widgets/canvas.py index 8af764942c..728999497b 100644 --- a/core/src/toga/widgets/canvas.py +++ b/core/src/toga/widgets/canvas.py @@ -1453,7 +1453,9 @@ def as_image(self, format: type[ImageT] = toga.Image) -> ImageT: """Render the canvas as an image. :param format: Format to provide. Defaults to :class:`~toga.images.Image`; also - supports :class:`PIL.Image.Image` if Pillow is installed + supports :any:`PIL.Image.Image` if Pillow is installed, as well as any image + types defined by installed :doc:`image format plugins + ` :returns: The canvas as an image of the specified type. """ return toga.Image(self._impl.get_image_data()).as_format(format) diff --git a/core/src/toga/widgets/imageview.py b/core/src/toga/widgets/imageview.py index 644bed0193..e269a2ce00 100644 --- a/core/src/toga/widgets/imageview.py +++ b/core/src/toga/widgets/imageview.py @@ -129,7 +129,9 @@ def as_image(self, format: type[ImageT] = toga.Image) -> ImageT: """Return the image in the specified format. :param format: Format to provide. Defaults to :class:`~toga.images.Image`; also - supports :any:`PIL.Image.Image` if Pillow is installed. + supports :any:`PIL.Image.Image` if Pillow is installed, as well as any image + types defined by installed :doc:`image format plugins + `. :returns: The image in the specified format. """ return self.image.as_format(format) diff --git a/core/src/toga/window.py b/core/src/toga/window.py index c9456ce9a7..9427ea0006 100644 --- a/core/src/toga/window.py +++ b/core/src/toga/window.py @@ -438,7 +438,9 @@ def as_image(self, format: type[ImageT] = Image) -> ImageT: """Render the current contents of the window as an image. :param format: Format to provide. Defaults to :class:`~toga.images.Image`; also - supports :any:`PIL.Image.Image` if Pillow is installed + supports :any:`PIL.Image.Image` if Pillow is installed, as well as any image + types defined by installed :doc:`image format plugins + `. :returns: An image containing the window content, in the format requested. """ return Image(self._impl.get_image_data()).as_format(format) diff --git a/core/tests/test_images.py b/core/tests/test_images.py index f4860674b4..4490ea4e6f 100644 --- a/core/tests/test_images.py +++ b/core/tests/test_images.py @@ -4,6 +4,11 @@ import pytest import toga +from toga_dummy.plugins.image_formats import ( + CustomImage, + CustomImageSubclass, + DisabledImageConverter, +) from toga_dummy.utils import assert_action_performed_with RELATIVE_FILE_PATH = Path("resources/sample.png") @@ -199,7 +204,7 @@ def test_deprecated_arguments(kwargs): def test_too_many_arguments(args, kwargs): """If multiple arguments are supplied, an error is raised""" with pytest.raises( - ValueError, + TypeError, match=r"Received multiple arguments to constructor.", ): toga.Image(*args, **kwargs) @@ -267,6 +272,29 @@ def test_as_format_pil(app): assert pil_image.size == (144, 72) +@pytest.mark.parametrize("ImageClass", [CustomImage, CustomImageSubclass]) +def test_create_from_custom_class(app, ImageClass): + """toga.Image can be created from custom type""" + custom_image = ImageClass() + toga_image = toga.Image(custom_image) + assert isinstance(toga_image, toga.Image) + assert toga_image.size == (144, 72) + + +@pytest.mark.parametrize("ImageClass", [CustomImage, CustomImageSubclass]) +def test_as_format_custom_class(app, ImageClass): + """as_format can successfully return a registered custom image type""" + toga_image = toga.Image(ABSOLUTE_FILE_PATH) + custom_image = toga_image.as_format(ImageClass) + assert isinstance(custom_image, ImageClass) + assert custom_image.size == (144, 72) + + +def test_disabled_image_plugin(app): + """Disabled image plugin shouldn't be available.""" + assert DisabledImageConverter not in toga.Image._converters() + + # None is same as supplying nothing; also test a random unrecognized class @pytest.mark.parametrize("arg", [None, toga.Button]) def test_as_format_invalid_input(app, arg): diff --git a/docs/reference/api/resources/images.rst b/docs/reference/api/resources/images.rst index 5e25750f7f..59aa204451 100644 --- a/docs/reference/api/resources/images.rst +++ b/docs/reference/api/resources/images.rst @@ -52,6 +52,9 @@ An image can be constructed from a :any:`wide range of sources `: my_pil_image = PIL.Image.new("L", (30, 30)) my_toga_image = toga.Image(my_pil_image) +You can also tell Toga how to convert from (and to) other classes that represent images +via :doc:`image format plugins `. + Notes ----- @@ -74,6 +77,12 @@ Notes - macOS: ``NSImage`` - Windows: ``System.Drawing.Image`` +.. _toga_image_subclassing: + +* If you subclass :any:`Image`, you can supply that subclass as the requested format to + any ``as_format()`` method in Toga, provided that your subclass has a constructor + signature compatible with the base :any:`Image` class. + Reference --------- @@ -87,9 +96,11 @@ Reference :ref:`known image format `; * a "blob of bytes" data type (:any:`bytes`, :any:`bytearray`, or :any:`memoryview`) containing raw image data in a :ref:`known image format `; - * an instance of :any:`toga.Image`; or - * if `Pillow `__ is installed, an instance of - :any:`PIL.Image.Image`; or + * an instance of :any:`toga.Image`; + * if `Pillow `_ is installed, an instance of + :any:`PIL.Image.Image`; + * an image of a class registered via an :doc:`image format plugin + ` (or a subclass of such a class); or * an instance of the :ref:`native platform image representation `. If a relative path is provided, it will be anchored relative to the module that diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 019084d2fa..46fe473dbc 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -11,3 +11,4 @@ Reference widgets_by_platform api/index style/index + plugins/index diff --git a/docs/reference/plugins/image_formats.rst b/docs/reference/plugins/image_formats.rst new file mode 100644 index 0000000000..15be282e00 --- /dev/null +++ b/docs/reference/plugins/image_formats.rst @@ -0,0 +1,56 @@ +==================== +Image Format Plugins +==================== + +Usage +----- + +Toga can be extended, via plugins, to understand externally defined image types, gaining +the ability to convert them to and from its own :any:`Image` class. Toga's `Pillow +`__ support is, in fact, +implemented as a plugin that's included as part of the core Toga package. + +An image format plugin consists of two things: + +- a converter class conforming to the :any:`ImageConverter` protocol, with methods + defining how to convert to and from your image class +- an `entry point + `__ + in the ``toga.image_formats`` group telling Toga the path to your converter class. + +Let's say you want to tell Toga how to handle an image class called ``MyImage``, and +you're publishing your plugin as a package named ``togax-myimage`` (see :ref:`package +prefixes `) that contains a ``plugins.py`` module that defines your +``MyImageConverter`` plugin class. Your ``pyproject.toml`` might include something like +the following: + +.. code-block:: toml + + [project.entry-points."toga.image_formats"] + myimage = "togax_myimage.plugins.MyImageConverter" + +The variable name being assigned to (``myimage`` in this case) can be whatever you like +(although it should probably have some relationship to the image format name) What +matters is the string assigned to it, which represents where Toga can find (and import) +your :any:`ImageConverter` class. + +.. _package_prefixes: + +Package prefixes +~~~~~~~~~~~~~~~~ + +An image plugin can be registered from any Python module. If you maintain a package +defining an image format, you could include a Toga converter plugin along with it. If +you're publishing a plugin as a standalone package, you should title it with a +``togax-`` prefix, to indicate that it's an unofficial extension for Toga. Do *not* use +the ``toga-`` prefix, as the BeeWare Project wishes to reserve that package prefix for +"official" packages. + +Reference +--------- + +.. c:type:: ExternalImageT + + Any class that represents an image. + +.. autoprotocol:: toga.images.ImageConverter diff --git a/docs/reference/plugins/index.rst b/docs/reference/plugins/index.rst new file mode 100644 index 0000000000..87d3597d75 --- /dev/null +++ b/docs/reference/plugins/index.rst @@ -0,0 +1,10 @@ +.. _plugins: + +======= +Plugins +======= + +.. toctree:: + :maxdepth: 1 + + image_formats diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 954dfde771..ba3df15415 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -66,6 +66,9 @@ Stimpy stylesheet subclasses Subclasses +subclassing +submodule +subpackage substring substrings Sur diff --git a/dummy/pyproject.toml b/dummy/pyproject.toml index e3a796fade..cf92d6cdb2 100644 --- a/dummy/pyproject.toml +++ b/dummy/pyproject.toml @@ -60,3 +60,7 @@ root = ".." dependencies = [ "toga-core == {version}", ] + +[project.entry-points."toga.image_formats"] +dummy = "toga_dummy.plugins.image_formats.CustomImageConverter" +disabled = "toga_dummy.plugins.image_formats.DisabledImageConverter" diff --git a/dummy/src/toga_dummy/plugins/__init__.py b/dummy/src/toga_dummy/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dummy/src/toga_dummy/plugins/image_formats.py b/dummy/src/toga_dummy/plugins/image_formats.py new file mode 100644 index 0000000000..d58686b739 --- /dev/null +++ b/dummy/src/toga_dummy/plugins/image_formats.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import toga + +if TYPE_CHECKING: + from toga.images import BytesLike + + +class CustomImage: + pass + + +class CustomImageSubclass(CustomImage): + pass + + +class CustomImageConverter: + image_class = CustomImage + + @staticmethod + def convert_from_format(image_in_format: CustomImage): + return (Path(__file__).parent.parent / "resources/sample.png").read_bytes() + + @staticmethod + def convert_to_format( + data: BytesLike, + image_class: type[CustomImage], + ) -> CustomImage: + image = image_class() + image.size = toga.Image(data).size + return image + + +# With image_class set to None, this converter shouldn't be added to the list of +# available converters. This simulates what the PIL plugin does if PIL isn't installed. +class DisabledImageConverter: + image_class = None + + @staticmethod + def convert_from_format(image_in_format: Any): + raise Exception("Converter should be disabled") + + @staticmethod + def convert_to_format( + data: BytesLike, + image_class: type[Any], + ) -> Any: + raise Exception("Converter should be disabled") diff --git a/dummy/src/toga_dummy/resources/sample.png b/dummy/src/toga_dummy/resources/sample.png new file mode 100644 index 0000000000..29ab02cb6d Binary files /dev/null and b/dummy/src/toga_dummy/resources/sample.png differ