Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 19 additions & 1 deletion pylette/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
3 changes: 2 additions & 1 deletion pylette/src/color.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down
46 changes: 28 additions & 18 deletions pylette/src/color_extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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
Expand All @@ -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.")
27 changes: 27 additions & 0 deletions pylette/src/exceptions.py
Original file line number Diff line number Diff line change
@@ -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."""
10 changes: 7 additions & 3 deletions pylette/src/extractors/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]:
Expand Down
3 changes: 2 additions & 1 deletion pylette/src/palette.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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] = {
Expand Down
14 changes: 11 additions & 3 deletions pylette/src/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,24 @@ 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)
name. This is the single place that turns user-facing strings into enums
(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
Expand All @@ -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
Expand Down
114 changes: 114 additions & 0 deletions tests/integration/test_exceptions.py
Original file line number Diff line number Diff line change
@@ -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="<html>nope</html>", 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)