Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for custom image format plugins #2387

Merged
merged 30 commits into from
Feb 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
af16327
Added PIL plugin, and included in tox install command
HalfWhitt Feb 8, 2024
9b08af7
Fixed circular dependency and changed registry to dict
HalfWhitt Feb 8, 2024
1042bfd
Changed error for wrong number of arguments from ValueError to TypeError
HalfWhitt Feb 8, 2024
b51639d
Added PIL plugin to dependencies for each backend + testbed
HalfWhitt Feb 9, 2024
48663ab
Added image plugin documentation
HalfWhitt Feb 9, 2024
cdb301f
Added tests; made Image._converters and Image.load_converters "private"
HalfWhitt Feb 9, 2024
08b6b64
Removed unnecessary app fixture from several image tests
HalfWhitt Feb 9, 2024
5347407
Added changenote
HalfWhitt Feb 9, 2024
8549f07
Added pil plugin to CI core alongside dev dependencies, not sure if t…
HalfWhitt Feb 9, 2024
dd3b91e
Adding PIL plugin to Python package and TOGA_INSTALL_COMMAND in CI, I…
HalfWhitt Feb 9, 2024
af2d494
Forgot to include in package, actually
HalfWhitt Feb 9, 2024
c328aec
...and fixing the wheel name
HalfWhitt Feb 9, 2024
0c32d6d
Put app fixture back in image tests
HalfWhitt Feb 10, 2024
0e3f0f3
Integrated PIL converter into core
HalfWhitt Feb 10, 2024
4669606
Moved dummy image converter from test file to entry point in dummy
HalfWhitt Feb 10, 2024
96b0b8d
Added to spelling list
HalfWhitt Feb 10, 2024
f5bfade
Accidentally didn't add the converter to the commit
HalfWhitt Feb 10, 2024
6c3943e
Fixed coverage (and added one more app fixture)
HalfWhitt Feb 10, 2024
9558495
Added teardown to custom image class tests
HalfWhitt Feb 10, 2024
d25cad0
Moved plugins to subpackages
HalfWhitt Feb 13, 2024
c69574b
Simplified plugin registry to a list, and gated it behind cached method
HalfWhitt Feb 13, 2024
869db41
Added test for disabled image plugin
HalfWhitt Feb 13, 2024
3e54dfe
Fixed/tweaked docs
HalfWhitt Feb 13, 2024
05580b1
Use @lru_cache instead of @cache, which isn't present on 3.8
HalfWhitt Feb 13, 2024
38d6f4a
Removed debug print statement
HalfWhitt Feb 13, 2024
250dbc5
Forgot to revert added capitalization in bulleted list
HalfWhitt Feb 13, 2024
0fe9d31
Merge branch 'main' into image_plugin for screens.py
HalfWhitt Feb 15, 2024
943bdc3
Changed converter from module to class, and altered docs
HalfWhitt Feb 16, 2024
7e2a6d6
Set image_class to None when PIL not imported
HalfWhitt Feb 16, 2024
ac7a31a
Naming consistency changes and documentation cleanups.
freakboy3742 Feb 17, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading