From 7b7555906811a8bf7b0044085ed0b7cd9c8a1699 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Fri, 30 Oct 2026 15:32:13 +0500 Subject: [PATCH 1/2] Add Resize, Padding, and Background factories --- imgproxykit/options/background.py | 36 ++++-- imgproxykit/options/padding.py | 20 +++- imgproxykit/options/resize.py | 103 +++++++++++++++++- .../test_unit/test_options/test_background.py | 3 + tests/test_unit/test_options/test_padding.py | 12 +- tests/test_unit/test_options/test_resize.py | 41 +++++++ 6 files changed, 194 insertions(+), 21 deletions(-) diff --git a/imgproxykit/options/background.py b/imgproxykit/options/background.py index 890e7c7..f96b680 100644 --- a/imgproxykit/options/background.py +++ b/imgproxykit/options/background.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import ClassVar, override +from typing import ClassVar, Self, override from imgproxykit.options.base import Option from imgproxykit.types import Argument @@ -8,10 +8,11 @@ @dataclass(frozen=True, slots=True, kw_only=True) class Background(Option): """ - Fill the background with a hex color or RGB tuple; omit to disable. + Fill the background with a hex or RGB color, or explicitly disable filling. - Useful when converting transparent images to JPEG. Omit the color - to disable background manipulation. + Useful when converting transparent images to JPEG. Use :meth:`hex` or + :meth:`rgb` to select a color, and :meth:`disabled` to override background + filling set earlier in the request, including by a preset. Attributes: color: Hex color without a leading hash, or an RGB tuple with @@ -19,13 +20,13 @@ class Background(Option): Usage: - >>> option = Background(color='ffffff') + >>> option = Background.hex('ffffff') >>> option.arguments ('ffffff',) - >>> option = Background(color=(255, 255, 255)) + >>> option = Background.rgb(red=255, green=255, blue=255) >>> option.arguments (255, 255, 255) - >>> option = Background() + >>> option = Background.disabled() >>> option.arguments (None,) @@ -39,6 +40,27 @@ class Background(Option): name: ClassVar[str] = 'background' short_name: ClassVar[str | None] = 'bg' + @classmethod + def rgb(cls, *, red: int, green: int, blue: int) -> Self: + """Fill the background using three required RGB channels.""" + return cls(color=(red, green, blue)) + + @classmethod + def hex(cls, color: str) -> Self: + """Fill the background using a hex color without a leading hash.""" + return cls(color=color) + + @classmethod + def disabled(cls) -> Self: + """ + Explicitly disable background filling, including a preset's color. + + Emits the background option without arguments. Unlike omitting the + option, this overrides background filling configured earlier in the + request. Equivalent to ``Background()``. + """ + return cls() + @property @override def arguments(self) -> tuple[Argument, ...]: diff --git a/imgproxykit/options/padding.py b/imgproxykit/options/padding.py index 6e7713c..2afcd24 100644 --- a/imgproxykit/options/padding.py +++ b/imgproxykit/options/padding.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import ClassVar, override +from typing import ClassVar, Self, override from imgproxykit.options.base import Option from imgproxykit.types import Argument @@ -15,6 +15,9 @@ class Padding(Option): Supply at least one side; omitted arguments remain ``None``. + Use :meth:`all` for uniform padding, :meth:`symmetric` for separate + horizontal and vertical padding, or the constructor for individual sides. + Attributes: top: Top padding in pixels; fallback for omitted sides. right: Right padding; also used for an omitted left side. @@ -23,9 +26,12 @@ class Padding(Option): Usage: - >>> option = Padding(top=10) + >>> option = Padding.all(10) >>> option.arguments (10, None, None, None) + >>> option = Padding.symmetric(horizontal=20, vertical=10) + >>> option.arguments + (10, 20, None, None) >>> option = Padding(top=10, right=20, bottom=30, left=40) >>> option.arguments (10, 20, 30, 40) @@ -43,6 +49,16 @@ class Padding(Option): name: ClassVar[str] = 'padding' short_name: ClassVar[str | None] = 'pd' + @classmethod + def all(cls, value: int) -> Self: + """Apply the same padding to all sides using protocol fallback.""" + return cls(top=value) + + @classmethod + def symmetric(cls, *, horizontal: int, vertical: int) -> Self: + """Set padding for both axes, requiring both values explicitly.""" + return cls(top=vertical, right=horizontal) + @property @override def arguments(self) -> tuple[Argument, ...]: diff --git a/imgproxykit/options/resize.py b/imgproxykit/options/resize.py index b1cd0e9..7b758d2 100644 --- a/imgproxykit/options/resize.py +++ b/imgproxykit/options/resize.py @@ -1,18 +1,22 @@ from dataclasses import dataclass -from typing import ClassVar, override +from typing import ClassVar, Self, override from imgproxykit.options.base import Option from imgproxykit.types import Argument, ResizeType @dataclass(frozen=True, slots=True, kw_only=True) -class Resize(Option): +class Resize(Option): # noqa: WPS214 """ Configure image dimensions and resizing behavior. Values left as ``None`` are not supplied explicitly; imgproxy resolves their defaults. + Select a strategy with :meth:`fit`, :meth:`fill`, :meth:`fill_down`, + :meth:`force`, or :meth:`auto`. Use the constructor when the mode is + supplied dynamically or should be left to the server. + Attributes: width: Target width in pixels. height: Target height in pixels. @@ -22,9 +26,12 @@ class Resize(Option): Usage: - >>> option = Resize(width=96, height=96, mode='fill') + >>> option = Resize.fill(width=96, height=96) >>> option.arguments ('fill', 96, 96, None, None) + >>> option = Resize.fit(width=300, enlarge=False) + >>> option.arguments + ('fit', 300, None, False, None) See `imgproxy resize `_. @@ -42,6 +49,96 @@ class Resize(Option): name: ClassVar[str] = 'resize' short_name: ClassVar[str | None] = 'rs' + @classmethod + def fit( + cls, + *, + width: int | None = None, + height: int | None = None, + enlarge: bool | None = None, + extend: bool | None = None, + ) -> Self: + """Resize using the fit strategy with optional dimensions.""" + return cls( + mode='fit', + width=width, + height=height, + enlarge=enlarge, + extend=extend, + ) + + @classmethod + def fill( + cls, + *, + width: int | None = None, + height: int | None = None, + enlarge: bool | None = None, + extend: bool | None = None, + ) -> Self: + """Resize using the fill strategy with optional dimensions.""" + return cls( + mode='fill', + width=width, + height=height, + enlarge=enlarge, + extend=extend, + ) + + @classmethod + def fill_down( + cls, + *, + width: int | None = None, + height: int | None = None, + enlarge: bool | None = None, + extend: bool | None = None, + ) -> Self: + """Resize using the fill-down strategy with optional dimensions.""" + return cls( + mode='fill-down', + width=width, + height=height, + enlarge=enlarge, + extend=extend, + ) + + @classmethod + def force( + cls, + *, + width: int | None = None, + height: int | None = None, + enlarge: bool | None = None, + extend: bool | None = None, + ) -> Self: + """Resize using the force strategy with optional dimensions.""" + return cls( + mode='force', + width=width, + height=height, + enlarge=enlarge, + extend=extend, + ) + + @classmethod + def auto( + cls, + *, + width: int | None = None, + height: int | None = None, + enlarge: bool | None = None, + extend: bool | None = None, + ) -> Self: + """Resize using the auto strategy with optional dimensions.""" + return cls( + mode='auto', + width=width, + height=height, + enlarge=enlarge, + extend=extend, + ) + @property @override def arguments(self) -> tuple[Argument, ...]: diff --git a/tests/test_unit/test_options/test_background.py b/tests/test_unit/test_options/test_background.py index 212843f..93e74c0 100644 --- a/tests/test_unit/test_options/test_background.py +++ b/tests/test_unit/test_options/test_background.py @@ -10,6 +10,9 @@ (Background(), 'background'), (Background(color='ff0080'), 'background:ff0080'), (Background(color=(0, 128, 255)), 'background:0:128:255'), + (Background.rgb(red=0, green=128, blue=255), 'background:0:128:255'), + (Background.hex('ff0080'), 'background:ff0080'), + (Background.disabled(), 'background'), ], ) def test_background( diff --git a/tests/test_unit/test_options/test_padding.py b/tests/test_unit/test_options/test_padding.py index 43d5c15..79d99d1 100644 --- a/tests/test_unit/test_options/test_padding.py +++ b/tests/test_unit/test_options/test_padding.py @@ -8,16 +8,10 @@ ('option', 'expected'), [ (Padding(), 'padding'), - ( - Padding( - top=10, - right=20, - bottom=30, - left=40, - ), - 'padding:10:20:30:40', - ), + (Padding(top=10, right=20, bottom=30, left=40), 'padding:10:20:30:40'), (Padding(left=0), 'padding::::0'), + (Padding.all(0), 'padding:0'), + (Padding.symmetric(horizontal=0, vertical=10), 'padding:10:0'), ], ) def test_padding( diff --git a/tests/test_unit/test_options/test_resize.py b/tests/test_unit/test_options/test_resize.py index 7adbdd4..5ac4710 100644 --- a/tests/test_unit/test_options/test_resize.py +++ b/tests/test_unit/test_options/test_resize.py @@ -1,7 +1,10 @@ +from collections.abc import Callable + import pytest from imgproxykit.options import Resize from imgproxykit.serialization import PathSerializer +from imgproxykit.types import ResizeType @pytest.mark.parametrize( @@ -33,3 +36,41 @@ def test_resize( path_serializer._serialize_segment(option.name, option.arguments) == expected ) + + +@pytest.mark.parametrize( + ('factory', 'mode'), + [ + (Resize.fit, 'fit'), + (Resize.fill, 'fill'), + (Resize.fill_down, 'fill-down'), + (Resize.force, 'force'), + (Resize.auto, 'auto'), + ], +) +@pytest.mark.parametrize( + ('settings', 'suffix'), + [ + ({}, ''), + ( + {'width': 0, 'height': 200, 'enlarge': False, 'extend': True}, + ':0:200:0:1', + ), + ({'enlarge': True, 'extend': False}, ':::1:0'), + ], +) +def test_resize_factories( + path_serializer: PathSerializer, + *, + factory: Callable[..., Resize], + mode: ResizeType, + settings: dict[str, int | bool], + suffix: str, +) -> None: + """Serialize each resize mode with omitted dimensions and explicit flags.""" + option = factory(**settings) + + assert ( + path_serializer._serialize_segment(option.name, option.arguments) + == f'resize:{mode}{suffix}' + ) From fcab46df20ccf15d072c9a6195ea334918cc0a5a Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Fri, 30 Oct 2026 15:34:34 +0500 Subject: [PATCH 2/2] Add Resize, Padding, and Background factories --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5788cf..defc19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ but with a deprecation period. ### Features +- Added `Resize`, `Padding`, and `Background` factories to select resizing + strategies, configure uniform or symmetric padding, and set or explicitly + disable background colors, #107 - Added `Client(source_encoding='plain')` to generate URLs with percent-encoded sources and imgproxy's `/plain/` marker. Base64 remains the default, #106