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
2 changes: 1 addition & 1 deletion .importlinter
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ layers =
# Coordinate path construction and signing:
client
# Independent client components:
serialization | signing
serialization | signing | variants
# Reusable processing configuration:
transform
options
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
... )
Expand Down
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/client/url.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/getting_started/local_thumbnail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/getting_started/transformations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)

Expand Down
27 changes: 11 additions & 16 deletions docs/examples/integrations/fastapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -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)
2 changes: 1 addition & 1 deletion docs/examples/options/resize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/transformations/composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion docs/examples/transformations/create_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Expand Down
2 changes: 1 addition & 1 deletion docs/examples/transformations/extend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion docs/examples/transformations/reuse_transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)

Expand Down
39 changes: 39 additions & 0 deletions docs/examples/variants/dataclass.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ Find your way
image URLs.
* :doc:`User guide <pages/user-guide/client>` introduces the client,
transformations, processing options, and named variants.
* :doc:`pages/api-reference/index` lists classes, arguments, types,
and extension points.
* :doc:`Django integration <pages/integrations/django>` configures a shared
client and generates URLs from model file fields.
* :doc:`DRF integration <pages/integrations/drf>` returns image URLs and
variants from serializer fields.
* :doc:`FastAPI integration <pages/integrations/fastapi>` 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::
Expand Down
12 changes: 10 additions & 2 deletions docs/pages/api-reference/extensions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
---------------
Expand All @@ -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__
11 changes: 10 additions & 1 deletion docs/pages/integrations/fastapi.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------------

Expand Down
2 changes: 1 addition & 1 deletion docs/pages/user-guide/options.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 29 additions & 1 deletion docs/pages/user-guide/variants.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::

Expand Down Expand Up @@ -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
----------

Expand Down
Loading
Loading