diff --git a/.importlinter b/.importlinter index dea32ad..22c5916 100644 --- a/.importlinter +++ b/.importlinter @@ -18,7 +18,7 @@ layers = # Coordinate path construction and signing: client # Independent client components: - serialization | signing + serialization | signing | variants # Reusable processing configuration: transform options diff --git a/CHANGELOG.md b/CHANGELOG.md index defc19d..4ee69b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ but with a deprecation period. ### Features +- Added dataclass support to `Client.variants()`: declare URL fields with + `Annotated[str, Transform(...)]` and pass the class to receive a typed + instance, #108 - Added `Resize`, `Padding`, and `Background` factories to select resizing strategies, configure uniform or symmetric padding, and set or explicitly disable background colors, #107 diff --git a/README.md b/README.md index fefa3a4..90c309f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Build a URL for a cropped `webp` thumbnail: >>> client = Client('https://imgproxy.example.com') >>> thumbnail = Transform( -... Resize(width=300, height=300, mode='fill'), # Pass typed option objects. +... Resize.fill(width=300, height=300), # Pass typed option objects. ... quality=80, # Or scalar keywords, equivalent to Quality(80). ... format='webp', ... ) diff --git a/docs/conf.py b/docs/conf.py index c8efc0f..ced1874 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -84,7 +84,10 @@ def _get_project_meta() -> dict[str, str]: ('py:param', 'ValueT'), # Method type parameters are not resolved by the type-hint extension. ('py:param', 'Key'), + (_PY_CLASS, 'VariantSet'), + ('py:param', 'VariantSet'), # External types without local documentation targets. + (_PY_CLASS, '_typeshed.DataclassInstance'), (_PY_CLASS, 'collections.abc.Mapping'), (_PY_CLASS, 'collections.abc.Callable'), (_PY_CLASS, 'collections.abc.Awaitable'), diff --git a/docs/examples/client/url.py b/docs/examples/client/url.py index 9fb29a2..bb39a6f 100644 --- a/docs/examples/client/url.py +++ b/docs/examples/client/url.py @@ -13,7 +13,7 @@ source_url = 'https://picsum.photos/id/237/1600/1200' thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), + Resize.fill(width=300, height=300), format='webp', ) diff --git a/docs/examples/getting_started/local_thumbnail.py b/docs/examples/getting_started/local_thumbnail.py index 02c4784..4f3f9bb 100644 --- a/docs/examples/getting_started/local_thumbnail.py +++ b/docs/examples/getting_started/local_thumbnail.py @@ -13,7 +13,7 @@ source_url = 'https://picsum.photos/id/237/1600/1200' thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), + Resize.fill(width=300, height=300), format='webp', ) diff --git a/docs/examples/getting_started/transformations.py b/docs/examples/getting_started/transformations.py index 940ec6e..4edecc0 100644 --- a/docs/examples/getting_started/transformations.py +++ b/docs/examples/getting_started/transformations.py @@ -13,7 +13,7 @@ source_url = 'https://originals.example.com/photo.jpg' thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), + Resize.fill(width=300, height=300), format='webp', ) diff --git a/docs/examples/integrations/fastapi/client.py b/docs/examples/integrations/fastapi/client.py index 46448d8..94fdccd 100644 --- a/docs/examples/integrations/fastapi/client.py +++ b/docs/examples/integrations/fastapi/client.py @@ -10,15 +10,16 @@ >>> product.id 42 >>> product.images.thumbnail.startswith( -... 'https://imgproxy.example.com/unsafe/width:320/format:webp/' +... 'https://imgproxy.example.com/unsafe/format:webp/width:320/' ... ) True >>> product.images.preview.startswith( -... 'https://imgproxy.example.com/unsafe/width:1280/format:webp/' +... 'https://imgproxy.example.com/unsafe/format:webp/width:1280/' ... ) True """ +from dataclasses import dataclass from typing import Annotated from fastapi import Depends, FastAPI @@ -36,17 +37,15 @@ ) # Reuse these image sizes and output format across products. -product_variants = { - 'thumbnail': Transform(width=320, format='webp'), - 'preview': Transform(width=1280, format='webp'), -} +base = Transform(format='webp') -class ProductImages(BaseModel): - """Image variants returned for a product.""" +@dataclass(frozen=True, slots=True) +class ProductImages: + """Declare the image response fields and their transformations.""" - thumbnail: str - preview: str + thumbnail: Annotated[str, base.extend(width=320)] + preview: Annotated[str, base.extend(width=1280)] class ProductResponse(BaseModel): @@ -64,9 +63,5 @@ async def get_product( """Return a product with image variants.""" source = f'https://originals.example.com/products/{product_id}.jpg' # Build one URL per variant for the same source image. - images = imgproxy.variants(source, variants=product_variants) - # Variant names match the fields of ProductImages. - return ProductResponse( - id=product_id, - images=ProductImages(**images), - ) + images = imgproxy.variants(source, variants=ProductImages) + return ProductResponse(id=product_id, images=images) diff --git a/docs/examples/options/resize.py b/docs/examples/options/resize.py index 85aecda..646edeb 100644 --- a/docs/examples/options/resize.py +++ b/docs/examples/options/resize.py @@ -14,7 +14,7 @@ # Combine structured options with a scalar keyword. thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), + Resize.fill(width=300, height=300), Gravity.center(), dpr=2, ) diff --git a/docs/examples/transformations/composition.py b/docs/examples/transformations/composition.py index 1107cfb..4ff7545 100644 --- a/docs/examples/transformations/composition.py +++ b/docs/examples/transformations/composition.py @@ -12,7 +12,7 @@ # Define reusable groups of processing options. base = Transform(quality=85, format='webp') -square = Transform(Resize(width=300, height=300, mode='fill')) +square = Transform(Resize.fill(width=300, height=300)) # Combine options from both transformations. thumbnail = base.extend(*square.options) diff --git a/docs/examples/transformations/create_transform.py b/docs/examples/transformations/create_transform.py index 3bf49c5..720da17 100644 --- a/docs/examples/transformations/create_transform.py +++ b/docs/examples/transformations/create_transform.py @@ -16,7 +16,7 @@ # Define the thumbnail dimensions, quality, and output format. thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), # Pass typed option objects. + Resize.fill(width=300, height=300), # Pass typed option objects. quality=85, # Or scalar keywords, equivalent to Quality(85). format='webp', ) diff --git a/docs/examples/transformations/extend.py b/docs/examples/transformations/extend.py index 2e821e1..568f051 100644 --- a/docs/examples/transformations/extend.py +++ b/docs/examples/transformations/extend.py @@ -16,7 +16,7 @@ base = Transform(quality=85, format='webp') # Add a square crop without changing the base transformation. -thumbnail = base.extend(Resize(width=300, height=300, mode='fill')) +thumbnail = base.extend(Resize.fill(width=300, height=300)) # Create a higher pixel density version of the thumbnail. retina = thumbnail.extend(dpr=2) diff --git a/docs/examples/transformations/reuse_transform.py b/docs/examples/transformations/reuse_transform.py index d46df25..b8140d8 100644 --- a/docs/examples/transformations/reuse_transform.py +++ b/docs/examples/transformations/reuse_transform.py @@ -18,7 +18,7 @@ client = Client('http://localhost:8080') # Reuse the same transformation for multiple source images. thumbnail = Transform( - Resize(width=300, height=300, mode='fill'), + Resize.fill(width=300, height=300), format='webp', ) diff --git a/docs/examples/variants/dataclass.py b/docs/examples/variants/dataclass.py new file mode 100644 index 0000000..3aacdf5 --- /dev/null +++ b/docs/examples/variants/dataclass.py @@ -0,0 +1,39 @@ +""" +Return a typed dataclass instance with named URL attributes. + +>>> isinstance(product_urls, ProductImages) +True +>>> product_urls.small.startswith( +... 'http://localhost:8080/unsafe/quality:85/format:webp/width:320/' +... ) +True +""" + +from dataclasses import dataclass +from typing import Annotated, assert_type + +from imgproxykit import Client, Transform + +# Share processing settings across all variants. +base = Transform(quality=85, format='webp') + + +@dataclass(frozen=True, slots=True) +class ProductImages: + """Declare the result fields and their transformations together.""" + + small: Annotated[str, base.extend(width=320)] + medium: Annotated[str, base.extend(width=640)] + large: Annotated[str, base.extend(width=1280)] + + +client = Client('http://localhost:8080') +source_url = 'https://picsum.photos/id/237/1600/1200' + +# Pass the class to build an instance containing the generated URLs. +product_urls = client.variants(source_url, variants=ProductImages) + +assert_type(product_urls, ProductImages) +assert_type(product_urls.small, str) + +print(product_urls.small) diff --git a/docs/index.rst b/docs/index.rst index b4cb0cb..e37a307 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -62,14 +62,14 @@ Find your way image URLs. * :doc:`User guide ` introduces the client, transformations, processing options, and named variants. -* :doc:`pages/api-reference/index` lists classes, arguments, types, - and extension points. * :doc:`Django integration ` configures a shared client and generates URLs from model file fields. * :doc:`DRF integration ` returns image URLs and variants from serializer fields. * :doc:`FastAPI integration ` registers clients as application dependencies. +* :doc:`pages/api-reference/index` lists classes, arguments, types, + and extension points. * :doc:`pages/changelog` tracks releases and compatibility changes. .. toctree:: diff --git a/docs/pages/api-reference/extensions.rst b/docs/pages/api-reference/extensions.rst index 138bae8..22076da 100644 --- a/docs/pages/api-reference/extensions.rst +++ b/docs/pages/api-reference/extensions.rst @@ -2,8 +2,9 @@ Extension points ================ Customize URL generation by subclassing these components and assigning them -to :attr:`imgproxykit.Client.path_serializer_cls` or -:attr:`imgproxykit.Client.url_signer_cls`. +to :attr:`imgproxykit.Client.path_serializer_cls`, +:attr:`imgproxykit.Client.url_signer_cls`, or +:attr:`imgproxykit.Client.variants_resolver_cls`. Path serializer --------------- @@ -18,3 +19,10 @@ URL signer .. autoclass:: imgproxykit.signing.UrlSigner :members: :special-members: __call__ + +Dataclass variants resolver +--------------------------- + +.. autoclass:: imgproxykit.variants.DataclassVariantsResolver + :members: + :special-members: __call__ diff --git a/docs/pages/integrations/fastapi.rst b/docs/pages/integrations/fastapi.rst index c811f34..cbfb912 100644 --- a/docs/pages/integrations/fastapi.rst +++ b/docs/pages/integrations/fastapi.rst @@ -38,13 +38,22 @@ the application. Keep the returned dependency for use in your routes: .. literalinclude:: /examples/integrations/fastapi/client.py :language: python - :start-at: from typing + :start-at: from dataclasses The client is stored on ``app.state.imgproxykit_client``. URL generation makes no network requests. Configure signing credentials on :class:`~imgproxykit.Client` as described in :doc:`../user-guide/client`. +``ProductImages`` is a standard-library dataclass that declares both the +response fields and their transformations. ``client.variants()`` returns +an instance directly, which the enclosing Pydantic ``ProductResponse`` +serializes as a nested object. FastAPI includes its string fields in the +OpenAPI response schema. + +See :ref:`dataclass-variants` for declaration requirements and shared +transformations. + Multiple clients ---------------- diff --git a/docs/pages/user-guide/options.rst b/docs/pages/user-guide/options.rst index d3284d7..5fdf4dc 100644 --- a/docs/pages/user-guide/options.rst +++ b/docs/pages/user-guide/options.rst @@ -36,7 +36,7 @@ while :class:`~imgproxykit.options.Gravity` selects the crop position: :language: python :lines: 8-25 -``Resize`` requests a 300×300 crop, ``Gravity.center()`` keeps the +``Resize.fill()`` requests a 300×300 crop, ``Gravity.center()`` keeps the center, and ``dpr=2`` requests twice the pixel density. Pass structured options as positional arguments to ``Transform``; scalar keywords can follow them. diff --git a/docs/pages/user-guide/variants.rst b/docs/pages/user-guide/variants.rst index 6d6cbab..17fe64e 100644 --- a/docs/pages/user-guide/variants.rst +++ b/docs/pages/user-guide/variants.rst @@ -3,7 +3,8 @@ Variants Variants define a named group of image versions, each with its own transformation. Pass a mapping of names to :class:`~imgproxykit.Transform` -objects to :meth:`~imgproxykit.Client.variants` to generate their URLs together. +objects to :meth:`~imgproxykit.Client.variants` to generate a dictionary of +URLs. Pass a dataclass class to return a typed instance with URL attributes. .. tip:: @@ -55,6 +56,33 @@ when accessing the generated URLs: invalid key. Without an explicit key annotation, a regular string-keyed mapping typically produces ``dict[str, str]``. +.. _dataclass-variants: + +Return a dataclass +------------------ + +Declare a standard-library dataclass when you want a named result type +for an API response or application code. Each field combines its result +type, ``str``, with a transformation in ``Annotated``: + +.. literalinclude:: /examples/variants/dataclass.py + :language: python + :start-at: from dataclasses + +Pass the class as ``variants=ProductImages`` and access the generated URLs +as attributes, such as ``product_urls.small``. Reuse the class with other +sources or clients. + +.. note:: + + Each field must declare exactly one ``Transform`` in + ``Annotated[str, Transform(...)]``. Use ``Transform()`` for a URL without + processing options. ``InitVar`` and ``init=False`` fields are unsupported. + +Use ``ProductImages`` as a field type in a Pydantic model or msgspec Struct, +and pass the result of ``client.variants()`` to that field. See the +:doc:`FastAPI example <../integrations/fastapi>`. + Next steps ---------- diff --git a/imgproxykit/client.py b/imgproxykit/client.py index f2a3e8e..d04b51b 100644 --- a/imgproxykit/client.py +++ b/imgproxykit/client.py @@ -1,10 +1,18 @@ from collections.abc import Mapping -from typing import ClassVar, overload +from typing import TYPE_CHECKING, ClassVar, overload from imgproxykit.serialization import PathSerializer from imgproxykit.signing import UrlSigner from imgproxykit.transform import Transform from imgproxykit.types import SourceEncoding +from imgproxykit.variants import DataclassVariantsResolver + +if TYPE_CHECKING: + from _typeshed import DataclassInstance + +type _VariantsResult[Key: str] = ( + dict[Key, str] | dict[str, str] | DataclassInstance +) class Client: @@ -19,10 +27,11 @@ class Client: option instances or scalar keyword arguments. Preset options reference names configured on the imgproxy server. - Subclass this client and override :attr:`path_serializer_cls` or - :attr:`url_signer_cls` to customize path construction or signing. - Each client creates its own instances of these components. - + Subclass this client and override :attr:`path_serializer_cls`, + :attr:`url_signer_cls`, or :attr:`variants_resolver_cls` to customize + path construction, signing, or dataclass declaration resolution. + Each client creates its own serializer and signer. A variants resolver + is created for each dataclass declaration passed to :meth:`variants`. """ __slots__ = ( @@ -35,6 +44,10 @@ class Client: path_serializer_cls: ClassVar[type[PathSerializer]] = PathSerializer #: Component that signs paths or returns the unsigned URL marker. url_signer_cls: ClassVar[type[UrlSigner]] = UrlSigner + #: Component that validates dataclass variant declarations. + variants_resolver_cls: ClassVar[type[DataclassVariantsResolver]] = ( + DataclassVariantsResolver + ) @overload def __init__( @@ -136,7 +149,6 @@ def __init__( # noqa: WPS211 ... key='736563726574', ... salt='73616c74', ... ) - """ # Prepare `base_url` only once pre instance self._base_url = base_url.rstrip('/') @@ -186,28 +198,53 @@ def url( signature = self._url_signer(path) return f'{self._base_url}/{signature}{path}' + @overload def variants[Key: str]( self, source: str, *, variants: Mapping[Key, Transform], - ) -> dict[Key, str]: + ) -> dict[Key, str]: ... + + @overload + def variants[VariantSet: DataclassInstance]( + self, + source: str, + *, + variants: type[VariantSet], + ) -> VariantSet: ... + + def variants[Key: str]( + self, + source: str, + *, + variants: Mapping[Key, Transform] | type['DataclassInstance'], + ) -> _VariantsResult[Key]: """ Build named URLs for one source using complete transformations. Args: source: Complete source URI understood by the imgproxy server. variants: Names mapped to the transformation for each variant. - Each transformation is passed unchanged to :meth:`url`. + Alternatively, a dataclass class with fields declared as + ``Annotated[str, Transform(...)]``. Each transformation is + passed unchanged to :meth:`url`. Returns: A new dictionary preserving the input keys and iteration order. The key type is preserved, including literal string unions. An empty mapping produces an empty dictionary. + A dataclass class produces a new instance of that exact class, + with a generated URL in every field. No network request is made. - """ - return { - name: self.url(source, transform=transform) - for name, transform in variants.items() + transforms = ( + variants + if isinstance(variants, Mapping) + else self.variants_resolver_cls(model=variants)() + ) + urls = { + name: self.url(source, transform=transform) # ty: ignore[invalid-argument-type] + for name, transform in transforms.items() } + return urls if isinstance(variants, Mapping) else variants(**urls) # ty: ignore[invalid-return-type, invalid-argument-type] diff --git a/imgproxykit/integrations/django/settings.py b/imgproxykit/integrations/django/settings.py index 363fbb9..4aa80c9 100644 --- a/imgproxykit/integrations/django/settings.py +++ b/imgproxykit/integrations/django/settings.py @@ -104,7 +104,6 @@ def get_config() -> SettingsDict: Raises: ImproperlyConfigured: If ``IMGPROXYKIT`` is missing or ``None``. - """ # Keep LazySettings out of module globals: doctest inspection evaluates it. from django.conf import settings # noqa: PLC0415 diff --git a/imgproxykit/integrations/django/shortcuts.py b/imgproxykit/integrations/django/shortcuts.py index ec1cd08..bfaadd0 100644 --- a/imgproxykit/integrations/django/shortcuts.py +++ b/imgproxykit/integrations/django/shortcuts.py @@ -27,7 +27,6 @@ def imgproxy_url( Raises: KeyError: If a named transformation is not configured. ValueError: If the default source resolver receives an empty file field. - """ if isinstance(transform, str): transform = get_config()[Settings.transforms.value][transform] # pyright: ignore[reportTypedDictNotRequiredAccess] @@ -66,7 +65,6 @@ def imgproxy_variants[Key: str]( Raises: KeyError: If a named variant set is not configured. ValueError: If the default source resolver receives an empty file field. - """ client = get_client() diff --git a/imgproxykit/integrations/django/sources.py b/imgproxykit/integrations/django/sources.py index 06317ca..524098f 100644 --- a/imgproxykit/integrations/django/sources.py +++ b/imgproxykit/integrations/django/sources.py @@ -22,7 +22,6 @@ def resolve_source(source: str | FieldFile) -> str: Returns: Source URI to pass to the configured client. - """ resolver = get_config()[Settings.source_resolver.value] # pyright: ignore[reportTypedDictNotRequiredAccess] if isinstance(resolver, str): @@ -39,6 +38,5 @@ def default_source_resolver(source: str | FieldFile) -> str: Raises: ValueError: If the file field has no associated file, as with direct access to ``FieldFile.url``. - """ return source.url if isinstance(source, FieldFile) else source diff --git a/imgproxykit/integrations/drf/serializers.py b/imgproxykit/integrations/drf/serializers.py index 0fc7e64..8813a16 100644 --- a/imgproxykit/integrations/drf/serializers.py +++ b/imgproxykit/integrations/drf/serializers.py @@ -20,7 +20,6 @@ class ImgproxyURLField(serializers.URLField): transform: Transformation or name from ``IMGPROXYKIT['transforms']``. Omit to add no processing options. **kwargs: DRF field options. ``allow_null`` defaults to ``True``. - """ def __init__( @@ -68,7 +67,6 @@ class ImgproxyVariantsField(serializers.DictField): variants: Transformation mapping or set name from ``IMGPROXYKIT['variants']``. **kwargs: DRF field options. ``allow_null`` defaults to ``True``. - """ def __init__( diff --git a/imgproxykit/options/auto_rotate.py b/imgproxykit/options/auto_rotate.py index e05ab89..708ece7 100644 --- a/imgproxykit/options/auto_rotate.py +++ b/imgproxykit/options/auto_rotate.py @@ -23,7 +23,6 @@ class AutoRotate(ScalarOption[bool]): See `imgproxy auto rotate `_. - """ name: ClassVar[str] = 'auto_rotate' diff --git a/imgproxykit/options/background.py b/imgproxykit/options/background.py index f96b680..758b61c 100644 --- a/imgproxykit/options/background.py +++ b/imgproxykit/options/background.py @@ -32,7 +32,6 @@ class Background(Option): See `imgproxy background `_. - """ color: str | tuple[int, int, int] | None = None diff --git a/imgproxykit/options/base.py b/imgproxykit/options/base.py index 75112e7..f3f926f 100644 --- a/imgproxykit/options/base.py +++ b/imgproxykit/options/base.py @@ -20,7 +20,6 @@ class Option(ABC): See `imgproxy processing options `_. - """ __slots__ = () @@ -55,7 +54,6 @@ def validate(self) -> None: # noqa: B027 Raises: ValueError: If option arguments are invalid. - """ # TODO: Add per option validation @@ -82,7 +80,6 @@ class ScalarOption[ValueT: Argument](Option): Attributes: value: Scalar processing option value. - """ value: ValueT @@ -119,7 +116,6 @@ def from_kwarg(cls, name: str, value: Argument) -> 'ScalarOption[Any]': Raises: TypeError: If the keyword does not identify a scalar option. ValueError: If the concrete option rejects the supplied value. - """ option_cls = cls._registry.get(name) if option_cls is None: diff --git a/imgproxykit/options/blur.py b/imgproxykit/options/blur.py index d4e166a..1585cf7 100644 --- a/imgproxykit/options/blur.py +++ b/imgproxykit/options/blur.py @@ -22,7 +22,6 @@ class Blur(ScalarOption[float]): See `imgproxy blur `_. - """ name: ClassVar[str] = 'blur' diff --git a/imgproxykit/options/cache_buster.py b/imgproxykit/options/cache_buster.py index e8a6ee4..2784000 100644 --- a/imgproxykit/options/cache_buster.py +++ b/imgproxykit/options/cache_buster.py @@ -25,7 +25,6 @@ class CacheBuster(ScalarOption[str]): See `imgproxy cache buster `_. - """ name: ClassVar[str] = 'cachebuster' diff --git a/imgproxykit/options/crop.py b/imgproxykit/options/crop.py index 12cb864..ae8f25e 100644 --- a/imgproxykit/options/crop.py +++ b/imgproxykit/options/crop.py @@ -36,7 +36,6 @@ class Crop(Option): See `imgproxy crop `_. - """ width: float | None = None diff --git a/imgproxykit/options/dpr.py b/imgproxykit/options/dpr.py index 2f28b7a..8155f69 100644 --- a/imgproxykit/options/dpr.py +++ b/imgproxykit/options/dpr.py @@ -22,7 +22,6 @@ class DPR(ScalarOption[float]): See `imgproxy DPR `_. - """ # Public API: diff --git a/imgproxykit/options/enforce_thumbnail.py b/imgproxykit/options/enforce_thumbnail.py index 57e7b65..d5c2801 100644 --- a/imgproxykit/options/enforce_thumbnail.py +++ b/imgproxykit/options/enforce_thumbnail.py @@ -23,7 +23,6 @@ class EnforceThumbnail(ScalarOption[bool]): See `imgproxy enforce thumbnail `_. - """ name: ClassVar[str] = 'enforce_thumbnail' diff --git a/imgproxykit/options/enlarge.py b/imgproxykit/options/enlarge.py index 301ba9d..e014787 100644 --- a/imgproxykit/options/enlarge.py +++ b/imgproxykit/options/enlarge.py @@ -22,7 +22,6 @@ class Enlarge(ScalarOption[bool]): See `imgproxy enlarge `_. - """ name: ClassVar[str] = 'enlarge' diff --git a/imgproxykit/options/expires.py b/imgproxykit/options/expires.py index 4cf123e..18ff2ae 100644 --- a/imgproxykit/options/expires.py +++ b/imgproxykit/options/expires.py @@ -16,7 +16,6 @@ class Expires(ScalarOption[int]): See `imgproxy expires `_. - """ # TODO: Accept timezone-aware datetime objects and convert them to Unix diff --git a/imgproxykit/options/extend.py b/imgproxykit/options/extend.py index c84f2ac..1e51851 100644 --- a/imgproxykit/options/extend.py +++ b/imgproxykit/options/extend.py @@ -27,7 +27,6 @@ class Extend(Option): See `imgproxy extend `_. - """ value: bool diff --git a/imgproxykit/options/extend_aspect_ratio.py b/imgproxykit/options/extend_aspect_ratio.py index cdec61d..e6dcfa4 100644 --- a/imgproxykit/options/extend_aspect_ratio.py +++ b/imgproxykit/options/extend_aspect_ratio.py @@ -27,7 +27,6 @@ class ExtendAspectRatio(Option): See `imgproxy extend aspect ratio `_. - """ value: bool diff --git a/imgproxykit/options/filename.py b/imgproxykit/options/filename.py index 104b7c1..4485cbd 100644 --- a/imgproxykit/options/filename.py +++ b/imgproxykit/options/filename.py @@ -26,7 +26,6 @@ class Filename(ScalarOption[str]): See `imgproxy filename `_. - """ name: ClassVar[str] = 'filename' diff --git a/imgproxykit/options/flip.py b/imgproxykit/options/flip.py index 05945a1..af2b391 100644 --- a/imgproxykit/options/flip.py +++ b/imgproxykit/options/flip.py @@ -28,7 +28,6 @@ class Flip(Option): See `imgproxy flip `_. - """ horizontal: bool | None = None diff --git a/imgproxykit/options/format.py b/imgproxykit/options/format.py index c02cdf6..74fc41b 100644 --- a/imgproxykit/options/format.py +++ b/imgproxykit/options/format.py @@ -21,11 +21,7 @@ class Format(ScalarOption[FormatType]): See `imgproxy format `_. - """ - # Public API: - - # Public API: name: ClassVar[str] = 'format' short_name: ClassVar[str | None] = 'f' diff --git a/imgproxykit/options/format_quality.py b/imgproxykit/options/format_quality.py index cd05846..4b3ff46 100644 --- a/imgproxykit/options/format_quality.py +++ b/imgproxykit/options/format_quality.py @@ -23,7 +23,6 @@ class FormatQuality(Option): See `imgproxy format quality `_. - """ qualities: tuple[tuple[FormatType, int], ...] diff --git a/imgproxykit/options/gravity.py b/imgproxykit/options/gravity.py index 98d3ad0..f896f9b 100644 --- a/imgproxykit/options/gravity.py +++ b/imgproxykit/options/gravity.py @@ -31,15 +31,12 @@ class Gravity(Option): # noqa: WPS214 See `imgproxy gravity `_. - """ - # Public API: position: Position offset_x: float | None = None offset_y: float | None = None - # Public API: name: ClassVar[str] = 'gravity' short_name: ClassVar[str | None] = 'g' diff --git a/imgproxykit/options/height.py b/imgproxykit/options/height.py index 923ec9f..5d94368 100644 --- a/imgproxykit/options/height.py +++ b/imgproxykit/options/height.py @@ -23,7 +23,6 @@ class Height(ScalarOption[int]): See `imgproxy height `_. - """ name: ClassVar[str] = 'height' diff --git a/imgproxykit/options/keep_copyright.py b/imgproxykit/options/keep_copyright.py index f0f26d8..2247104 100644 --- a/imgproxykit/options/keep_copyright.py +++ b/imgproxykit/options/keep_copyright.py @@ -22,7 +22,6 @@ class KeepCopyright(ScalarOption[bool]): See `imgproxy keep copyright `_. - """ name: ClassVar[str] = 'keep_copyright' diff --git a/imgproxykit/options/max_animation_frame_resolution.py b/imgproxykit/options/max_animation_frame_resolution.py index 9b06893..df77af3 100644 --- a/imgproxykit/options/max_animation_frame_resolution.py +++ b/imgproxykit/options/max_animation_frame_resolution.py @@ -25,7 +25,6 @@ class MaxAnimationFrameResolution(ScalarOption[float]): See `imgproxy max animation frame resolution `_. - """ name: ClassVar[str] = 'max_animation_frame_resolution' diff --git a/imgproxykit/options/max_animation_frames.py b/imgproxykit/options/max_animation_frames.py index 02d3f16..1e358f6 100644 --- a/imgproxykit/options/max_animation_frames.py +++ b/imgproxykit/options/max_animation_frames.py @@ -25,7 +25,6 @@ class MaxAnimationFrames(ScalarOption[int]): See `imgproxy max animation frames `_. - """ name: ClassVar[str] = 'max_animation_frames' diff --git a/imgproxykit/options/max_bytes.py b/imgproxykit/options/max_bytes.py index a1c7d97..a2abdbc 100644 --- a/imgproxykit/options/max_bytes.py +++ b/imgproxykit/options/max_bytes.py @@ -23,7 +23,6 @@ class MaxBytes(ScalarOption[int]): See `imgproxy max bytes `_. - """ name: ClassVar[str] = 'max_bytes' diff --git a/imgproxykit/options/max_result_dimension.py b/imgproxykit/options/max_result_dimension.py index 2cb58c7..2edf7ee 100644 --- a/imgproxykit/options/max_result_dimension.py +++ b/imgproxykit/options/max_result_dimension.py @@ -25,7 +25,6 @@ class MaxResultDimension(ScalarOption[int]): See `imgproxy max result dimension `_. - """ name: ClassVar[str] = 'max_result_dimension' diff --git a/imgproxykit/options/max_src_file_size.py b/imgproxykit/options/max_src_file_size.py index 4987198..8ac6cd9 100644 --- a/imgproxykit/options/max_src_file_size.py +++ b/imgproxykit/options/max_src_file_size.py @@ -25,7 +25,6 @@ class MaxSrcFileSize(ScalarOption[int]): See `imgproxy max src file size `_. - """ name: ClassVar[str] = 'max_src_file_size' diff --git a/imgproxykit/options/max_src_resolution.py b/imgproxykit/options/max_src_resolution.py index dbdd5ed..d5d6dbd 100644 --- a/imgproxykit/options/max_src_resolution.py +++ b/imgproxykit/options/max_src_resolution.py @@ -25,7 +25,6 @@ class MaxSrcResolution(ScalarOption[float]): See `imgproxy max src resolution `_. - """ name: ClassVar[str] = 'max_src_resolution' diff --git a/imgproxykit/options/min_height.py b/imgproxykit/options/min_height.py index be7d4df..5c5134c 100644 --- a/imgproxykit/options/min_height.py +++ b/imgproxykit/options/min_height.py @@ -22,7 +22,6 @@ class MinHeight(ScalarOption[int]): See `imgproxy min height `_. - """ name: ClassVar[str] = 'min-height' diff --git a/imgproxykit/options/min_width.py b/imgproxykit/options/min_width.py index 72201ac..0bbf3f1 100644 --- a/imgproxykit/options/min_width.py +++ b/imgproxykit/options/min_width.py @@ -22,7 +22,6 @@ class MinWidth(ScalarOption[int]): See `imgproxy min width `_. - """ name: ClassVar[str] = 'min-width' diff --git a/imgproxykit/options/padding.py b/imgproxykit/options/padding.py index 2afcd24..0d5ce96 100644 --- a/imgproxykit/options/padding.py +++ b/imgproxykit/options/padding.py @@ -38,7 +38,6 @@ class Padding(Option): See `imgproxy padding `_. - """ top: int | None = None diff --git a/imgproxykit/options/pixelate.py b/imgproxykit/options/pixelate.py index a95a008..b818e90 100644 --- a/imgproxykit/options/pixelate.py +++ b/imgproxykit/options/pixelate.py @@ -22,7 +22,6 @@ class Pixelate(ScalarOption[int]): See `imgproxy pixelate `_. - """ name: ClassVar[str] = 'pixelate' diff --git a/imgproxykit/options/preserve_hdr.py b/imgproxykit/options/preserve_hdr.py index a056162..02b1723 100644 --- a/imgproxykit/options/preserve_hdr.py +++ b/imgproxykit/options/preserve_hdr.py @@ -22,7 +22,6 @@ class PreserveHDR(ScalarOption[bool]): See `imgproxy preserve hdr `_. - """ name: ClassVar[str] = 'preserve_hdr' diff --git a/imgproxykit/options/preset.py b/imgproxykit/options/preset.py index cdc9a9b..7d6b0e5 100644 --- a/imgproxykit/options/preset.py +++ b/imgproxykit/options/preset.py @@ -25,7 +25,6 @@ class Preset(Option): See `imgproxy preset `_. - """ names: tuple[str, ...] diff --git a/imgproxykit/options/quality.py b/imgproxykit/options/quality.py index 40504c4..eac6ad6 100644 --- a/imgproxykit/options/quality.py +++ b/imgproxykit/options/quality.py @@ -20,11 +20,7 @@ class Quality(ScalarOption[int]): See `imgproxy quality `_. - """ - # Public API: - - # Public API: name: ClassVar[str] = 'quality' short_name: ClassVar[str | None] = 'q' diff --git a/imgproxykit/options/raw.py b/imgproxykit/options/raw.py index 6065ba7..3e62618 100644 --- a/imgproxykit/options/raw.py +++ b/imgproxykit/options/raw.py @@ -23,7 +23,6 @@ class Raw(ScalarOption[bool]): See `imgproxy raw `_. - """ name: ClassVar[str] = 'raw' diff --git a/imgproxykit/options/resize.py b/imgproxykit/options/resize.py index 7b758d2..51ce649 100644 --- a/imgproxykit/options/resize.py +++ b/imgproxykit/options/resize.py @@ -38,14 +38,12 @@ class Resize(Option): # noqa: WPS214 """ - # Public API: width: int | None = None height: int | None = None mode: ResizeType | None = None enlarge: bool | None = None extend: bool | None = None - # Public API: name: ClassVar[str] = 'resize' short_name: ClassVar[str | None] = 'rs' diff --git a/imgproxykit/options/resizing_type.py b/imgproxykit/options/resizing_type.py index 882b7f3..254224b 100644 --- a/imgproxykit/options/resizing_type.py +++ b/imgproxykit/options/resizing_type.py @@ -24,7 +24,6 @@ class ResizingType(ScalarOption[ResizeType]): See `imgproxy resizing type `_. - """ name: ClassVar[str] = 'resizing_type' diff --git a/imgproxykit/options/return_attachment.py b/imgproxykit/options/return_attachment.py index a8c8410..28c84f8 100644 --- a/imgproxykit/options/return_attachment.py +++ b/imgproxykit/options/return_attachment.py @@ -23,7 +23,6 @@ class ReturnAttachment(ScalarOption[bool]): See `imgproxy return attachment `_. - """ name: ClassVar[str] = 'return_attachment' diff --git a/imgproxykit/options/rotate.py b/imgproxykit/options/rotate.py index fdb3f36..dc4a9e5 100644 --- a/imgproxykit/options/rotate.py +++ b/imgproxykit/options/rotate.py @@ -22,7 +22,6 @@ class Rotate(ScalarOption[int]): See `imgproxy rotate `_. - """ name: ClassVar[str] = 'rotate' diff --git a/imgproxykit/options/sharpen.py b/imgproxykit/options/sharpen.py index 76c89bb..a85404b 100644 --- a/imgproxykit/options/sharpen.py +++ b/imgproxykit/options/sharpen.py @@ -22,7 +22,6 @@ class Sharpen(ScalarOption[float]): See `imgproxy sharpen `_. - """ name: ClassVar[str] = 'sharpen' diff --git a/imgproxykit/options/size.py b/imgproxykit/options/size.py index 66b503e..fe4d5bf 100644 --- a/imgproxykit/options/size.py +++ b/imgproxykit/options/size.py @@ -27,7 +27,6 @@ class Size(Option): See `imgproxy size `_. - """ width: int | None = None diff --git a/imgproxykit/options/skip_processing.py b/imgproxykit/options/skip_processing.py index f56b9c3..cd1eaea 100644 --- a/imgproxykit/options/skip_processing.py +++ b/imgproxykit/options/skip_processing.py @@ -24,7 +24,6 @@ class SkipProcessing(Option): See `imgproxy skip processing `_. - """ formats: tuple[FormatType, ...] diff --git a/imgproxykit/options/strip_color_profile.py b/imgproxykit/options/strip_color_profile.py index b40ce72..7f7fcd9 100644 --- a/imgproxykit/options/strip_color_profile.py +++ b/imgproxykit/options/strip_color_profile.py @@ -23,7 +23,6 @@ class StripColorProfile(ScalarOption[bool]): See `imgproxy strip color profile `_. - """ name: ClassVar[str] = 'strip_color_profile' diff --git a/imgproxykit/options/strip_metadata.py b/imgproxykit/options/strip_metadata.py index 77b636d..095058d 100644 --- a/imgproxykit/options/strip_metadata.py +++ b/imgproxykit/options/strip_metadata.py @@ -22,7 +22,6 @@ class StripMetadata(ScalarOption[bool]): See `imgproxy strip metadata `_. - """ name: ClassVar[str] = 'strip_metadata' diff --git a/imgproxykit/options/trim.py b/imgproxykit/options/trim.py index 5aa2783..3c7244f 100644 --- a/imgproxykit/options/trim.py +++ b/imgproxykit/options/trim.py @@ -29,7 +29,6 @@ class Trim(Option): See `imgproxy trim `_. - """ threshold: float diff --git a/imgproxykit/options/watermark.py b/imgproxykit/options/watermark.py index ab128e9..8308021 100644 --- a/imgproxykit/options/watermark.py +++ b/imgproxykit/options/watermark.py @@ -33,17 +33,14 @@ class Watermark(Option): `_ and `watermark image configuration `_. - """ - # Public API: opacity: float position: WatermarkPosition | None = None offset_x: float | None = None offset_y: float | None = None scale: float | None = None - # Public API: name: ClassVar[str] = 'watermark' short_name: ClassVar[str | None] = 'wm' diff --git a/imgproxykit/options/width.py b/imgproxykit/options/width.py index 708cabb..d1d3c55 100644 --- a/imgproxykit/options/width.py +++ b/imgproxykit/options/width.py @@ -23,7 +23,6 @@ class Width(ScalarOption[int]): See `imgproxy width `_. - """ name: ClassVar[str] = 'width' diff --git a/imgproxykit/options/zoom.py b/imgproxykit/options/zoom.py index ae0c0eb..1f021dc 100644 --- a/imgproxykit/options/zoom.py +++ b/imgproxykit/options/zoom.py @@ -29,7 +29,6 @@ class Zoom(Option): See `imgproxy zoom `_. - """ factor_x: float diff --git a/imgproxykit/serialization.py b/imgproxykit/serialization.py index 3ca9dcd..bef4628 100644 --- a/imgproxykit/serialization.py +++ b/imgproxykit/serialization.py @@ -20,7 +20,6 @@ class PathSerializer: Subclass this serializer and override :meth:`__call__` to customize path construction. Set the subclass on :attr:`imgproxykit.Client.path_serializer_cls` to use it in a client. - """ __slots__ = ( @@ -50,7 +49,6 @@ def __init__( padding or ``'plain'`` with full UTF-8 percent-encoding. See `imgproxy source URLs `_. - """ self._argument_separator = argument_separator self._use_short_options = use_short_options @@ -93,12 +91,11 @@ def __call__( ... source_encoding='base64', ... ) >>> thumbnail = Transform( - ... Resize(width=96, height=96, mode='fill'), + ... Resize.fill(width=96, height=96), ... format='webp', ... ) >>> serializer('s3://images/photo.jpg', transform=thumbnail) '/resize:fill:96:96/format:webp/czM6Ly9pbWFnZXMvcGhvdG8uanBn' - """ options: tuple[str, ...] = () if transform is not None: @@ -154,7 +151,6 @@ def _serialize_segment( Internal ``None`` values become empty positions; trailing ``None`` values are omitted. Explicit zeroes and empty strings retain their positions. Booleans are serialized as ``1`` and ``0``. - """ # TODO: Verify reserved characters in string arguments and preset names # against imgproxy before choosing encoding or rejection. diff --git a/imgproxykit/signing.py b/imgproxykit/signing.py index 8d18a1d..e8c06b2 100644 --- a/imgproxykit/signing.py +++ b/imgproxykit/signing.py @@ -21,7 +21,6 @@ class UrlSigner: Subclass this signer and override :meth:`__call__` to customize signing. Set the subclass on :attr:`imgproxykit.Client.url_signer_cls` to use it in a client. - """ __slots__ = ( @@ -101,7 +100,6 @@ def __call__(self, path: str) -> str: Returns: URL-safe Base64 signature without padding, or the unsigned marker when both signing credentials are ``None``. - """ if self._credentials is None: return self._unsigned_marker diff --git a/imgproxykit/transform.py b/imgproxykit/transform.py index 9b77779..a6bece8 100644 --- a/imgproxykit/transform.py +++ b/imgproxykit/transform.py @@ -13,7 +13,6 @@ class ScalarOptions(TypedDict, total=False, closed=True): Optional scalar processing arguments. Accepted by :class:`Transform` and :meth:`~imgproxykit.Transform.extend`. - """ auto_rotate: bool @@ -67,7 +66,6 @@ class Transform: Attributes: options: Tuple of processing options. Defaults to an empty tuple, which adds no processing option segments to the URL. - """ options: tuple[Option, ...] @@ -93,13 +91,12 @@ def __init__( >>> from imgproxykit import Transform >>> from imgproxykit.options import Resize >>> thumbnail = Transform( - ... Resize(width=96, height=96, mode='fill'), + ... Resize.fill(width=96, height=96), ... quality=85, ... format='webp', ... ) >>> [option.name for option in thumbnail.options] ['resize', 'quality', 'format'] - """ # Keep variadic options while initializing a frozen dataclass. # Bypassing __setattr__ is safe until initialization is finished. @@ -129,6 +126,5 @@ def extend( ['quality', 'format'] >>> [option.name for option in retina.options] ['quality', 'format', 'dpr'] - """ return Transform(*self.options, *options, **kwargs) diff --git a/imgproxykit/variants.py b/imgproxykit/variants.py new file mode 100644 index 0000000..46cbb2f --- /dev/null +++ b/imgproxykit/variants.py @@ -0,0 +1,93 @@ +from dataclasses import Field, InitVar, dataclass, fields +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + get_args, + get_origin, + get_type_hints, +) + +from imgproxykit.transform import Transform + +if TYPE_CHECKING: + from _typeshed import DataclassInstance + + +@dataclass(frozen=True, slots=True, kw_only=True) +class DataclassVariantsResolver: + """ + Validate a dataclass variant declaration and resolve its transformations. + + Each constructor field must be annotated with + ``Annotated[str, Transform(...)]`` and contain exactly one + :class:`~imgproxykit.Transform`. Other annotation metadata is ignored. + ``InitVar`` and fields declared with ``init=False`` are unsupported. + + This component only resolves the declaration. URL generation and result + construction are handled by :meth:`imgproxykit.Client.variants`. + """ + + #: Dataclass class whose fields declare the named image variants. + model: type['DataclassInstance'] + + def __call__(self) -> dict[str, Transform]: + """ + Resolve transformations in dataclass field order. + + Returns: + A new mapping from result field names to their transformations. + + Raises: + TypeError: If the declaration contains unsupported fields, + invalid field annotations, or ambiguous transformations. + NameError: If a forward reference in the annotations cannot + be resolved in the model's defining namespace. + """ + hints = self._resolve_annotations() + return { + field.name: self._resolve_field(field, hints[field.name]) + for field in fields(self.model) + } + + def _resolve_annotations(self) -> dict[str, Any]: + """Resolve annotation metadata and reject init-only fields.""" + hints = get_type_hints(self.model, include_extras=True) + model = self.model + for field_name, annotation in hints.items(): + if annotation is InitVar or isinstance(annotation, InitVar): + raise TypeError( + f'Field {field_name!r} of {model!r} uses `InitVar`. ' + 'Variant declarations do not support init-only fields', + ) + return hints + + def _resolve_field(self, field: Field[Any], annotation: Any) -> Transform: + """Validate a result field and extract its transformation metadata.""" + model = self.model + field_name = field.name + if not field.init: + raise TypeError( + f'Field {field_name!r} of {model!r} has `init=False`. ' + 'Variant fields must accept a constructor argument', + ) + + arguments = get_args(annotation) + if get_origin(annotation) is not Annotated or arguments[0] is not str: + raise TypeError( + f'Field {field_name!r} of {model!r} must be annotated with ' + f'`Annotated[str, Transform(...)]`, got {annotation!r}', + ) + + transforms = [ + metadata + for metadata in arguments[1:] + if isinstance(metadata, Transform) + ] + transform_count = len(transforms) + if transform_count != 1: + raise TypeError( + f'Field {field_name!r} of {model!r} must declare exactly one ' + f'`Transform` in its metadata, got {transform_count}', + ) + return transforms[0] diff --git a/test_apps/fastapi/app.py b/test_apps/fastapi/app.py index 8a07200..341f471 100644 --- a/test_apps/fastapi/app.py +++ b/test_apps/fastapi/app.py @@ -1,6 +1,8 @@ +from dataclasses import dataclass from typing import Annotated, Final from fastapi import Depends, FastAPI +from pydantic import BaseModel from imgproxykit import Client, Transform from imgproxykit.integrations.fastapi import add_imgproxy @@ -17,7 +19,21 @@ ), ) -_PREVIEW: Final = Transform(width=640, format='webp') +_BASE: Final = Transform(format='webp') +_THUMBNAIL: Final = _BASE.extend(width=320) +_PREVIEW: Final = _BASE.extend(width=640) + + +@dataclass(frozen=True, slots=True) +class _Variants: + thumbnail: Annotated[str, _THUMBNAIL] + preview: Annotated[str, _PREVIEW] + + +class VariantsResponse(BaseModel): + """Serialize typed image variants inside a response DTO.""" + + images: _Variants @app.get('/images') @@ -27,3 +43,14 @@ async def image( ) -> dict[str, str]: """Return an imgproxy URL for the requested source image.""" return {'url': imgproxy.url(source, transform=_PREVIEW)} + + +@app.get('/images/variants') +async def image_variants( + source: str, + imgproxy: Annotated[Client, Depends(get_imgproxy)], +) -> VariantsResponse: + """Return typed imgproxy variants for the requested source image.""" + return VariantsResponse( + images=imgproxy.variants(source, variants=_Variants), + ) diff --git a/tests/test_integration/test_fastapi.py b/tests/test_integration/test_fastapi.py index e8a3201..269e988 100644 --- a/tests/test_integration/test_fastapi.py +++ b/tests/test_integration/test_fastapi.py @@ -22,8 +22,34 @@ async def test_image_url(fastapi_client: zapros.AsyncClient) -> None: assert response.status == HTTPStatus.OK assert response.json == snapshot({ - 'url': 'http://localhost:10002/' - 'L0RDdxi3h1d9jV7NThc_36IC9chNMxBMoBRJIovd1gM/' - 'width:640/format:webp/' - 'czM6Ly9pbWFnZXMvcGhvdG8uanBn', + 'url': ( + 'http://localhost:10002/' + 'wWaQL2fIdx4tC8NALpxilcq2Kfv24AxRiRQB60l_bHE/' + 'format:webp/width:640/czM6Ly9pbWFnZXMvcGhvdG8uanBn' + ), + }) + + +@pytest.mark.asyncio +async def test_image_variants(fastapi_client: zapros.AsyncClient) -> None: + """Serialize dataclass variants through a Pydantic response DTO.""" + response = await fastapi_client.get( + '/images/variants', + params={'source': 's3://images/photo.jpg'}, + ) + + assert response.status == HTTPStatus.OK + assert response.json == snapshot({ + 'images': { + 'thumbnail': ( + 'http://localhost:10002/' + 'eMwawA59uE4G9Po9oBnS-6Dxr6U95oz7oEUp5YAnFBY/' + 'format:webp/width:320/czM6Ly9pbWFnZXMvcGhvdG8uanBn' + ), + 'preview': ( + 'http://localhost:10002/' + 'wWaQL2fIdx4tC8NALpxilcq2Kfv24AxRiRQB60l_bHE/' + 'format:webp/width:640/czM6Ly9pbWFnZXMvcGhvdG8uanBn' + ), + }, }) diff --git a/tests/test_unit/test_client/test_dataclass_variants.py b/tests/test_unit/test_client/test_dataclass_variants.py new file mode 100644 index 0000000..f940004 --- /dev/null +++ b/tests/test_unit/test_client/test_dataclass_variants.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Annotated, Final, assert_type + +from imgproxykit import Client, Transform +from tests.fixtures.credentials import Credentials + +_SOURCE: Final = 'https://example.com/photo.jpg' + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _ImageVariants: + """Provide typed URL fields for client integration checks.""" + + small: Annotated[str, Transform(width=320)] + original: Annotated[str, Transform()] + + +@dataclass(frozen=True, slots=True) +class _EmptyVariants: + """Declare an empty result.""" + + +def test_dataclass_variants() -> None: + """Return the declared dataclass with generated URLs in its fields.""" + images = Client('https://example.com').variants( + _SOURCE, + variants=_ImageVariants, + ) + + assert_type(images, _ImageVariants) + assert_type(images.small, str) + assert images == _ImageVariants( + small='https://example.com/unsafe/width:320/' + 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', + original='https://example.com/unsafe/' + 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', + ) + + +def test_signed_dataclass_variants(credentials: Credentials) -> None: + """Use the client's signing and source encoding for every variant.""" + client = Client( + 'https://example.com', + source_encoding='plain', + **credentials, + ) + + images = client.variants(_SOURCE, variants=_ImageVariants) + + assert images == _ImageVariants( + small=client.url(_SOURCE, transform=Transform(width=320)), + original=client.url(_SOURCE), + ) + + +def test_empty_dataclass_variants() -> None: + """Construct a result when the declaration has no fields.""" + client = Client('https://example.com') + + assert client.variants(_SOURCE, variants=_EmptyVariants) == _EmptyVariants() diff --git a/tests/test_unit/test_client/test_mapping_variants.py b/tests/test_unit/test_client/test_mapping_variants.py new file mode 100644 index 0000000..33aade7 --- /dev/null +++ b/tests/test_unit/test_client/test_mapping_variants.py @@ -0,0 +1,46 @@ +from collections.abc import Callable, Mapping +from types import MappingProxyType +from typing import Final, Literal, assert_type + +import pytest +from inline_snapshot import snapshot + +from imgproxykit import Client, Transform + +_SOURCE: Final = 'https://example.com/photo.jpg' + +type _VariantKeys = Literal['small', 'large'] +type _MappingFactory = Callable[ + [dict[_VariantKeys, Transform]], + Mapping[_VariantKeys, Transform], +] + + +@pytest.mark.parametrize('mapping_factory', [dict, MappingProxyType]) +def test_variants(*, mapping_factory: _MappingFactory) -> None: + """Ensure that each variant name maps to its transformed URL.""" + client = Client('https://example.com') + transforms: dict[_VariantKeys, Transform] = { + 'small': Transform(width=320), + 'large': Transform(width=1280), + } + variants = mapping_factory(transforms) + + urls = client.variants(_SOURCE, variants=variants) + + assert_type(urls, dict[_VariantKeys, str]) + assert urls == snapshot({ + 'small': 'https://example.com/unsafe/width:320/' + 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', + 'large': 'https://example.com/unsafe/width:1280/' + 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', + }) + assert list(urls) == ['small', 'large'] + assert dict(variants) == transforms + + +def test_empty_variants() -> None: + """Ensure that an empty variant mapping produces no URLs.""" + client = Client('https://example.com') + + assert client.variants(_SOURCE, variants={}) == {} diff --git a/tests/test_unit/test_client/test_reuse_variants.py b/tests/test_unit/test_client/test_reuse_variants.py new file mode 100644 index 0000000..e3b84e9 --- /dev/null +++ b/tests/test_unit/test_client/test_reuse_variants.py @@ -0,0 +1,62 @@ +from dataclasses import dataclass +from typing import Annotated, Final + +from imgproxykit import Client, Transform +from tests.fixtures.credentials import Credentials + +_SOURCE: Final = 'https://example.com/photo.jpg' + + +@dataclass(frozen=True, slots=True) +class _ImageVariants: + """Declare one reusable result shape for different sources and clients.""" + + small: Annotated[str, Transform(width=320)] + original: Annotated[str, Transform()] + + +def test_different_sources() -> None: + """Reuse a declaration without retaining URLs from an earlier source.""" + client = Client('https://example.com') + other_source = 's3://bucket/other.jpg' + + first = client.variants(_SOURCE, variants=_ImageVariants) + other = client.variants(other_source, variants=_ImageVariants) + + assert first == _ImageVariants( + small=client.url(_SOURCE, transform=Transform(width=320)), + original=client.url(_SOURCE), + ) + assert other == _ImageVariants( + small=client.url(other_source, transform=Transform(width=320)), + original=client.url(other_source), + ) + + +def test_different_clients(credentials: Credentials) -> None: + """Keep the host and signing settings specific to each client.""" + unsigned = Client('https://unsigned.example.com') + signed = Client('https://signed.example.com', **credentials) + + first = unsigned.variants(_SOURCE, variants=_ImageVariants) + other = signed.variants(_SOURCE, variants=_ImageVariants) + + assert first == _ImageVariants( + small=unsigned.url(_SOURCE, transform=Transform(width=320)), + original=unsigned.url(_SOURCE), + ) + assert other == _ImageVariants( + small=signed.url(_SOURCE, transform=Transform(width=320)), + original=signed.url(_SOURCE), + ) + + +def test_fresh_instances() -> None: + """Return equal but independent instances for identical arguments.""" + client = Client('https://example.com') + + first = client.variants(_SOURCE, variants=_ImageVariants) + repeated = client.variants(_SOURCE, variants=_ImageVariants) + + assert first == repeated + assert first is not repeated diff --git a/tests/test_unit/test_client.py b/tests/test_unit/test_client/test_url.py similarity index 72% rename from tests/test_unit/test_client.py rename to tests/test_unit/test_client/test_url.py index 8614355..7559fbe 100644 --- a/tests/test_unit/test_client.py +++ b/tests/test_unit/test_client/test_url.py @@ -66,30 +66,3 @@ def test_signed_url(credentials: Credentials) -> None: '9TG79qV8tG6YeCbuGRKt1eLM9SySuCJAldwf3zcrlJU/' 'resize:fill:300:200/format:webp/aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', ) - - -def test_variants() -> None: - """Ensure that each variant name maps to its transformed URL.""" - client = Client('https://example.com') - - urls = client.variants( - _SOURCE, - variants={ - 'small': Transform(width=320), - 'large': Transform(width=1280), - }, - ) - - assert urls == snapshot({ - 'small': 'https://example.com/unsafe/width:320/' - 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', - 'large': 'https://example.com/unsafe/width:1280/' - 'aHR0cHM6Ly9leGFtcGxlLmNvbS9waG90by5qcGc', - }) - - -def test_empty_variants() -> None: - """Ensure that an empty variant mapping produces no URLs.""" - client = Client('https://example.com') - - assert client.variants(_SOURCE, variants={}) == {} diff --git a/tests/test_unit/test_variants/test_annotations.py b/tests/test_unit/test_variants/test_annotations.py new file mode 100644 index 0000000..ab5a387 --- /dev/null +++ b/tests/test_unit/test_variants/test_annotations.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass, make_dataclass +from re import escape +from typing import Annotated, Any, Final + +import pytest + +from imgproxykit import Transform +from imgproxykit.variants import DataclassVariantsResolver + +_SMALL: Final = Transform(width=320) +_LARGE: Final = Transform(width=1280) +_Thumbnail = Annotated[str, _SMALL] + + +@dataclass +class _AliasVariants: + """Reference a module-level alias through a string annotation.""" + + small: '_Thumbnail' + + +def test_alias_and_forward_reference() -> None: + """Resolve a string annotation referring to a module-level type alias.""" + assert DataclassVariantsResolver(model=_AliasVariants)() == { + 'small': _SMALL + } + + +@pytest.mark.parametrize( + ('annotation', 'message'), + [ + (str, 'must be annotated with `Annotated[str, Transform(...)]`'), + ( + Annotated[int, _SMALL], + 'must be annotated with `Annotated[str, Transform(...)]`', + ), + (Annotated[str, 'description'], 'got 0'), + (Annotated[str, _SMALL, _LARGE], 'got 2'), + (Annotated[str, _SMALL, _SMALL], 'got 2'), + ], +) +def test_invalid_annotation(*, annotation: Any, message: str) -> None: + """Reject invalid result types and missing or repeated transformations.""" + model = make_dataclass('InvalidVariants', [('small', annotation)]) + + with pytest.raises(TypeError, match=escape(message)) as exc_info: + DataclassVariantsResolver(model=model)() # ty: ignore[invalid-argument-type] + + assert "Field 'small'" in str(exc_info.value) + assert 'InvalidVariants' in str(exc_info.value) + + +def test_unresolved_forward_reference() -> None: + """Propagate unresolved application annotations without hiding the name.""" + model = make_dataclass( + 'UnresolvedVariants', + [('small', 'MissingThumbnail')], + ) + + with pytest.raises(NameError, match='MissingThumbnail'): + DataclassVariantsResolver(model=model)() # ty: ignore[invalid-argument-type] diff --git a/tests/test_unit/test_variants/test_declarations.py b/tests/test_unit/test_variants/test_declarations.py new file mode 100644 index 0000000..f076d66 --- /dev/null +++ b/tests/test_unit/test_variants/test_declarations.py @@ -0,0 +1,51 @@ +from dataclasses import InitVar, dataclass, field, make_dataclass +from typing import Annotated, Any, Final + +import pytest + +from imgproxykit import Transform +from imgproxykit.variants import DataclassVariantsResolver + +_SMALL: Final = Transform(width=320) +_Thumbnail = Annotated[str, _SMALL] + + +@dataclass +class _EmptyVariants: + """Declare an empty result.""" + + +@dataclass +class _NonInitVariants: + """Declare a field that cannot receive a generated URL.""" + + small: Annotated[str, _SMALL] = field(init=False) + + +def test_empty_declaration() -> None: + """Allow declarations with no result fields.""" + assert DataclassVariantsResolver(model=_EmptyVariants)() == {} + + +def test_non_init_field() -> None: + """Reject result fields that cannot be supplied to the constructor.""" + with pytest.raises(TypeError, match='init=False') as exc_info: + DataclassVariantsResolver(model=_NonInitVariants)() + + assert "Field 'small'" in str(exc_info.value) + assert '_NonInitVariants' in str(exc_info.value) + + +@pytest.mark.parametrize('annotation', [InitVar[str], InitVar]) +def test_init_var(*, annotation: Any) -> None: + """Identify typed and bare init-only fields instead of ignoring them.""" + model = make_dataclass( + 'InitOnlyVariants', + [('token', annotation), ('small', _Thumbnail)], + ) + + with pytest.raises(TypeError, match='InitVar') as exc_info: + DataclassVariantsResolver(model=model)() # ty: ignore[invalid-argument-type] + + assert "Field 'token'" in str(exc_info.value) + assert 'InitOnlyVariants' in str(exc_info.value) diff --git a/tests/test_unit/test_variants/test_resolution.py b/tests/test_unit/test_variants/test_resolution.py new file mode 100644 index 0000000..b362f30 --- /dev/null +++ b/tests/test_unit/test_variants/test_resolution.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass +from typing import Annotated, ClassVar, Final + +from imgproxykit import Transform +from imgproxykit.variants import DataclassVariantsResolver + +_SMALL: Final = Transform(width=320) +_LARGE: Final = Transform(width=1280) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _BaseVariants: + """Declare an inherited field and a non-field class attribute.""" + + small: Annotated[str, _SMALL] + label: ClassVar[str] = 'images' + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _InheritedVariants(_BaseVariants): + """Extend the declaration with unrelated annotation metadata.""" + + large: Annotated[str, 'description', _LARGE, 42] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class _OverriddenVariants(_BaseVariants): + """Replace the transformation of an inherited result field.""" + + small: Annotated[str, _LARGE] + + +def test_resolve_fields() -> None: + """Preserve field order and transformations, ignoring other metadata.""" + transforms = DataclassVariantsResolver(model=_InheritedVariants)() + + assert list(transforms) == ['small', 'large'] + assert transforms == {'small': _SMALL, 'large': _LARGE} + + +def test_override_inherited_field() -> None: + """Use the subclass annotation when an inherited field is overridden.""" + transforms = DataclassVariantsResolver(model=_OverriddenVariants)() + + assert transforms == {'small': _LARGE} + + +def test_fresh_mapping() -> None: + """Return independent mappings when resolving the same declaration.""" + resolver = DataclassVariantsResolver(model=_BaseVariants) + first = resolver() + second = resolver() + first.clear() + + assert second == {'small': _SMALL}