Skip to content

Commit

Permalink
Merge pull request #2387 from HalfWhitt/image_plugin
Browse files Browse the repository at this point in the history
Support for custom image format plugins
  • Loading branch information
freakboy3742 committed Feb 17, 2024
2 parents 71cd314 + ac7a31a commit cb2aa18
Show file tree
Hide file tree
Showing 19 changed files with 311 additions and 35 deletions.
1 change: 1 addition & 0 deletions changes/2387.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Toga can now be extended, via plugins, to create Toga Images from external image classes (and vice-versa).
3 changes: 3 additions & 0 deletions core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,6 @@ source = [

[tool.pytest.ini_options]
asyncio_mode = "auto"

[project.entry-points."toga.image_formats"]
pil = "toga.plugins.image_formats.PILConverter"
111 changes: 86 additions & 25 deletions core/src/toga/images.py
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
<known-image-formats>`.
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 <known-image-formats>`.
"""
...

@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 <known-image-formats>`, 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 <known-image-formats>`.
:param image_class: The class of image to return.
:returns: The image, as an instance of the image class specified.
"""
...


NOT_PROVIDED = object()

Expand All @@ -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'"
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
</reference/plugins/image_formats>`.
: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}")
Empty file.
40 changes: 40 additions & 0 deletions core/src/toga/plugins/image_formats.py
Original file line number Diff line number Diff line change
@@ -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
7 changes: 4 additions & 3 deletions core/src/toga/screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
</reference/plugins/image_formats>`.
:returns: An image containing the screen content, in the format requested.
"""
return Image(self._impl.get_image_data()).as_format(format)
4 changes: 3 additions & 1 deletion core/src/toga/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
</reference/plugins/image_formats>`
:returns: The canvas as an image of the specified type.
"""
return toga.Image(self._impl.get_image_data()).as_format(format)
Expand Down
4 changes: 3 additions & 1 deletion core/src/toga/widgets/imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
</reference/plugins/image_formats>`.
:returns: The image in the specified format.
"""
return self.image.as_format(format)
4 changes: 3 additions & 1 deletion core/src/toga/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
</reference/plugins/image_formats>`.
:returns: An image containing the window content, in the format requested.
"""
return Image(self._impl.get_image_data()).as_format(format)
Expand Down
30 changes: 29 additions & 1 deletion core/tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
17 changes: 14 additions & 3 deletions docs/reference/api/resources/images.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ An image can be constructed from a :any:`wide range of sources <ImageContent>`:
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 </reference/plugins/image_formats>`.

Notes
-----

Expand All @@ -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
---------

Expand All @@ -87,9 +96,11 @@ Reference
:ref:`known image format <known-image-formats>`;
* a "blob of bytes" data type (:any:`bytes`, :any:`bytearray`, or :any:`memoryview`)
containing raw image data in a :ref:`known image format <known-image-formats>`;
* an instance of :any:`toga.Image`; or
* if `Pillow <https://pillow.readthedocs.io/>`__ is installed, an instance of
:any:`PIL.Image.Image`; or
* an instance of :any:`toga.Image`;
* if `Pillow <https://pillow.readthedocs.io/>`_ is installed, an instance of
:any:`PIL.Image.Image`;
* an image of a class registered via an :doc:`image format plugin
</reference/plugins/image_formats>` (or a subclass of such a class); or
* an instance of the :ref:`native platform image representation <native-image-rep>`.

If a relative path is provided, it will be anchored relative to the module that
Expand Down
1 change: 1 addition & 0 deletions docs/reference/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ Reference
widgets_by_platform
api/index
style/index
plugins/index

0 comments on commit cb2aa18

Please sign in to comment.