diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1031d..0255405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **String acceptance for `mode` and `colorspace`**: both now accept an enum member, its value, or its case-insensitive name (e.g. `mode="KM"`, `mode="KMeans"`). +- **Typed exception hierarchy**: `PyletteError` base with `InvalidImageError`, +`NoValidPixelsError`, `UnknownExtractionMethodError`, and +`InvalidColorspaceError`. Using a `except PyletteError` clause now catches any +failures from Pylette, and the failure mode is identified by exception type +rather than message. Each subclass also derives from `ValueError`, so existing +`except ValueError` handlers keep working. ### Changed diff --git a/docs/reference.md b/docs/reference.md index 7a80498..dab4d9f 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -22,6 +22,20 @@ and the `Palette` and `Color` classes, which are used to work with the extracted ::: pylette.Color +## Exceptions + +Every error Pylette raises derives from `PyletteError`, so you can catch any +Pylette-originated failure with a single `except pylette.PyletteError` and branch +on the concrete subclass to identify the failure mode. Each subclass also derives +from `ValueError`, so existing `except ValueError` handlers keep working. + +::: pylette.PyletteError +::: pylette.InvalidImageError +::: pylette.NoValidPixelsError +::: pylette.UnknownExtractionMethodError +::: pylette.InvalidColorspaceError + + ## Core Types: ::: pylette.types.ArrayImage diff --git a/pylette/__init__.py b/pylette/__init__.py index f794a89..24cd04f 100644 --- a/pylette/__init__.py +++ b/pylette/__init__.py @@ -1,6 +1,24 @@ from pylette import types from pylette.src.color import Color from pylette.src.color_extraction import batch_extract_colors, extract_colors +from pylette.src.exceptions import ( + InvalidColorspaceError, + InvalidImageError, + NoValidPixelsError, + PyletteError, + UnknownExtractionMethodError, +) from pylette.src.palette import Palette -__all__ = ["extract_colors", "batch_extract_colors", "Palette", "Color", "types"] +__all__ = [ + "extract_colors", + "batch_extract_colors", + "Palette", + "Color", + "types", + "PyletteError", + "InvalidImageError", + "NoValidPixelsError", + "UnknownExtractionMethodError", + "InvalidColorspaceError", +] diff --git a/pylette/src/color.py b/pylette/src/color.py index d9caed9..b50d764 100644 --- a/pylette/src/color.py +++ b/pylette/src/color.py @@ -3,6 +3,7 @@ import numpy as np +from pylette.src.exceptions import InvalidColorspaceError from pylette.src.types import ColorSpace, coerce_to_enum # Weights for calculating luminance @@ -193,7 +194,7 @@ def get_colors(self, colorspace: ColorSpace | str = ColorSpace.RGB) -> tuple[int Returns: tuple[int, ...] | tuple[float, ...]: The color values in the specified color space. """ - colorspace = coerce_to_enum(colorspace, ColorSpace) + colorspace = coerce_to_enum(colorspace, ColorSpace, error_cls=InvalidColorspaceError) colors = {ColorSpace.RGB: self.rgb, ColorSpace.HSV: self.hsv, ColorSpace.HLS: self.hls} return colors[colorspace] diff --git a/pylette/src/color_extraction.py b/pylette/src/color_extraction.py index ee8054c..01be348 100644 --- a/pylette/src/color_extraction.py +++ b/pylette/src/color_extraction.py @@ -9,6 +9,7 @@ import numpy as np from PIL import Image +from pylette.src.exceptions import InvalidImageError, NoValidPixelsError, UnknownExtractionMethodError from pylette.src.extractors.registry import get_extractor from pylette.src.palette import Palette from pylette.src.types import ( @@ -35,21 +36,30 @@ def _is_url(image_str: str) -> bool: def _normalize_image_input(image: ImageInput) -> PILImage: - """Convert any valid image input to PIL Image.""" - if isinstance(image, Image.Image): - return image - elif isinstance(image, (str, Path)): - image_str = str(image) - if _is_url(image_str): - return request_image(image_str) + """Convert any valid image input to a PIL Image. + + Any failure to load (unsupported type, missing file, corrupt data, a URL that + is not an image) is surfaced as :class:`InvalidImageError`. + """ + try: + if isinstance(image, Image.Image): + return image + elif isinstance(image, (str, Path)): + image_str = str(image) + if _is_url(image_str): + return request_image(image_str) + else: + return Image.open(image) + elif isinstance(image, bytes): + return Image.open(BytesIO(image)) + elif hasattr(image, "__array__"): # More general check for array-like objects + return Image.fromarray(image) else: - return Image.open(image) - elif isinstance(image, bytes): - return Image.open(BytesIO(image)) - elif hasattr(image, "__array__"): # More general check for array-like objects - return Image.fromarray(image) - else: - raise TypeError(f"Unsupported image type: {type(image)}") + raise InvalidImageError(f"Unsupported image type: {type(image)}") + except InvalidImageError: + raise + except Exception as e: + raise InvalidImageError(f"Could not load image: {e}") from e def _get_source_type_from_image_input(image: ImageInput) -> SourceType: @@ -169,7 +179,7 @@ def extract_colors( start_time = time.time() - mode = coerce_to_enum(mode, ExtractionMethod) + mode = coerce_to_enum(mode, ExtractionMethod, error_cls=UnknownExtractionMethodError) source_type = _get_source_type_from_image_input(image) # Normalize input to PIL Image and convert to RGBA @@ -200,7 +210,7 @@ def extract_colors( valid_pixels = arr[~alpha_mask] if len(valid_pixels) == 0: - raise ValueError( + raise NoValidPixelsError( f"No valid pixels remain after applying alpha mask with threshold {alpha_mask_threshold}. " f"Try using a lower alpha-mask-threshold value or check if your image has transparency." ) @@ -251,7 +261,7 @@ def request_image(image_url: str) -> Image.Image: Image.Image: The requested image. Raises: - ValueError: If the URL does not point to a valid image. + InvalidImageError: If the URL does not point to a valid image. """ import requests @@ -262,4 +272,4 @@ def request_image(image_url: str) -> Image.Image: img = Image.open(BytesIO(response.content)) return img else: - raise ValueError("The URL did not point to a valid image.") + raise InvalidImageError("The URL did not point to a valid image.") diff --git a/pylette/src/exceptions.py b/pylette/src/exceptions.py new file mode 100644 index 0000000..f58b73d --- /dev/null +++ b/pylette/src/exceptions.py @@ -0,0 +1,27 @@ +"""Typed exception hierarchy for Pylette. + +Every error Pylette raises derives from :class:`PyletteError`, so a caller can +``except PyletteError`` to catch any Pylette-originated failure and branch on the +concrete subclass to identify the failure mode. The concrete subclasses also +derive from the builtin (:class:`ValueError`). +""" + + +class PyletteError(Exception): + """Base class for every error raised by Pylette.""" + + +class InvalidImageError(PyletteError, ValueError): + """An input image could not be loaded, or its type is unsupported.""" + + +class NoValidPixelsError(PyletteError, ValueError): + """No pixels remain to extract a palette from (e.g. a fully alpha-masked image).""" + + +class UnknownExtractionMethodError(PyletteError, ValueError): + """The requested extraction method is not a registered/known method.""" + + +class InvalidColorspaceError(PyletteError, ValueError): + """The requested color space is not recognized.""" diff --git a/pylette/src/extractors/registry.py b/pylette/src/extractors/registry.py index 94578b5..a116029 100644 --- a/pylette/src/extractors/registry.py +++ b/pylette/src/extractors/registry.py @@ -4,6 +4,7 @@ from typing import Callable, TypeVar +from pylette.src.exceptions import UnknownExtractionMethodError from pylette.src.extractors.protocol import ColorExtractor from pylette.src.types import ExtractionMethod, coerce_to_enum @@ -37,16 +38,19 @@ def get_extractor(method: ExtractionMethod | str) -> ColorExtractor: Return the extractor registered for ``method``. Raises: - ValueError: If ``method`` is not a known method or has no registered extractor. + UnknownExtractionMethodError: If ``method`` is not a known method or has + no registered extractor. """ - method = coerce_to_enum(method, ExtractionMethod) + method = coerce_to_enum(method, ExtractionMethod, error_cls=UnknownExtractionMethodError) try: return _REGISTRY[method] except KeyError: available = ", ".join(sorted(m.value for m in _REGISTRY)) or "(none)" - raise ValueError(f"No extractor registered for {method.value}. Registered: {available}.") from None + raise UnknownExtractionMethodError( + f"No extractor registered for {method.value}. Registered: {available}." + ) from None def available_methods() -> list[ExtractionMethod]: diff --git a/pylette/src/palette.py b/pylette/src/palette.py index aa5115a..740d19c 100644 --- a/pylette/src/palette.py +++ b/pylette/src/palette.py @@ -4,6 +4,7 @@ from PIL import Image from pylette.src.color import Color +from pylette.src.exceptions import InvalidColorspaceError from pylette.src.types import ( ColorSpace, ExtractionParams, @@ -140,7 +141,7 @@ def to_json( dict | None: The palette data as a dictionary if filename is None. """ - colorspace = coerce_to_enum(colorspace, ColorSpace) + colorspace = coerce_to_enum(colorspace, ColorSpace, error_cls=InvalidColorspaceError) # Build the palette data palette_data: dict[str, object] = { diff --git a/pylette/src/types.py b/pylette/src/types.py index dbbb815..4113c5e 100644 --- a/pylette/src/types.py +++ b/pylette/src/types.py @@ -68,7 +68,11 @@ class ColorSpace(str, Enum): _EnumT = TypeVar("_EnumT", bound=Enum) -def coerce_to_enum(value: "_EnumT | str", enum_cls: type[_EnumT]) -> _EnumT: +def coerce_to_enum( + value: "_EnumT | str", + enum_cls: type[_EnumT], + error_cls: type[Exception] = ValueError, +) -> _EnumT: """Coerce ``value`` to a member of ``enum_cls``. Accepts an existing member, the member's value, or its (case-insensitive) @@ -76,8 +80,12 @@ def coerce_to_enum(value: "_EnumT | str", enum_cls: type[_EnumT]) -> _EnumT: (e.g. ``mode`` and ``colorspace``), replacing acceptance scattered across the registry, ``extract_colors``, and JSON export. + Parameters: + error_cls: Exception type raised on a miss, so callers can surface a + domain-specific error (e.g. ``UnknownExtractionMethodError``). + Raises: - ValueError: If ``value`` matches no member of ``enum_cls``. + error_cls: If ``value`` matches no member of ``enum_cls``. """ if isinstance(value, enum_cls): return value @@ -91,7 +99,7 @@ def coerce_to_enum(value: "_EnumT | str", enum_cls: type[_EnumT]) -> _EnumT: except KeyError: pass valid = ", ".join(f"{m.name}/{m.value}" for m in enum_cls) - raise ValueError(f"Unknown {enum_cls.__name__} {value!r}. Valid options: {valid}.") + raise error_cls(f"Unknown {enum_cls.__name__} {value!r}. Valid options: {valid}.") # PaletteMetaData Types diff --git a/tests/integration/test_exceptions.py b/tests/integration/test_exceptions.py new file mode 100644 index 0000000..44536dc --- /dev/null +++ b/tests/integration/test_exceptions.py @@ -0,0 +1,114 @@ +""" +Every Pylette-originated failure derives from ``PyletteError`` and is +identified by its type, while remaining a ``ValueError`` for backward +compatibility. +""" + +import numpy as np +import pytest +from PIL import Image + +from pylette import ( + InvalidColorspaceError, + InvalidImageError, + NoValidPixelsError, + PyletteError, + UnknownExtractionMethodError, + batch_extract_colors, + extract_colors, +) +from pylette.src.color import Color +from pylette.src.extractors.registry import get_extractor + + +@pytest.fixture +def opaque_image() -> Image.Image: + arr = np.random.default_rng(0).integers(0, 256, (16, 16, 3), dtype=np.uint8) + return Image.fromarray(arr, "RGB") + + +@pytest.fixture +def fully_transparent_image() -> Image.Image: + """16x16 RGBA image that is fully transparent (alpha = 0 everywhere).""" + arr = np.zeros((16, 16, 4), dtype=np.uint8) + arr[..., :3] = 200 + return Image.fromarray(arr, "RGBA") + + +def test_unsupported_image_type_raises_invalid_image_error() -> None: + with pytest.raises(InvalidImageError): + extract_colors(12345) # type: ignore[arg-type] + + +def test_missing_file_raises_invalid_image_error() -> None: + with pytest.raises(InvalidImageError): + extract_colors("does/not/exist.png") + + +def test_corrupt_bytes_raise_invalid_image_error() -> None: + with pytest.raises(InvalidImageError): + extract_colors(b"not a real image") + + +def test_invalid_url_image_raises_invalid_image_error(requests_mock) -> None: # type: ignore[no-untyped-def] + url = "https://example.com/not-an-image" + requests_mock.get(url, text="nope", headers={"Content-Type": "text/html"}) + with pytest.raises(InvalidImageError): + extract_colors(url) + + +def test_fully_masked_image_raises_no_valid_pixels_error(fully_transparent_image: Image.Image) -> None: + """The all-masked #76 case stays pinned to a typed error.""" + with pytest.raises(NoValidPixelsError): + extract_colors(fully_transparent_image, alpha_mask_threshold=0, resize=False) + + +def test_unknown_mode_raises_unknown_extraction_method_error(opaque_image: Image.Image) -> None: + with pytest.raises(UnknownExtractionMethodError): + extract_colors(opaque_image, mode="NotARealMethod") + + +def test_get_extractor_unknown_raises_unknown_extraction_method_error() -> None: + with pytest.raises(UnknownExtractionMethodError): + get_extractor("NotARealMethod") + + +def test_invalid_colorspace_in_to_json_raises_invalid_colorspace_error(opaque_image: Image.Image) -> None: + palette = extract_colors(opaque_image, palette_size=2) + with pytest.raises(InvalidColorspaceError): + palette.to_json(colorspace="not-a-space") + + +def test_invalid_colorspace_in_get_colors_raises_invalid_colorspace_error() -> None: + color = Color(rgba=(10, 20, 30, 255), frequency=1.0) + with pytest.raises(InvalidColorspaceError): + color.get_colors(colorspace="not-a-space") + + +@pytest.mark.parametrize( + "exc_cls", + [InvalidImageError, NoValidPixelsError, UnknownExtractionMethodError, InvalidColorspaceError], +) +def test_every_error_is_a_pylette_error_and_value_error(exc_cls: type[PyletteError]) -> None: + """Acceptance: ``except PyletteError`` catches all; ``except ValueError`` still works.""" + assert issubclass(exc_cls, PyletteError) + assert issubclass(exc_cls, ValueError) + + +def test_pylette_error_catches_pipeline_failures(opaque_image: Image.Image) -> None: + with pytest.raises(PyletteError): + extract_colors(opaque_image, mode="bogus") + + +def test_batch_classifies_failures_by_exception_type(opaque_image: Image.Image, tmp_path) -> None: + """The batch layer preserves the typed exception per failed source.""" + good = tmp_path / "good.png" + opaque_image.save(good) + + results = batch_extract_colors(images=[str(good), 12345]) # type: ignore[list-item] + by_success = {r.success: r for r in results} + + assert by_success[True].palette is not None + failed = by_success[False] + assert isinstance(failed.error, InvalidImageError) + assert isinstance(failed.error, PyletteError)