From d2cfa81553c7b94c241e7e50b124b42a2aff1333 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Sat, 31 Oct 2026 19:07:55 +0500 Subject: [PATCH 1/3] Update docs --- CONTRIBUTING.md | 5 - docs/__init__.py | 0 docs/_ext/show_output.py | 171 ++++++++++++++++++ docs/_static/show_output.css | 11 ++ docs/conf.py | 2 + docs/examples/__init__.py | 0 docs/examples/client/default_url.py | 7 + docs/examples/client/plain_source.py | 10 + docs/examples/client/short_options.py | 10 + docs/examples/client/signed_url.py | 13 +- docs/examples/client/url.py | 17 +- .../getting_started/local_thumbnail.py | 15 +- .../getting_started/transformations.py | 16 +- docs/examples/getting_started/variants.py | 16 +- docs/examples/integrations/__init__.py | 0 docs/examples/integrations/django/__init__.py | 0 docs/examples/integrations/django/settings.py | 9 +- .../integrations/django/source_resolver.py | 15 +- docs/examples/integrations/django/url.py | 14 ++ docs/examples/integrations/django/variants.py | 16 ++ docs/examples/integrations/dmr/__init__.py | 0 docs/examples/integrations/drf/inline.py | 30 +-- docs/examples/integrations/drf/serializer.py | 33 ++-- .../examples/integrations/fastapi/__init__.py | 0 docs/examples/integrations/fastapi/client.py | 53 +----- .../integrations/fastapi/multiple_clients.py | 29 +-- .../examples/integrations/fastapi/variants.py | 40 ++++ docs/examples/options/omitted_arguments.py | 14 ++ docs/examples/options/resize.py | 16 +- docs/examples/options/scalar_options.py | 23 +-- docs/examples/transformations/composition.py | 20 +- .../transformations/create_transform.py | 16 +- docs/examples/transformations/extend.py | 27 +-- docs/examples/transformations/presets.py | 14 +- .../transformations/reuse_transform.py | 19 +- docs/examples/variants/basic.py | 19 +- docs/examples/variants/dataclass.py | 20 +- docs/examples/variants/reusable_sets.py | 22 +-- docs/examples/variants/typed.py | 17 +- docs/index.rst | 30 +-- docs/pages/getting-started.rst | 42 +++-- docs/pages/integrations/django.rst | 102 +++++------ docs/pages/integrations/drf.rst | 65 ++++--- docs/pages/integrations/fastapi.rst | 65 ++++--- docs/pages/user-guide/client.rst | 84 ++++++--- docs/pages/user-guide/options.rst | 69 ++++--- docs/pages/user-guide/transformations.rst | 61 ++++--- docs/pages/user-guide/variants.rst | 62 +++++-- poetry.lock | 100 +++++++++- pyproject.toml | 6 +- 50 files changed, 847 insertions(+), 598 deletions(-) create mode 100644 docs/__init__.py create mode 100644 docs/_ext/show_output.py create mode 100644 docs/_static/show_output.css create mode 100644 docs/examples/__init__.py create mode 100644 docs/examples/client/default_url.py create mode 100644 docs/examples/client/plain_source.py create mode 100644 docs/examples/client/short_options.py create mode 100644 docs/examples/integrations/__init__.py create mode 100644 docs/examples/integrations/django/__init__.py create mode 100644 docs/examples/integrations/django/url.py create mode 100644 docs/examples/integrations/django/variants.py create mode 100644 docs/examples/integrations/dmr/__init__.py create mode 100644 docs/examples/integrations/fastapi/__init__.py create mode 100644 docs/examples/integrations/fastapi/variants.py create mode 100644 docs/examples/options/omitted_arguments.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f529b22..438299e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,11 +35,6 @@ To run unit tests only: make unit ``` -`make unit` also runs pytest doctests in Python docstrings, `*.rst` files, -and `README.md`. Write executable examples with `>>>` prompts and expected -output; ordinary Python code fences are not executed. Leave a blank line after -the expected output before closing a Markdown code fence. - To run linting: ```bash diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/_ext/show_output.py b/docs/_ext/show_output.py new file mode 100644 index 0000000..163c42b --- /dev/null +++ b/docs/_ext/show_output.py @@ -0,0 +1,171 @@ +""" +Execute selected Python examples and display their standard output. + +Add ``:show-output:`` to ``literalinclude`` to append a collapsed +``Show Output`` block. Source selection options affect only the displayed +code: the complete file runs in a fresh interpreter during the Sphinx build. +Examples must print their results and finish within the execution timeout. +Use ``:output-language: python`` to highlight Python representations; +output is plain text by default. +""" + +import os +import subprocess # noqa: S404 +import sys +from pathlib import Path +from typing import ClassVar, Final, override + +from docutils import nodes +from docutils.parsers.rst import directives +from sphinx.application import Sphinx +from sphinx.directives.code import LiteralInclude +from sphinx.errors import SphinxError +from sphinx_immaterial.code_annotations import annotations_list + +_PROJECT_ROOT: Final = Path(__file__).resolve().parents[2] +_EXECUTION_TIMEOUT: Final = 30 + + +def _example_environment(root: Path) -> dict[str, str]: + """Preserve build settings while preferring local imports and UTF-8.""" + environment = os.environ.copy() + python_path = environment.get('PYTHONPATH') + environment['PYTHONPATH'] = ( + os.pathsep.join((str(root), python_path)) if python_path else str(root) + ) + environment['PYTHONIOENCODING'] = 'utf-8' + return environment + + +def _execute_example(example: Path) -> str: + """ + Execute a Python file from the project root and return its stdout. + + Use the build's interpreter so examples see the same dependencies as + autodoc. A separate process keeps their imports and global state out of + Sphinx. Nothing is fetched from the generated image URLs. + + Raises: + SphinxError: If the file is not Python, fails to execute, exceeds + the timeout, or prints no non-whitespace output. + """ + if example.suffix != '.py': + raise SphinxError(f':show-output: requires a Python file: {example}') + + stdout = _run_example(example) + if not stdout.strip(): + raise SphinxError(f'Example {example} produced no stdout') + return stdout + + +def _run_example(example: Path) -> str: + """Capture stdout and report execution failures with the example path.""" + try: + process = subprocess.run( # noqa: S603 + [sys.executable, str(example)], + cwd=_PROJECT_ROOT, + env=_example_environment(_PROJECT_ROOT), + capture_output=True, + text=True, + encoding='utf-8', + timeout=_EXECUTION_TIMEOUT, + check=True, + ) + except subprocess.CalledProcessError as exc: + raise SphinxError( + f'Example {example} failed (exit {exc.returncode}):\n' + f'{exc.stdout}{exc.stderr}', + ) from exc + except subprocess.TimeoutExpired as exc: + raise SphinxError( + f'Example {example} exceeded {_EXECUTION_TIMEOUT} seconds', + ) from exc + except OSError as exc: + raise SphinxError( + f'Could not execute example {example}: {exc}' + ) from exc + return process.stdout + + +def _create_output_node(stdout: str, language: str) -> nodes.admonition: + """ + Build a themed, collapsed result containing literal text. + + Construct Docutils nodes directly, as in DMR's example extension. + Printed reStructuredText or HTML stays literal instead of being parsed + as documentation markup. Sphinx handles escaping and code highlighting. + """ + literal = stdout.rstrip('\r\n') + output = nodes.literal_block(literal, literal) + output['language'] = language + return nodes.admonition( + '', + nodes.title('', 'Show Output'), + output, + collapsible='', + classes=['show_output'], + ) + + +class ExecutedLiteralInclude(LiteralInclude): + """ + Include source and optionally append the complete file's actual output. + + Regular includes keep Sphinx's behavior. Execution is opt-in through + ``:show-output:`` and only runs when source inclusion succeeds. The + parent directive tracks the example as a dependency for rebuilds. + """ + + option_spec: ClassVar = { + **LiteralInclude.option_spec, + 'show-output': directives.flag, + 'output-language': directives.unchanged_required, + } + + @override + def run(self) -> list[nodes.Node]: + """Render source, execute the selected example, and append stdout.""" + rendered = super().run() + if 'show-output' not in self.options or any( + isinstance(node, nodes.system_message) for node in rendered + ): + return rendered + + example = Path(self.env.relfn2path(self.arguments[0])[1]) + output = _create_output_node( + _execute_example(example), + self.options.get('output-language', 'text'), + ) + self.set_source_info(output) + return [*rendered, output] + + +def _arrange_annotations(_app: Sphinx, document: nodes.document) -> None: + """ + Keep code annotations beside their source, before the generated result. + + The theme requires the annotation list immediately after the code block. + Move it ahead of our inserted result so both features work together. + """ + for annotation in document.findall(annotations_list): + parent = annotation.parent + if parent is None: + continue + + index = parent.index(annotation) + previous = parent[index - 1] if index else None + + if ( + isinstance(previous, nodes.admonition) + and ('show_output' in previous.get('classes', ())) # pyright: ignore[reportUnnecessaryContains] + ): + parent.remove(annotation) + parent.insert(index - 1, annotation) + + +def setup(app: Sphinx) -> dict[str, bool]: + """Replace literalinclude while allowing parallel reads and writes.""" + app.add_directive('literalinclude', ExecutedLiteralInclude, override=True) + app.add_css_file('show_output.css') + app.connect('doctree-read', _arrange_annotations) + return {'parallel_read_safe': True, 'parallel_write_safe': True} diff --git a/docs/_static/show_output.css b/docs/_static/show_output.css new file mode 100644 index 0000000..c29fb50 --- /dev/null +++ b/docs/_static/show_output.css @@ -0,0 +1,11 @@ +/* Keep long generated URLs inside the result panel. */ +.md-typeset details.show_output { + min-width: 0; + max-width: 100%; +} + +.md-typeset details.show_output pre { + max-width: 100%; + overflow-x: auto; + white-space: pre; +} diff --git a/docs/conf.py b/docs/conf.py index ced1874..eeb0f52 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,7 @@ # We need `imgproxykit` to be importable from here: _ROOT = Path(__file__).resolve(strict=True).parent.parent sys.path.insert(0, str(_ROOT)) +sys.path.insert(0, str(_ROOT / 'docs' / '_ext')) # -- Project information ----------------------------------------------------- @@ -68,6 +69,7 @@ def _get_project_meta() -> dict[str, str]: 'sphinx_autodoc_typehints', 'sphinx_immaterial', 'sphinx_iconify', + 'show_output', ] # If true, Sphinx will warn about all references diff --git a/docs/examples/__init__.py b/docs/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/client/default_url.py b/docs/examples/client/default_url.py new file mode 100644 index 0000000..03d8607 --- /dev/null +++ b/docs/examples/client/default_url.py @@ -0,0 +1,7 @@ +from imgproxykit import Client + +client = Client('http://localhost:8080') +source_url = 'https://picsum.photos/id/237/1600/1200' + +image_url = client.url(source=source_url) +print(image_url) diff --git a/docs/examples/client/plain_source.py b/docs/examples/client/plain_source.py new file mode 100644 index 0000000..fe657c0 --- /dev/null +++ b/docs/examples/client/plain_source.py @@ -0,0 +1,10 @@ +from imgproxykit import Client, Transform + +client = Client('http://localhost:8080', source_encoding='plain') # (1)! +source_url = 'https://picsum.photos/id/237/1600/1200' # (2)! + +image_url = client.url( + source=source_url, + transform=Transform(width=300, format='webp'), +) +print(image_url) diff --git a/docs/examples/client/short_options.py b/docs/examples/client/short_options.py new file mode 100644 index 0000000..2d4bb55 --- /dev/null +++ b/docs/examples/client/short_options.py @@ -0,0 +1,10 @@ +from imgproxykit import Client, Transform + +client = Client('http://localhost:8080', use_short_options=True) # (1)! +source_url = 'https://picsum.photos/id/237/1600/1200' + +image_url = client.url( + source=source_url, + transform=Transform(width=300, quality=85), +) +print(image_url) diff --git a/docs/examples/client/signed_url.py b/docs/examples/client/signed_url.py index e89f34f..7151fb1 100644 --- a/docs/examples/client/signed_url.py +++ b/docs/examples/client/signed_url.py @@ -1,22 +1,11 @@ -""" -Sign a URL with example credentials. - ->>> imgproxy_url.startswith('http://localhost:8081/unsafe/') -False ->>> imgproxy_url == client.url(source=source_url) -True -""" - from imgproxykit import Client client = Client( 'http://localhost:8081', - # Replace with your imgproxy server's hex-encoded key and salt. - key='736563726574', + key='736563726574', # (1)! salt='73616c74', ) source_url = 'https://picsum.photos/id/237/1600/1200' imgproxy_url = client.url(source=source_url) - print(imgproxy_url) diff --git a/docs/examples/client/url.py b/docs/examples/client/url.py index bb39a6f..9ba4bd3 100644 --- a/docs/examples/client/url.py +++ b/docs/examples/client/url.py @@ -1,15 +1,7 @@ -""" -Resize an image and select its output format. - ->>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2] -['resize:fill:300:300', 'format:webp'] -""" - from imgproxykit import Client, Transform from imgproxykit.options import Resize -# Public URL of your imgproxy server. -client = Client('http://localhost:8080') +client = Client('http://localhost:8080') # (1)! source_url = 'https://picsum.photos/id/237/1600/1200' thumbnail = Transform( @@ -17,10 +9,5 @@ format='webp', ) -# Build the URL; imgproxy processes the image when the URL is requested. -thumbnail_url = client.url(source=source_url, transform=thumbnail) - +thumbnail_url = client.url(source=source_url, transform=thumbnail) # (2)! print(thumbnail_url) - -image_url = client.url(source=source_url) -print(image_url) diff --git a/docs/examples/getting_started/local_thumbnail.py b/docs/examples/getting_started/local_thumbnail.py index 4f3f9bb..262fe55 100644 --- a/docs/examples/getting_started/local_thumbnail.py +++ b/docs/examples/getting_started/local_thumbnail.py @@ -1,22 +1,13 @@ -""" -Resize an image and select its output format. - ->>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2] -['resize:fill:300:300', 'format:webp'] -""" - from imgproxykit import Client, Transform from imgproxykit.options import Resize -# Address of the test imgproxy container. -client = Client('http://localhost:8080') -source_url = 'https://picsum.photos/id/237/1600/1200' +client = Client('http://localhost:8080') # (1)! +source_url = 'https://picsum.photos/id/237/1600/1200' # (2)! thumbnail = Transform( Resize.fill(width=300, height=300), format='webp', ) -# Build the URL; imgproxy processes the image when the URL is requested. -thumbnail_url = client.url(source=source_url, transform=thumbnail) +thumbnail_url = client.url(source=source_url, transform=thumbnail) # (3)! print(thumbnail_url) diff --git a/docs/examples/getting_started/transformations.py b/docs/examples/getting_started/transformations.py index 4edecc0..d6d4c57 100644 --- a/docs/examples/getting_started/transformations.py +++ b/docs/examples/getting_started/transformations.py @@ -1,21 +1,13 @@ -""" -Resize an image and select its output format. - ->>> thumbnail_url.split('/unsafe/')[1].split('/')[0:2] -['resize:fill:300:300', 'format:webp'] -""" - from imgproxykit import Client, Transform from imgproxykit.options import Resize -# Public URL of your imgproxy server. -client = Client('https://imgproxy.example.com') -source_url = 'https://originals.example.com/photo.jpg' +client = Client('https://imgproxy.example.com') # (1)! +source_url = 'https://originals.example.com/photo.jpg' # (2)! thumbnail = Transform( Resize.fill(width=300, height=300), format='webp', ) -# Build the URL; imgproxy processes the image when the URL is requested. -thumbnail_url = client.url(source=source_url, transform=thumbnail) +thumbnail_url = client.url(source=source_url, transform=thumbnail) # (3)! +print(thumbnail_url) diff --git a/docs/examples/getting_started/variants.py b/docs/examples/getting_started/variants.py index efa258f..e4e748b 100644 --- a/docs/examples/getting_started/variants.py +++ b/docs/examples/getting_started/variants.py @@ -1,28 +1,16 @@ -""" -Build a reusable set of product variants. - ->>> list(product_urls) -['small', 'medium', 'large'] ->>> product_urls['small'] == client.url(source_url, transform=products['small']) -True -""" - from imgproxykit import Client, Transform client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Share quality and output format across all variants. -base = Transform(quality=85, format='webp') +base = Transform(quality=85, format='webp') # (1)! products = { - # Add a width to the shared options for each variant. - 'small': base.extend(width=320), + 'small': base.extend(width=320), # (2)! 'medium': base.extend(width=640), 'large': base.extend(width=1280), } product_urls = client.variants(source=source_url, variants=products) -# Access a URL by its variant name. print('Small:', product_urls['small']) print('Medium:', product_urls['medium']) print('Large:', product_urls['large']) diff --git a/docs/examples/integrations/__init__.py b/docs/examples/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/integrations/django/__init__.py b/docs/examples/integrations/django/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/integrations/django/settings.py b/docs/examples/integrations/django/settings.py index 765d908..26a1a8e 100644 --- a/docs/examples/integrations/django/settings.py +++ b/docs/examples/integrations/django/settings.py @@ -4,14 +4,13 @@ thumbnail = Transform(width=300, height=300, format='webp') IMGPROXYKIT: SettingsDict = { - 'client': { + 'client': { # (1)! 'base_url': 'https://imgproxy.example.com', - # Use your server's key and salt. - 'key': '736563726574', + 'key': '736563726574', # (2)! 'salt': '73616c74', }, - 'transforms': {'thumbnail': thumbnail}, - 'variants': { + 'transforms': {'thumbnail': thumbnail}, # (3)! + 'variants': { # (4)! 'product': { 'small': thumbnail, 'large': Transform(width=1280, format='webp'), diff --git a/docs/examples/integrations/django/source_resolver.py b/docs/examples/integrations/django/source_resolver.py index 42966ca..7ec23eb 100644 --- a/docs/examples/integrations/django/source_resolver.py +++ b/docs/examples/integrations/django/source_resolver.py @@ -1,12 +1,3 @@ -""" -Resolve relative image URLs against a media domain. - ->>> resolve_image_source('/media/photo.jpg') -'https://media.example.com/media/photo.jpg' ->>> resolve_image_source('https://storage.example.com/photo.jpg') -'https://storage.example.com/photo.jpg' -""" - from urllib.parse import urljoin from django.db.models.fields.files import FieldFile @@ -15,4 +6,8 @@ def resolve_image_source(source: str | FieldFile) -> str: """Return an absolute source URL for a string or model file field.""" url = source.url if isinstance(source, FieldFile) else source - return urljoin('https://media.example.com/', url) + return urljoin('https://media.example.com/', url) # (1)! + + +print(resolve_image_source('/media/photo.jpg')) +print(resolve_image_source('https://storage.example.com/photo.jpg')) diff --git a/docs/examples/integrations/django/url.py b/docs/examples/integrations/django/url.py new file mode 100644 index 0000000..56b6649 --- /dev/null +++ b/docs/examples/integrations/django/url.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.test import override_settings + +from docs.examples.integrations.django.settings import IMGPROXYKIT + +if not settings.configured: + settings.configure() + +with override_settings(IMGPROXYKIT=IMGPROXYKIT): + from imgproxykit.integrations.django import imgproxy_url + + source = 'https://media.example.com/products/42.jpg' + image_url = imgproxy_url(source, 'thumbnail') # (1)! + print(image_url) diff --git a/docs/examples/integrations/django/variants.py b/docs/examples/integrations/django/variants.py new file mode 100644 index 0000000..78f4353 --- /dev/null +++ b/docs/examples/integrations/django/variants.py @@ -0,0 +1,16 @@ +from django.conf import settings +from django.test import override_settings + +from docs.examples.integrations.django.settings import IMGPROXYKIT + +if not settings.configured: + settings.configure() + +with override_settings(IMGPROXYKIT=IMGPROXYKIT): + from pprint import pprint + + from imgproxykit.integrations.django import imgproxy_variants + + source = 'https://media.example.com/products/42.jpg' + images = imgproxy_variants(source, 'product') # (1)! + pprint(images, sort_dicts=False) diff --git a/docs/examples/integrations/dmr/__init__.py b/docs/examples/integrations/dmr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/integrations/drf/inline.py b/docs/examples/integrations/drf/inline.py index aa2cf17..9435308 100644 --- a/docs/examples/integrations/drf/inline.py +++ b/docs/examples/integrations/drf/inline.py @@ -1,28 +1,10 @@ -""" -Serialize an image using inline transformations. - ->>> from django.test import override_settings ->>> with override_settings( -... IMGPROXYKIT={ -... 'client': {'base_url': 'https://imgproxy.example.com'}, -... } -... ): -... product = ProductSerializer({ -... 'image': 'https://originals.example.com/42.jpg' -... }) -... images = product.data ->>> images['thumbnail'].startswith('https://imgproxy.example.com/unsafe/') -True ->>> '/width:160/height:160/' in images['thumbnail'] -True ->>> list(images['images']) -['small', 'large'] -""" - from rest_framework import serializers from imgproxykit import Transform -from imgproxykit.integrations.drf import ImgproxyURLField, ImgproxyVariantsField +from imgproxykit.integrations.drf import ( + ImgproxyURLField, + ImgproxyVariantsField, +) class ProductSerializer(serializers.Serializer[dict[str, str]]): @@ -30,11 +12,11 @@ class ProductSerializer(serializers.Serializer[dict[str, str]]): thumbnail = ImgproxyURLField( source='image', - transform=Transform(width=160, height=160), + transform=Transform(width=160, height=160), # (1)! ) images = ImgproxyVariantsField( source='image', - variants={ + variants={ # (2)! 'small': Transform(width=320), 'large': Transform(width=1280), }, diff --git a/docs/examples/integrations/drf/serializer.py b/docs/examples/integrations/drf/serializer.py index 2b39053..64479b8 100644 --- a/docs/examples/integrations/drf/serializer.py +++ b/docs/examples/integrations/drf/serializer.py @@ -1,28 +1,19 @@ -""" -Serialize an image using named transformations from Django settings. - ->>> from django.test import override_settings ->>> from docs.examples.integrations.django.settings import IMGPROXYKIT ->>> with override_settings(IMGPROXYKIT=IMGPROXYKIT): -... product = ProductSerializer({ -... 'image': 'https://originals.example.com/42.jpg' -... }) -... images = product.data ->>> '/width:300/height:300/format:webp/' in images['thumbnail'] -True ->>> list(images['images']) -['small', 'large'] ->>> '/width:1280/format:webp/' in images['images']['large'] -True -""" - from rest_framework import serializers -from imgproxykit.integrations.drf import ImgproxyURLField, ImgproxyVariantsField +from imgproxykit.integrations.drf import ( + ImgproxyURLField, + ImgproxyVariantsField, +) class ProductSerializer(serializers.Serializer[dict[str, str]]): """Return a thumbnail and named variants for a product image.""" - thumbnail = ImgproxyURLField(source='image', transform='thumbnail') - images = ImgproxyVariantsField(source='image', variants='product') + thumbnail = ImgproxyURLField( # (1)! + source='image', + transform='thumbnail', + ) + images = ImgproxyVariantsField( # (2)! + source='image', + variants='product', + ) diff --git a/docs/examples/integrations/fastapi/__init__.py b/docs/examples/integrations/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docs/examples/integrations/fastapi/client.py b/docs/examples/integrations/fastapi/client.py index 94fdccd..db1135c 100644 --- a/docs/examples/integrations/fastapi/client.py +++ b/docs/examples/integrations/fastapi/client.py @@ -1,25 +1,3 @@ -""" -Register a client and use it in a FastAPI route. - ->>> import asyncio ->>> from fastapi import Request ->>> async def example(): -... client = await get_imgproxy(Request({'type': 'http', 'app': app})) -... return await get_product(42, client) ->>> product = asyncio.run(example()) ->>> product.id -42 ->>> product.images.thumbnail.startswith( -... 'https://imgproxy.example.com/unsafe/format:webp/width:320/' -... ) -True ->>> product.images.preview.startswith( -... 'https://imgproxy.example.com/unsafe/format:webp/width:1280/' -... ) -True -""" - -from dataclasses import dataclass from typing import Annotated from fastapi import Depends, FastAPI @@ -29,39 +7,22 @@ from imgproxykit.integrations.fastapi import add_imgproxy app = FastAPI() - -# Keep the returned dependency for use with Depends in route handlers. -get_imgproxy = add_imgproxy( - app, - Client('https://imgproxy.example.com'), # Public imgproxy address. -) - -# Reuse these image sizes and output format across products. -base = Transform(format='webp') - - -@dataclass(frozen=True, slots=True) -class ProductImages: - """Declare the image response fields and their transformations.""" - - thumbnail: Annotated[str, base.extend(width=320)] - preview: Annotated[str, base.extend(width=1280)] +get_imgproxy = add_imgproxy(app, Client('https://imgproxy.example.com')) # (1)! class ProductResponse(BaseModel): - """Product and its image variants returned by the API.""" + """Product and its image URL returned by the API.""" id: int - images: ProductImages + image: str @app.get('/products/{product_id}') async def get_product( product_id: int, - imgproxy: Annotated[Client, Depends(get_imgproxy)], + imgproxy: Annotated[Client, Depends(get_imgproxy)], # (2)! ) -> ProductResponse: - """Return a product with image variants.""" + """Return a product with a transformed image URL.""" 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=ProductImages) - return ProductResponse(id=product_id, images=images) + image = imgproxy.url(source, transform=Transform(width=300, format='webp')) + return ProductResponse(id=product_id, image=image) diff --git a/docs/examples/integrations/fastapi/multiple_clients.py b/docs/examples/integrations/fastapi/multiple_clients.py index 18b307d..60544c0 100644 --- a/docs/examples/integrations/fastapi/multiple_clients.py +++ b/docs/examples/integrations/fastapi/multiple_clients.py @@ -1,22 +1,3 @@ -""" -Register signed and unsigned clients and inject both into a FastAPI route. - ->>> import asyncio ->>> from fastapi import Request ->>> async def example(): -... request = Request({'type': 'http', 'app': app}) -... signed = await get_signed_imgproxy(request) -... unsigned = await get_unsigned_imgproxy(request) -... return await get_product_images(42, signed, unsigned) ->>> images = asyncio.run(example()) ->>> images.signed.startswith('https://imgproxy.example.com/') -True ->>> '/unsafe/' not in images.signed -True ->>> images.unsigned.startswith('https://imgproxy.example.com/unsafe/') -True -""" - from typing import Annotated from fastapi import Depends, FastAPI @@ -27,21 +8,18 @@ app = FastAPI() -# Register the signed client under the default name. -get_signed_imgproxy = add_imgproxy( +get_signed_imgproxy = add_imgproxy( # (1)! app, Client( 'https://imgproxy.example.com', - # Replace the example key and salt with your imgproxy credentials. key='736563726574', salt='73616c74', ), ) -# Give the unsigned client its own name to keep both registrations. get_unsigned_imgproxy = add_imgproxy( app, Client('https://imgproxy.example.com'), - name='unsigned_imgproxy', + name='unsigned_imgproxy', # (2)! ) @@ -55,12 +33,11 @@ class ProductImageUrls(BaseModel): @app.get('/products/{product_id}/images') async def get_product_images( product_id: int, - signed: Annotated[Client, Depends(get_signed_imgproxy)], + signed: Annotated[Client, Depends(get_signed_imgproxy)], # (3)! unsigned: Annotated[Client, Depends(get_unsigned_imgproxy)], ) -> ProductImageUrls: """Build image URLs with each client's signing configuration.""" source = f'https://originals.example.com/products/{product_id}.jpg' - # Each dependency supplies a client with its own signing settings. return ProductImageUrls( signed=signed.url(source), unsigned=unsigned.url(source), diff --git a/docs/examples/integrations/fastapi/variants.py b/docs/examples/integrations/fastapi/variants.py new file mode 100644 index 0000000..91cd596 --- /dev/null +++ b/docs/examples/integrations/fastapi/variants.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Annotated + +from fastapi import Depends, FastAPI +from pydantic import BaseModel + +from imgproxykit import Client, Transform +from imgproxykit.integrations.fastapi import add_imgproxy + +app = FastAPI() + +get_imgproxy = add_imgproxy(app, Client('https://imgproxy.example.com')) + +base = Transform(format='webp') + + +@dataclass(frozen=True, slots=True) +class ProductImages: + """Declare the image response fields and their transformations.""" + + thumbnail: Annotated[str, base.extend(width=320)] # (1)! + preview: Annotated[str, base.extend(width=1280)] + + +class ProductResponse(BaseModel): + """Product and its image variants returned by the API.""" + + id: int + images: ProductImages # (2)! + + +@app.get('/products/{product_id}') +async def get_product( + product_id: int, + imgproxy: Annotated[Client, Depends(get_imgproxy)], +) -> ProductResponse: + """Return a product with image variants.""" + source = f'https://originals.example.com/products/{product_id}.jpg' + images = imgproxy.variants(source, variants=ProductImages) # (3)! + return ProductResponse(id=product_id, images=images) diff --git a/docs/examples/options/omitted_arguments.py b/docs/examples/options/omitted_arguments.py new file mode 100644 index 0000000..14d058d --- /dev/null +++ b/docs/examples/options/omitted_arguments.py @@ -0,0 +1,14 @@ +from imgproxykit import Client, Transform +from imgproxykit.options import Resize + +client = Client('http://localhost:8080') +source_url = 'https://picsum.photos/id/237/1600/1200' + +default = Transform(Resize.fit(width=300)) # (1)! +no_enlarge = Transform(Resize.fit(width=300, enlarge=False)) # (2)! + +default_url = client.url(source=source_url, transform=default) +no_enlarge_url = client.url(source=source_url, transform=no_enlarge) + +print('Omitted:', default_url) +print('Explicit False:', no_enlarge_url) diff --git a/docs/examples/options/resize.py b/docs/examples/options/resize.py index 646edeb..df085cf 100644 --- a/docs/examples/options/resize.py +++ b/docs/examples/options/resize.py @@ -1,25 +1,15 @@ -""" -Combine a square crop, gravity, and pixel density. - ->>> thumbnail_url.split('/unsafe/')[1].split('/')[:-1] -['resize:fill:300:300', 'gravity:ce', 'dpr:2'] -""" - from imgproxykit import Client, Transform from imgproxykit.options import Gravity, Resize -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Combine structured options with a scalar keyword. thumbnail = Transform( - Resize.fill(width=300, height=300), - Gravity.center(), - dpr=2, + Resize.fill(width=300, height=300), # (1)! + Gravity.center(), # (2)! + dpr=2, # (3)! ) -# Build the URL; imgproxy processes the image when the URL is requested. thumbnail_url = client.url(source=source_url, transform=thumbnail) print(thumbnail_url) diff --git a/docs/examples/options/scalar_options.py b/docs/examples/options/scalar_options.py index 19d49d8..6b34acb 100644 --- a/docs/examples/options/scalar_options.py +++ b/docs/examples/options/scalar_options.py @@ -1,26 +1,17 @@ -""" -Use scalar option instances and keyword arguments together. - ->>> [option.name for option in optimized.options] -['quality', 'format'] ->>> '/quality:85/format:webp/' in image_url -True -""" - from imgproxykit import Client, Transform -from imgproxykit.options import Quality +from imgproxykit.options import Format, Quality -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Pass a scalar option instance or use its keyword form. optimized = Transform( - Quality(85), # Equivalent to quality=85. - format='webp', # Equivalent to Format('webp'). + Quality(85), # (1)! + Format('webp'), ) +keyword_optimized = Transform(quality=85, format='webp') # (2)! -# Build the URL; imgproxy processes the image when the URL is requested. image_url = client.url(source=source_url, transform=optimized) +keyword_url = client.url(source=source_url, transform=keyword_optimized) -print(image_url) +print('Option instance:', image_url) +print('Keyword:', keyword_url) diff --git a/docs/examples/transformations/composition.py b/docs/examples/transformations/composition.py index 4ff7545..28ef366 100644 --- a/docs/examples/transformations/composition.py +++ b/docs/examples/transformations/composition.py @@ -1,18 +1,12 @@ -""" -Compose transformations from reusable groups of options. - ->>> [option.name for option in thumbnail.options] -['quality', 'format', 'resize'] ->>> [option.name for option in base.options] -['quality', 'format'] -""" - -from imgproxykit import Transform +from imgproxykit import Client, Transform from imgproxykit.options import Resize -# Define reusable groups of processing options. +client = Client('http://localhost:8080') +source_url = 'https://picsum.photos/id/237/1600/1200' + base = Transform(quality=85, format='webp') square = Transform(Resize.fill(width=300, height=300)) -# Combine options from both transformations. -thumbnail = base.extend(*square.options) +thumbnail = base.extend(*square.options) # (1)! + +print(client.url(source_url, transform=thumbnail)) diff --git a/docs/examples/transformations/create_transform.py b/docs/examples/transformations/create_transform.py index 720da17..5bcf619 100644 --- a/docs/examples/transformations/create_transform.py +++ b/docs/examples/transformations/create_transform.py @@ -1,27 +1,15 @@ -""" -Define processing options and build an image URL. - ->>> [option.name for option in thumbnail.options] -['resize', 'quality', 'format'] ->>> '/resize:fill:300:300/quality:85/format:webp/' in thumbnail_url -True -""" - from imgproxykit import Client, Transform from imgproxykit.options import Resize -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Define the thumbnail dimensions, quality, and output format. thumbnail = Transform( - Resize.fill(width=300, height=300), # Pass typed option objects. - quality=85, # Or scalar keywords, equivalent to Quality(85). + Resize.fill(width=300, height=300), # (1)! + quality=85, # (2)! format='webp', ) -# Build the URL; imgproxy processes the image when the URL is requested. thumbnail_url = client.url(source=source_url, transform=thumbnail) print(thumbnail_url) diff --git a/docs/examples/transformations/extend.py b/docs/examples/transformations/extend.py index 568f051..e0bfd57 100644 --- a/docs/examples/transformations/extend.py +++ b/docs/examples/transformations/extend.py @@ -1,22 +1,15 @@ -""" -Create new transformations by adding processing options. +from imgproxykit import Client, Transform +from imgproxykit.options import Resize ->>> [option.name for option in base.options] -['quality', 'format'] ->>> [option.name for option in thumbnail.options] -['quality', 'format', 'resize'] ->>> [option.name for option in retina.options] -['quality', 'format', 'resize', 'dpr'] -""" +client = Client('http://localhost:8080') +source_url = 'https://picsum.photos/id/237/1600/1200' -from imgproxykit import Transform -from imgproxykit.options import Resize +base = Transform(quality=85, format='webp') # (1)! -# Share quality and output format across transformations. -base = Transform(quality=85, format='webp') +thumbnail = base.extend(Resize.fill(width=300, height=300)) # (2)! -# Add a square crop without changing the base transformation. -thumbnail = base.extend(Resize.fill(width=300, height=300)) +retina = thumbnail.extend(dpr=2) # (3)! -# Create a higher pixel density version of the thumbnail. -retina = thumbnail.extend(dpr=2) +print('Base:', client.url(source_url, transform=base)) +print('Thumbnail:', client.url(source_url, transform=thumbnail)) +print('Retina:', client.url(source_url, transform=retina)) diff --git a/docs/examples/transformations/presets.py b/docs/examples/transformations/presets.py index ddb2b1e..a6ff07b 100644 --- a/docs/examples/transformations/presets.py +++ b/docs/examples/transformations/presets.py @@ -1,25 +1,15 @@ -""" -Combine server preset references with processing options. - ->>> thumbnail_url.split('/unsafe/')[1].split('/')[:-1] -['preset:thumbnail:small', 'quality:85', 'format:webp'] -""" - from imgproxykit import Client, Transform from imgproxykit.options import Preset -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Combine server presets with explicit processing options. thumbnail = Transform( - Preset('thumbnail', 'small'), - quality=85, + Preset('thumbnail', 'small'), # (1)! + quality=85, # (2)! format='webp', ) -# Build the URL; imgproxy processes the image when the URL is requested. thumbnail_url = client.url(source=source_url, transform=thumbnail) print(thumbnail_url) diff --git a/docs/examples/transformations/reuse_transform.py b/docs/examples/transformations/reuse_transform.py index b8140d8..acdeb09 100644 --- a/docs/examples/transformations/reuse_transform.py +++ b/docs/examples/transformations/reuse_transform.py @@ -1,23 +1,8 @@ -""" -Reuse processing options for different source images. - ->>> len(thumbnail_urls) -2 ->>> thumbnail_urls[0] != thumbnail_urls[1] -True ->>> all( -... '/resize:fill:300:300/format:webp/' in thumbnail_url -... for thumbnail_url in thumbnail_urls -... ) -True -""" - from imgproxykit import Client, Transform from imgproxykit.options import Resize client = Client('http://localhost:8080') -# Reuse the same transformation for multiple source images. -thumbnail = Transform( +thumbnail = Transform( # (1)! Resize.fill(width=300, height=300), format='webp', ) @@ -31,4 +16,4 @@ for source_url in source_urls ] -print(thumbnail_urls) +print(*thumbnail_urls, sep='\n') diff --git a/docs/examples/variants/basic.py b/docs/examples/variants/basic.py index 84a13ed..c42706e 100644 --- a/docs/examples/variants/basic.py +++ b/docs/examples/variants/basic.py @@ -1,27 +1,16 @@ -""" -Generate named variant URLs for one source. - ->>> list(product_urls) -['small', 'medium', 'large'] ->>> small_url == client.url(source_url, transform=products['small']) -True -""" +from pprint import pprint from imgproxykit import Client, Transform -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Define a complete transformation for each variant name. -products = { +products = { # (1)! 'small': Transform(width=320), 'medium': Transform(width=640), 'large': Transform(width=1280), } -# Build one URL per variant for the same source image. -product_urls = client.variants(source=source_url, variants=products) -small_url = product_urls['small'] # Access a URL by its variant name. +product_urls = client.variants(source=source_url, variants=products) # (2)! -print(small_url) +pprint(product_urls, sort_dicts=False) diff --git a/docs/examples/variants/dataclass.py b/docs/examples/variants/dataclass.py index 3aacdf5..7deecec 100644 --- a/docs/examples/variants/dataclass.py +++ b/docs/examples/variants/dataclass.py @@ -1,20 +1,9 @@ -""" -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 pprint import pprint from typing import Annotated, assert_type from imgproxykit import Client, Transform -# Share processing settings across all variants. base = Transform(quality=85, format='webp') @@ -22,7 +11,7 @@ class ProductImages: """Declare the result fields and their transformations together.""" - small: Annotated[str, base.extend(width=320)] + small: Annotated[str, base.extend(width=320)] # (1)! medium: Annotated[str, base.extend(width=640)] large: Annotated[str, base.extend(width=1280)] @@ -30,10 +19,9 @@ class ProductImages: 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) +product_urls = client.variants(source_url, variants=ProductImages) # (2)! assert_type(product_urls, ProductImages) assert_type(product_urls.small, str) -print(product_urls.small) +pprint(product_urls) diff --git a/docs/examples/variants/reusable_sets.py b/docs/examples/variants/reusable_sets.py index 6bacf52..99a0441 100644 --- a/docs/examples/variants/reusable_sets.py +++ b/docs/examples/variants/reusable_sets.py @@ -1,32 +1,20 @@ -""" -Reuse a set of transformations across source images. - ->>> list(dog_urls) == list(pug_urls) -True ->>> dog_urls['small'] != pug_urls['small'] -True ->>> [option.name for option in sizes['small'].options] -['quality', 'format', 'width'] -""" +from pprint import pprint from imgproxykit import Client, Transform -# Public URL of your imgproxy server. client = Client('http://localhost:8080') dog_source = 'https://picsum.photos/id/237/1600/1200' pug_source = 'https://picsum.photos/id/1025/1600/1200' -# Share quality and output format across all variants. -base = Transform(quality=85, format='webp') +base = Transform(quality=85, format='webp') # (1)! sizes = { 'small': base.extend(width=320), 'medium': base.extend(width=640), 'large': base.extend(width=1280), } -# Reuse the same set for different source images. -dog_urls = client.variants(source=dog_source, variants=sizes) +dog_urls = client.variants(source=dog_source, variants=sizes) # (2)! pug_urls = client.variants(source=pug_source, variants=sizes) -print(dog_urls['small']) -print(pug_urls['small']) +pprint({'Dog': dog_urls}, sort_dicts=False) +pprint({'Pug': pug_urls}, sort_dicts=False) diff --git a/docs/examples/variants/typed.py b/docs/examples/variants/typed.py index 7be3641..56f1d91 100644 --- a/docs/examples/variants/typed.py +++ b/docs/examples/variants/typed.py @@ -1,20 +1,12 @@ -""" -Preserve literal key types in generated URL dictionaries. - ->>> list(product_urls) -['small', 'medium', 'large'] -""" - +from pprint import pprint from typing import Literal, assert_type from imgproxykit import Client, Transform -# Public URL of your imgproxy server. client = Client('http://localhost:8080') source_url = 'https://picsum.photos/id/237/1600/1200' -# Limit the allowed variant names with a literal union. -type ProductKeys = Literal['small', 'medium', 'large'] +type ProductKeys = Literal['small', 'medium', 'large'] # (1)! products: dict[ProductKeys, Transform] = { 'small': Transform(width=320), @@ -22,8 +14,7 @@ 'large': Transform(width=1280), } -# The returned dictionary preserves the annotated key type. product_urls = client.variants(source=source_url, variants=products) -assert_type(product_urls, dict[ProductKeys, str]) +assert_type(product_urls, dict[ProductKeys, str]) # (2)! -print(product_urls['small']) +pprint(product_urls, sort_dicts=False) diff --git a/docs/index.rst b/docs/index.rst index e37a307..2fab304 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,9 +3,8 @@ imgproxykit A typed Python toolkit for building imgproxy URLs. -Describe image transformations in Python and generate signed URLs for your -imgproxy server. Reuse the same settings across product cards, avatars, -and image previews. +Define reusable image transformations, generate signed URLs, and create +named variants for thumbnails, avatars, and previews. Build your first URL -------------------- @@ -35,19 +34,21 @@ Install imgproxykit with Python 3.12 or newer: Create a client pointing to your imgproxy server and define a thumbnail transformation: -.. note:: - - The examples use ``imgproxy.example.com`` for your imgproxy server and - ``originals.example.com`` for the original images. Replace these example - addresses with your own. - .. literalinclude:: /examples/getting_started/transformations.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-13 + +.. code-annotations:: + + 1. Public URL of your imgproxy server. Replace it with your own address. + 2. URL of the original image. Replace it with an image URL accessible + to your imgproxy server. + 3. Build the URL without making a network request. Imgproxy fetches and + processes the source image when the generated URL is requested. -``thumbnail_url`` is ready to use in an image's ``src`` attribute or an API -response. Reuse ``thumbnail`` with other source images to apply the same -300×300 crop and WebP format across your application. +Use ``thumbnail_url`` in an image's ``src`` attribute or an API response. +Reuse ``thumbnail`` with other source images to apply the same transformation. .. tip:: @@ -64,6 +65,8 @@ Find your way transformations, processing options, and named variants. * :doc:`Django integration ` configures a shared client and generates URLs from model file fields. +* :doc:`Django Modern REST ` returns image URLs and + variants from typed controllers. * :doc:`DRF integration ` returns image URLs and variants from serializer fields. * :doc:`FastAPI integration ` registers clients @@ -94,6 +97,7 @@ Find your way :hidden: pages/integrations/django + pages/integrations/dmr pages/integrations/drf pages/integrations/fastapi diff --git a/docs/pages/getting-started.rst b/docs/pages/getting-started.rst index 9666631..811455d 100644 --- a/docs/pages/getting-started.rst +++ b/docs/pages/getting-started.rst @@ -51,32 +51,43 @@ imgproxykit requires Python 3.12 or newer: Build a thumbnail ----------------- -Connect :class:`~imgproxykit.Client` to the local container and describe a -thumbnail with :class:`~imgproxykit.Transform`. The source is a public sample -image; you can replace it with another image URL accessible to imgproxy: +Connect :class:`~imgproxykit.Client` to the local container and define a +thumbnail with :class:`~imgproxykit.Transform`: .. literalinclude:: /examples/getting_started/local_thumbnail.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-13 -The example prints a URL pointing to your local imgproxy server. Open it in -your browser to view the 300×300 WebP thumbnail. +.. code-annotations:: + + 1. Address of the local imgproxy container started above. + 2. Public sample image. You can use another image URL accessible to + your imgproxy server. + 3. Generate a URL without making a network request. Imgproxy fetches and + processes the source image when the generated URL is requested. + +Open the generated URL in your browser to view the image. Build variants -------------- -To generate several sizes of the same image, define a set of transformations. -Reuse ``client`` and ``source_url`` from above, keep quality and format in a -base transformation, then use :meth:`~imgproxykit.Transform.extend` to -create a new transformation for each width without changing the base: +Define a transformation for each size. Use +:meth:`~imgproxykit.Transform.extend` to share quality and format across +variants: .. literalinclude:: /examples/getting_started/variants.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-16 + +.. code-annotations:: -The example prints three URLs for widths of 320, 640, and 1280 pixels. -Open them to compare the sizes; each image keeps the source proportions -and uses the shared WebP format and quality setting. + 1. Shared quality and output format for all variants. + 2. Add a width to the shared options without changing ``base``. + +Each variant uses the shared quality and format with its own width. +Open the generated URLs to compare them. .. tip:: @@ -86,9 +97,6 @@ and uses the shared WebP format and quality setting. docker stop imgproxy-test -You've learned how to build image URLs and variants. Next, explore client -configuration and more ways to compose transformations. - Next steps ---------- diff --git a/docs/pages/integrations/django.rst b/docs/pages/integrations/django.rst index b451d9b..da93ab0 100644 --- a/docs/pages/integrations/django.rst +++ b/docs/pages/integrations/django.rst @@ -50,15 +50,17 @@ Define ``IMGPROXYKIT`` in your Django settings: .. literalinclude:: /examples/integrations/django/settings.py :language: python - :start-at: from + :lines: 1-19 -``client.base_url`` is required. Set ``key`` and ``salt`` to your server's -hex-encoded credentials, or omit both if the server accepts unsigned URLs. -See :doc:`../user-guide/client` for other client options. +.. code-annotations:: + + 1. Configure the shared client. ``base_url`` is required. + 2. Use your server's hex-encoded key and salt, or omit both if the server + accepts unsigned URLs. + 3. Optional named transformations for Python shortcuts and template tags. + 4. Optional named sets of transformations for generating variants. -``transforms`` defines named transformations, and ``variants`` groups -transformations into named sets. Both are optional and can be reused by -Python shortcuts and template tags. +See :doc:`../user-guide/client` for other client options. Generate URLs ------------- @@ -66,14 +68,15 @@ Generate URLs Use ``imgproxy_url`` with a source string or model file field and a transformation name from ``IMGPROXYKIT['transforms']``: -.. TODO: Move this code block to docs/examples/integrations/django/url.py. - -.. code-block:: python +.. literalinclude:: /examples/integrations/django/url.py + :language: python + :lines: 10-14 + :dedent: 4 + :show-output: - from imgproxykit.integrations.django import imgproxy_url +.. code-annotations:: - source = 'https://media.example.com/products/42.jpg' - image_url = imgproxy_url(source, 'thumbnail') + 1. Select ``IMGPROXYKIT['transforms']['thumbnail']``. You can also pass a ``Transform`` object directly. Omit the transformation argument to generate a URL without processing options. @@ -84,17 +87,17 @@ Generate variants Use ``imgproxy_variants`` with a set name from ``IMGPROXYKIT['variants']`` to generate several URLs for the same source: -.. TODO: Move this code block to docs/examples/integrations/django/variants.py. - -.. code-block:: python - - from imgproxykit.integrations.django import imgproxy_variants +.. literalinclude:: /examples/integrations/django/variants.py + :language: python + :lines: 10-16 + :dedent: 4 + :show-output: + :output-language: python - source = 'https://media.example.com/products/42.jpg' - images = imgproxy_variants(source, 'product') +.. code-annotations:: - preview_url = images['small'] - large_url = images['large'] + 1. Select ``IMGPROXYKIT['variants']['product']`` and generate a URL + for each configured name. The result is a dictionary of URLs with the configured variant names as keys. You can also pass a dictionary of names and ``Transform`` objects directly @@ -115,7 +118,9 @@ an image with the ``thumbnail`` transformation configured above: {% load imgproxykit %} - {{ product.name }} + {% if product.image %} + {{ product.name }} + {% endif %} The source can be a model file field, as shown here, or a source string. The transformation name is optional; omitting it generates a URL without @@ -127,38 +132,36 @@ the configured ``product`` variant set: .. code-block:: django {% load imgproxykit %} - {% imgproxy_variants product.image 'product' as images %} - - - {{ product.name }} - + {% if product.image %} + {% imgproxy_variants product.image 'product' as images %} + + {{ product.name }} + + {% endif %} Source resolution ----------------- -When generating URLs in Python or templates, imgproxykit passes source -strings unchanged and reads ``.url`` from model file fields. +By default, strings are passed unchanged and model file fields use ``.url``. +Relative URLs are not expanded automatically. -In Django, a file field's URL depends on the storage backend and its -configuration. It may be relative, such as ``/media/photo.jpg``, or absolute, -such as ``https://media.example.com/photo.jpg``. imgproxykit does not expand -relative URLs automatically. - -To use relative URLs, you can configure `IMGPROXY_BASE_URL +Configure `IMGPROXY_BASE_URL `__ -on your imgproxy server. For example, with -``IMGPROXY_BASE_URL=https://media.example.com``, imgproxy fetches -``/media/photo.jpg`` from ``https://media.example.com/media/photo.jpg``. -This setting adds a prefix to source URLs unless they already contain it. +on imgproxy, or set ``IMGPROXYKIT['source_resolver']`` to resolve sources in +Django. The resolver receives the original string or file field and must +return a URI accessible to imgproxy. -Alternatively, override source resolution in Django with -``IMGPROXYKIT['source_resolver']``. The resolver receives the original string -or file field and must return a URI that your imgproxy server can access. -For example, add a media domain in ``myapp/images.py``: +Define a resolver in ``myapp/images.py``: .. literalinclude:: /examples/integrations/django/source_resolver.py :language: python - :start-at: from urllib.parse + :lines: 1-13 + :show-output: + +.. code-annotations:: + + 1. Resolve relative paths against the media domain. Absolute URLs keep + their own domain. Register the resolver in your Django settings: @@ -169,15 +172,12 @@ Register the resolver in your Django settings: 'source_resolver': 'myapp.images.resolve_image_source', } -This turns ``/media/photo.jpg`` into -``https://media.example.com/media/photo.jpg`` and leaves absolute URLs -unchanged. The setting also accepts a callable directly instead of an -import path. +The setting also accepts a callable directly instead of an import path. Next steps ---------- -* :doc:`../user-guide/transformations` describes reusable transformations. -* :doc:`../user-guide/variants` shows how to generate several image URLs. +* :doc:`dmr` returns image URLs and variants from typed controllers. +* :doc:`drf` returns image URLs and variants from serializer fields. * :doc:`../api-reference/django` documents the client, URL generation functions, source resolution, and settings. diff --git a/docs/pages/integrations/drf.rst b/docs/pages/integrations/drf.rst index 7ff95b4..0b00ac1 100644 --- a/docs/pages/integrations/drf.rst +++ b/docs/pages/integrations/drf.rst @@ -1,9 +1,8 @@ Django REST Framework ===================== -Generate imgproxy URLs using DRF serializers. Return a single image URL -or a dictionary of named variants using transformations configured -in Django settings. +Return image URLs and named variants from DRF serializers. Use transformations +from Django settings or define them directly on serializer fields. Installation ------------ @@ -33,37 +32,41 @@ The extra installs Django and Django REST Framework. Configuration ------------- -Define ``IMGPROXYKIT`` in your Django settings. This integration shares -the client, named transformations, variant sets, and source resolver with -the :doc:`Django integration `: +Define ``IMGPROXYKIT`` in your Django settings. The client, transformations, +variant sets, and source resolver are shared with the :doc:`Django integration +`: .. literalinclude:: /examples/integrations/django/settings.py :language: python - :start-at: from + :lines: 1-19 -``client.base_url`` is required. Set ``key`` and ``salt`` to your server's -hex-encoded credentials, or omit both if the server accepts unsigned URLs. +.. code-annotations:: + + 1. Configure the shared client. ``base_url`` is required. + 2. Use your server's hex-encoded key and salt, or omit both if the server + accepts unsigned URLs. + 3. Optional named transformations shared with the Django integration. + 4. Optional named sets of transformations for generating variants. Serializer fields ----------------- Use :class:`~imgproxykit.integrations.drf.ImgproxyURLField` for one URL -and :class:`~imgproxykit.integrations.drf.ImgproxyVariantsField` for a -dictionary of named URLs. Both fields read the original image from -``source``: +and :class:`~imgproxykit.integrations.drf.ImgproxyVariantsField` for named +variants. Set ``source`` to the original image field: .. literalinclude:: /examples/integrations/drf/serializer.py :language: python - :start-at: from rest_framework + :lines: 1-19 + +.. code-annotations:: -``transform='thumbnail'`` selects ``IMGPROXYKIT['transforms']['thumbnail']``. -``variants='product'`` selects ``IMGPROXYKIT['variants']['product']``. -The response contains a ``thumbnail`` URL and an ``images`` dictionary with -``small`` and ``large`` URLs. + 1. Use the named transformation from + ``IMGPROXYKIT['transforms']['thumbnail']``. + 2. Use the named variant set from ``IMGPROXYKIT['variants']['product']``. -The same field declarations work in a ``ModelSerializer``: use the model's -file field as ``source`` and include the generated fields in ``Meta.fields``. -Request context is not required, and URL generation makes no network requests. +For a ``ModelSerializer``, use the model's file field as ``source`` and include +the generated fields in ``Meta.fields``. Request context is not required. .. note:: @@ -79,7 +82,12 @@ directly instead of referring to Django settings by name: .. literalinclude:: /examples/integrations/drf/inline.py :language: python - :start-at: from rest_framework + :lines: 1-23 + +.. code-annotations:: + + 1. Pass a ``Transform`` directly instead of a configured name. + 2. Map variant names to their transformations. Omit ``transform`` from ``ImgproxyURLField`` to generate a URL without processing options. ``ImgproxyVariantsField`` always requires ``variants``. @@ -92,10 +100,15 @@ fields used in the response: .. code-block:: python - image = serializers.ImageField(write_only=True, required=False) + image = serializers.ImageField(write_only=True, required=False) # (1)! thumbnail = ImgproxyURLField(source='image', transform='thumbnail') images = ImgproxyVariantsField(source='image', variants='product') +.. code-annotations:: + + 1. Accept uploads with a standard DRF field. The generated fields remain + read-only and appear in the response. + In a ``ModelSerializer``, include all three fields in ``Meta.fields``. Configure an appropriate parser for the upload endpoint, as described in `DRF's file upload documentation @@ -104,14 +117,12 @@ Configure an appropriate parser for the upload endpoint, as described in Source resolution ----------------- -Nonempty source strings and model file fields use the Django integration's -source resolver. By default, strings are passed unchanged and file fields -use their storage URL. Relative URLs are not expanded using the request. -See :doc:`django` for relative media URLs and custom storage URIs. +Nonempty sources use the Django source resolver. Strings are passed unchanged; +file fields use ``.url``. Relative URLs are not expanded using the request. +See :doc:`django` to configure source resolution. Next steps ---------- * :doc:`django` describes shared settings and source resolution. -* :doc:`../user-guide/transformations` describes reusable transformations. * :doc:`../api-reference/drf` documents the serializer fields and arguments. diff --git a/docs/pages/integrations/fastapi.rst b/docs/pages/integrations/fastapi.rst index cbfb912..eeee33d 100644 --- a/docs/pages/integrations/fastapi.rst +++ b/docs/pages/integrations/fastapi.rst @@ -1,11 +1,8 @@ FastAPI ======= -The FastAPI integration lets you generate imgproxy URLs in route handlers -using FastAPI's dependency injection. Register a configured -:class:`~imgproxykit.Client` once when creating your application, then -inject it into handlers with ``Depends``. The same client is reused across -requests, keeping the imgproxy address and signing credentials in one place. +Register an imgproxy client and inject it into FastAPI route handlers with +``Depends``. Installation ------------ @@ -30,29 +27,45 @@ Installation pip install 'imgproxykit[fastapi]' -Register Client ---------------- +Register a client +----------------- Call :func:`~imgproxykit.integrations.fastapi.add_imgproxy` while creating the application. Keep the returned dependency for use in your routes: .. literalinclude:: /examples/integrations/fastapi/client.py :language: python - :start-at: from dataclasses + :lines: 1-28 + +.. code-annotations:: + + 1. Register the client once and keep the returned dependency. The client + is stored on ``app.state.imgproxykit_client``. + 2. Inject the registered client into the route handler. The same client + is reused across requests. + +URL generation makes no network requests. See :doc:`../user-guide/client` +to configure signing credentials. + +Return variants +--------------- + +Declare image variants as a dataclass and use it as a field in a Pydantic +response model: + +.. literalinclude:: /examples/integrations/fastapi/variants.py + :language: python + :lines: 1-40 -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`. +.. code-annotations:: -``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. + 1. Associate each URL field with a transformation using ``Annotated``. + 2. Include the dataclass in the response model. FastAPI documents its + string fields in the OpenAPI schema. + 3. Generate a dataclass instance containing the URLs. Pydantic serializes + it as a nested object. -See :ref:`dataclass-variants` for declaration requirements and shared -transformations. +See :ref:`dataclass-variants` for declaration requirements. Multiple clients ---------------- @@ -62,18 +75,20 @@ Register each client separately and use its returned dependency in .. literalinclude:: /examples/integrations/fastapi/multiple_clients.py :language: python - :start-at: from typing + :lines: 1-44 + +.. code-annotations:: -The first client uses ``app.state.imgproxykit_client``. The additional -client uses the attribute supplied through ``name``, here -``app.state.unsigned_imgproxy``. A name that is already present on -``app.state`` raises ``ValueError``. + 1. Register a signed client under the default name. Replace the example + key and salt with your imgproxy credentials. + 2. Give the unsigned client a separate name on ``app.state``. + 3. Use each registration's returned dependency to inject its client. +Registering a name already present on ``app.state`` raises ``ValueError``. Unsigned URLs require an imgproxy server configured to accept them. Next steps ---------- -* :doc:`../user-guide/transformations` describes reusable transformations. * :doc:`../user-guide/variants` shows how to generate several image URLs. * :doc:`../api-reference/fastapi` documents the registration function. diff --git a/docs/pages/user-guide/client.rst b/docs/pages/user-guide/client.rst index cf6d73a..4417b34 100644 --- a/docs/pages/user-guide/client.rst +++ b/docs/pages/user-guide/client.rst @@ -31,40 +31,64 @@ Create a client with your imgproxy server's address. Without signing credentials, it generates unsigned URLs containing ``/unsafe/``; the server must accept unsigned requests. -Call :meth:`~imgproxykit.Client.url` with the original image's URI as -``source``. The source must be accessible to imgproxy. Pass a -:class:`~imgproxykit.Transform` through ``transform`` to include processing -options: +Pass an image URI as ``source`` and a :class:`~imgproxykit.Transform` +as ``transform`` to :meth:`~imgproxykit.Client.url`: .. literalinclude:: /examples/client/url.py :language: python - :lines: 8-23 + :show-output: + :lines: 1-13 -``thumbnail_url`` is a string ready for an image's ``src`` attribute or an -API response. URL generation makes no network requests; imgproxy fetches -and processes the original when the generated URL is requested. +.. code-annotations:: + + 1. Address of the local imgproxy container started above. + 2. Generate a URL without making a network request. Imgproxy fetches and + processes the source image when the generated URL is requested. + +Use ``thumbnail_url`` in an image's ``src`` attribute or an API response. To generate a URL without explicit processing options, omit ``transform``: -.. literalinclude:: /examples/client/url.py +.. literalinclude:: /examples/client/default_url.py :language: python - :lines: 25-26 + :show-output: + :lines: 1-7 Imgproxy applies its server defaults. -.. tip:: +Shorten option names +-------------------- + +Set ``use_short_options=True`` to use aliases such as ``w`` and ``q``: + +.. literalinclude:: /examples/client/short_options.py + :language: python + :show-output: + :lines: 1-10 + +.. code-annotations:: + + 1. Use short aliases where available. Options without a short alias keep + their full names. - To shorten URLs with many processing options, set - ``use_short_options=True``. This uses aliases such as ``w`` and ``q`` - instead of the full names used by default: +Changing this setting changes URL paths and their signatures. - .. code-block:: python +Encode the source URL +--------------------- + +By default, source URLs use URL-safe Base64. Set ``source_encoding='plain'`` +to use percent-encoding with imgproxy's ``/plain/`` marker: - client = Client('https://imgproxy.example.com', use_short_options=True) - client.url(source, transform=Transform(width=300, quality=85)) +.. literalinclude:: /examples/client/plain_source.py + :language: python + :show-output: + :lines: 1-10 - Options without a short alias keep their full names. Switching this - setting changes URL paths and their signatures. +.. code-annotations:: + + 1. Select plain encoding for all URLs generated by this client. + 2. Pass the original source URI. The client percent-encodes it for you; + do not encode it before passing it to ``url()``. Sign URLs --------- @@ -93,7 +117,13 @@ Use the same hex-encoded values configured in your server's .. literalinclude:: /examples/client/signed_url.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-11 + +.. code-annotations:: + + 1. Example hex-encoded credentials matching the container above. Replace + both ``key`` and ``salt`` with your server's values in your application. This client signs URLs generated by both :meth:`~imgproxykit.Client.url` and :meth:`~imgproxykit.Client.variants`. @@ -104,14 +134,6 @@ and :meth:`~imgproxykit.Client.variants`. generated URLs to browsers and other clients. Use your own credentials when connecting to a production server. -Generate several URLs ---------------------- - -:meth:`~imgproxykit.Client.variants` accepts a mapping of names to complete -transformations and returns a dictionary of URLs for one source. Every URL -uses this client's configuration. See :doc:`variants` for defining, -reusing, and typing these mappings. - .. tip:: Keep the server on port 8080 running for the remaining chapters. @@ -131,5 +153,7 @@ Next steps ---------- Continue with :doc:`transformations` to define and reuse image -transformations. See the :doc:`Client API reference <../api-reference/client>` -for all configuration options. +transformations. To generate several versions of one image, see +:doc:`variants`. See the +:doc:`Client API reference <../api-reference/client>` for all configuration +options. diff --git a/docs/pages/user-guide/options.rst b/docs/pages/user-guide/options.rst index 5fdf4dc..19b0669 100644 --- a/docs/pages/user-guide/options.rst +++ b/docs/pages/user-guide/options.rst @@ -19,11 +19,16 @@ Scalar options accept a single value. Pass an option instance to .. literalinclude:: /examples/options/scalar_options.py :language: python - :lines: 10-26 + :lines: 1-17 + :show-output: -``Quality(85)`` and ``quality=85`` create the same option, as do -``Format('webp')`` and ``format='webp'``. Both forms can be used together -in one transformation. Choose whichever fits how you organize your settings. +.. code-annotations:: + + 1. Pass scalar options as instances of ``Quality`` and ``Format``. + 2. Use keyword arguments to create the same options. + +Both transformations generate the same URL. Option instances and keyword +arguments can also be combined in one transformation. Structured options ------------------ @@ -34,16 +39,34 @@ while :class:`~imgproxykit.options.Gravity` selects the crop position: .. literalinclude:: /examples/options/resize.py :language: python - :lines: 8-25 + :lines: 1-15 + :show-output: + +.. code-annotations:: + + 1. Request a square crop using the fill strategy. + 2. Keep the center when cropping. + 3. Request twice the pixel density using a scalar keyword. + +Pass structured options as positional arguments; scalar keywords can follow +them. + +Omitted arguments +----------------- + +Omit an argument to leave it to imgproxy. Explicit ``False`` and zero values +are preserved: + +.. literalinclude:: /examples/options/omitted_arguments.py + :language: python + :lines: 1-14 + :show-output: -``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. +.. code-annotations:: -Omitted arguments use imgproxy's defaults. For example, leaving -``Resize.enlarge`` unset lets the server decide whether to upscale a small -source. Explicit ``False`` and zero values are preserved. + 1. Leave the upscaling setting to the server. + 2. Explicitly disable upscaling. The path preserves the empty height + position and serializes ``False`` as ``0``. .. _server-presets: @@ -56,13 +79,17 @@ combine them with other options: .. literalinclude:: /examples/transformations/presets.py :language: python - :lines: 8-25 + :lines: 1-15 + :show-output: + +.. code-annotations:: + + 1. Reference presets defined on the guide's Docker server: + ``thumbnail`` is a 300×300 crop and ``small`` is a 96×96 crop. + 2. Combine preset references with explicit quality and output format. -The guide's Docker server defines ``thumbnail`` as a 300×300 crop and -``small`` as a 96×96 crop. This example references both presets in that -order and requests quality 85 and WebP output. Imgproxy resolves their -settings; the library sends the preset names without loading or expanding -their definitions. +The library sends preset names in the supplied order. Imgproxy resolves +their definitions on the server. Available options ----------------- @@ -77,9 +104,9 @@ list and constructor arguments. Some options require server configuration. For example, source limit options require ``IMGPROXY_ALLOW_SECURITY_OPTIONS=true``. Supported formats -and processing features -depend on your imgproxy installation; type annotations do not check server -capabilities. See the `imgproxy configuration documentation +and processing features depend on your imgproxy installation; type +annotations do not check server capabilities. See the +`imgproxy configuration documentation `__ for server settings. Next steps diff --git a/docs/pages/user-guide/transformations.rst b/docs/pages/user-guide/transformations.rst index 60ea89d..15f763a 100644 --- a/docs/pages/user-guide/transformations.rst +++ b/docs/pages/user-guide/transformations.rst @@ -19,53 +19,68 @@ keyword arguments: .. literalinclude:: /examples/transformations/create_transform.py :language: python - :lines: 10-27 + :show-output: + :lines: 1-15 -``thumbnail`` combines a 300×300 crop, quality 85, and WebP output format. -Pass it to ``client.url()`` through the ``transform`` argument and open the -printed URL to see the result. The :doc:`options` chapter explains the -available option classes and their arguments. +.. code-annotations:: + + 1. Pass structured processing options as positional option instances. + 2. Pass scalar options as keywords. ``quality=85`` is equivalent to + ``Quality(85)``. + +Pass ``thumbnail`` to ``client.url()`` to apply it to an image. +See :doc:`options` for available options and their arguments. Reuse a transformation ---------------------- -Transformations are immutable and designed for reuse. Define a transformation -once and use it for different source images; building a URL does not change -its settings. Built-in option instances are immutable too. - -For example, reuse a 300×300 WebP thumbnail transformation across product -images: +Define a transformation once and use it with different source images: .. literalinclude:: /examples/transformations/reuse_transform.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-19 + +.. code-annotations:: + + 1. Transformations and built-in options are immutable. You can keep + ``thumbnail`` at module level and reuse it across requests. -``thumbnail_urls`` contains a URL for each source, using the same crop and -format. You can keep ``thumbnail`` at module level and reuse it across -requests. To add processing options, create a new transformation with -:meth:`~imgproxykit.Transform.extend`, as shown below. +Each URL uses the same transformation with a different source. Compose transformations ----------------------- Use :meth:`~imgproxykit.Transform.extend` to create a new transformation -with additional options. It accepts the same positional options and scalar -keyword arguments as the constructor: +with additional options: .. literalinclude:: /examples/transformations/extend.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-15 -Each call returns a new transformation, leaving the original unchanged. -New positional options follow the existing options; scalar keyword options -come last, in keyword order. +.. code-annotations:: + + 1. Shared quality and output format. + 2. Append a square crop, leaving ``base`` unchanged. + 3. Append a pixel density option, leaving ``thumbnail`` unchanged. + +The generated URLs show the options retained by each transformation. +New positional options follow existing options; scalar keywords come last, +in keyword order. To combine reusable groups, pass another transformation's unpacked ``options`` to ``extend()``: .. literalinclude:: /examples/transformations/composition.py :language: python - :start-at: from imgproxykit + :show-output: + :lines: 1-12 + +.. code-annotations:: + + 1. Unpack ``square.options`` to append its options to ``base``. + Both original transformations remain unchanged. ``extend()`` appends options without merging or replacing them. For example, ``base.extend(quality=70)`` retains both quality options. Imgproxy resolves diff --git a/docs/pages/user-guide/variants.rst b/docs/pages/user-guide/variants.rst index 17fe64e..e051820 100644 --- a/docs/pages/user-guide/variants.rst +++ b/docs/pages/user-guide/variants.rst @@ -19,12 +19,18 @@ source image: .. literalinclude:: /examples/variants/basic.py :language: python - :lines: 10-27 + :lines: 1-16 + :show-output: + :output-language: python -``product_urls`` contains a URL for each of the three widths. Access a URL -by its name, or pass the dictionary to a template or API response. -The names belong to your application; each URL uses its transformation and -the client's signing settings. +.. code-annotations:: + + 1. Choose names for your application and a complete transformation for + each variant. + 2. Generate one URL per variant using the same source and client settings. + +Access URLs by name, such as ``product_urls['small']``, or pass the dictionary +to a template or API response. Reuse a variant set ------------------- @@ -35,11 +41,18 @@ add a width for each variant: .. literalinclude:: /examples/variants/reusable_sets.py :language: python - :lines: 12-32 + :lines: 1-20 + :show-output: + :output-language: python + +.. code-annotations:: + + 1. Share quality and format, then use ``extend()`` to add each width. + 2. Reuse ``sizes`` with different sources. You can keep the mapping at + module level and reuse it across requests. -Both dictionaries contain the same names and processing settings, with -URLs pointing to their respective sources. The shared ``sizes`` mapping -can be kept at module level and reused across requests. +Both dictionaries have the same variant names and transformations, with +URLs pointing to their respective sources. Type variant names ------------------ @@ -49,12 +62,19 @@ when accessing the generated URLs: .. literalinclude:: /examples/variants/typed.py :language: python - :lines: 8-29 + :lines: 1-20 + :show-output: + :output-language: python -``product_urls`` has type ``dict[ProductKeys, str]``. A type checker accepts -``product_urls['small']`` and reports ``product_urls['profile']`` as an -invalid key. Without an explicit key annotation, a regular string-keyed -mapping typically produces ``dict[str, str]``. +.. code-annotations:: + + 1. Limit variant names to a ``Literal`` union and use it as the mapping's + key type. + 2. The returned dictionary preserves that key type. A type checker accepts + ``product_urls['small']`` and rejects ``product_urls['profile']``. + +Without an explicit key annotation, a regular string-keyed mapping typically +produces ``dict[str, str]``. .. _dataclass-variants: @@ -67,11 +87,17 @@ type, ``str``, with a transformation in ``Annotated``: .. literalinclude:: /examples/variants/dataclass.py :language: python - :start-at: from dataclasses + :lines: 1-27 + :show-output: + :output-language: python + +.. code-annotations:: + + 1. Declare each URL field as ``Annotated[str, Transform(...)]``. + 2. Pass the class to return a ``ProductImages`` instance containing URLs. -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. +Access URLs as attributes, such as ``product_urls.small``. Reuse the class +with other sources or clients. .. note:: diff --git a/poetry.lock b/poetry.lock index 7ae1064..f334237 100644 --- a/poetry.lock +++ b/poetry.lock @@ -31,7 +31,7 @@ version = "0.8.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, @@ -478,6 +478,55 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=23.1.0)"] bcrypt = ["bcrypt (>=4.1.1)"] +[[package]] +name = "django-modern-rest" +version = "0.16.0" +description = "Modern REST framework for Django with types and async support" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "django_modern_rest-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a961c242a3506978270e8fe5353db2a618a313aea7fe0a95f37372fe2944b78e"}, + {file = "django_modern_rest-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31eb9c615099c4bca8793bcf1464ea3ca19716ffef5f59357d96962ac1916ca4"}, + {file = "django_modern_rest-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de04c6c43bc902f13c9d2819a2537b0dd0e709878d0ba13fbea6e289750bf629"}, + {file = "django_modern_rest-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95cee28595905bc29eecfcff8b06b35827e7c716d2df492d4eeffd37891cdce6"}, + {file = "django_modern_rest-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:227a57c01566d518163be6a92512bc41f4e4f0889793de97c71d379e285b5add"}, + {file = "django_modern_rest-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db1abbddbdbc99e5fd74115ee411b9ef0c7a26b011eb2f545dbcc9c76971bd18"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6eee91d7200a9370773bd0d873126640d3f13ac0423a943438e98083a7939f12"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf7be5e20c551a8d79f6a11befac8dea45a3b9e19f696dae82cc4204141a27e"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4014e9d03723477d48579f88f0d9d19d40216509c864407490b9ed64dd4bb1fe"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da9380e4e5f499fa524f7987ed0c4ae40f757826cd0575559e5d832f7edb0dfa"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0278d9c38a05d5eb22425fff3295267a93b29eca7e35cb6bf137e77cb228319c"}, + {file = "django_modern_rest-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:6654d20298dcd645fb99dbd5afe329a9bd8ef394585e6cbe8060da5cbd6b87b7"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:141cf41f2dc1f64bff232945aa10237a5220c960e9c95179d60216b54dc2beb6"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb255f9e78c8f551c9f0a31b05a7bd3451c9d9ec909d6b0fec442a1da808f139"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5fdf21601dbb9f078de22bea44b387d927709fd0043f8fbf67121f22081183a0"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:959b3f7ffa9460eb20119fdfbb92c5afe28f86ccf805d949bba433b58f2a4903"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:31f0c9498fc7fb562af652c6a1891a58c2ce59bcbfdb7f03b68efb1f17ab5dc9"}, + {file = "django_modern_rest-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:d9c3ef92590fc5f3d2e89579c17e5931e3d46348ef52b39575aff11165635327"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20c20fcd9895e5c2fc2612d7a0e5199cb5417a664ef466a19d72a90fc0f7c8ad"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c1ac63ee3464379bf05bd381dc5b3d66413ec303a59bb1787f1419a0462d66e"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd2ee34e9a30cd043ae4365b7d0c121f06d2844f0d4f1aa6ca466cdde58b5512"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea087bf88369acab2d68755738c70285034980bd381fee67bb93dcd229365415"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50b97081a85ee119466120be192c223ed5b76082f87399d2840ea5435edcd097"}, + {file = "django_modern_rest-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:010fe97d7b15e74cf15ae4cf86fda83ea3b54ef33ae4a4a92e189a5f2a94756b"}, + {file = "django_modern_rest-0.16.0-py3-none-any.whl", hash = "sha256:30817d5c17c080bb018f2bf49ef15f0461635a14bef596c93a2f2043e2541559"}, + {file = "django_modern_rest-0.16.0.tar.gz", hash = "sha256:c16cd35cad4ee19ecc9336be086aa61b51a13ed715a03063fa36be143829b470"}, +] + +[package.dependencies] +django = ">=5,<6.2" +pydantic = {version = ">=2.12", extras = ["email", "timezone"], optional = true, markers = "extra == \"pydantic\""} +pydantic-extra-types = {version = "*", optional = true, markers = "extra == \"pydantic\""} +typing-extensions = ">=4.12" + +[package.extras] +attrs = ["attrs (>=26)", "msgspec (>=0.22)"] +jwt = ["pyjwt[crypto] (>=2.15)"] +msgspec = ["msgspec (>=0.22)"] +openapi = ["openapi-spec-validator (>=0.8)", "polyfactory (>=3.2)", "pyyaml"] +pydantic = ["pydantic-extra-types", "pydantic[email,timezone] (>=2.12)"] + [[package]] name = "django-stubs" version = "6.1.2" @@ -556,6 +605,27 @@ coreapi = ["coreapi (>=2.0.0)"] markdown = ["types-markdown (>=0.1.5)"] requests = ["types-requests"] +[[package]] +name = "dnspython" +version = "2.9.1" +description = "DNS toolkit" +optional = false +python-versions = ">=3.11" +groups = ["dev"] +files = [ + {file = "dnspython-2.9.1-py3-none-any.whl", hash = "sha256:7c81caf8de1ed57b8cfc7e0ad4f528a0b8ceace473c7eb946df195dc9def751f"}, + {file = "dnspython-2.9.1.tar.gz", hash = "sha256:9a6d30e55f8dc63e6052597666112bff0586b95a2474a9deda03afbbf820d37a"}, +] + +[package.extras] +dev = ["black (>=26.5)", "coverage (>=7.15)", "hypercorn (>=0.18.0)", "pyright (>=1.1.411)", "pytest (>=9.1)", "pytest-cov (>=7.1)", "quart-trio (>=0.12.0)", "ruff (>=0.16.0)", "sphinx (>=9.1.0) ; python_full_version >= \"3.12.0\"", "sphinx-rtd-theme (>=3.1.0) ; python_full_version >= \"3.12.0\"", "trustme (>=1.2.1)", "ty (>=0.0.85)"] +dnssec = ["cryptography (>=50)"] +doh = ["h2 (>=4.4)", "httpcore2 (>=2.13)", "httpx2 (>=2.13)"] +doq = ["aioquic (>=1.3.0)"] +idna = ["idna (>=3.20)"] +trio = ["trio (>=0.34)"] +wmi = ["wmi (>=1.5.1) ; sys_platform == \"win32\""] + [[package]] name = "docker" version = "7.2.0" @@ -591,6 +661,22 @@ files = [ {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"}, ] +[[package]] +name = "email-validator" +version = "2.3.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"}, + {file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + [[package]] name = "executing" version = "2.3.0" @@ -1547,7 +1633,7 @@ version = "2.14.1" description = "Data validation using Python type hints" optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"}, {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"}, @@ -1556,9 +1642,11 @@ markers = {main = "extra == \"fastapi\""} [package.dependencies] annotated-types = ">=0.6.0" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} pydantic-core = "2.50.1" typing-extensions = ">=4.16.0" typing-inspection = ">=0.4.4" +tzdata = {version = "*", optional = true, markers = "platform_system == \"Windows\" and extra == \"timezone\""} [package.extras] email = ["email-validator (>=2.0.0)"] @@ -1570,7 +1658,7 @@ version = "2.50.1" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "pydantic_core-2.50.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b281a3b0f0822618fe5e3e0d8a2048b6356b14388505dc9374ccffeb69989713"}, {file = "pydantic_core-2.50.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1fa4c8bc12c1354c5550c0c35c1852c8c1901e89e06561724e03f8d0342e1f87"}, @@ -1721,7 +1809,7 @@ version = "2.11.1" description = "Extra Pydantic types." optional = false python-versions = ">=3.9" -groups = ["docs"] +groups = ["dev", "docs"] files = [ {file = "pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1"}, {file = "pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049"}, @@ -2513,7 +2601,7 @@ version = "0.4.4" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.10" -groups = ["main", "docs"] +groups = ["main", "dev", "docs"] files = [ {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"}, {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"}, @@ -2741,4 +2829,4 @@ fastapi = ["fastapi"] [metadata] lock-version = "2.1" python-versions = ">=3.12,<4" -content-hash = "0fdec7b75e7d4e852610a4e11eb2dc7e42af78b0a696e8fa34b2704128351a5a" +content-hash = "d3c5c5ae191db6e4a29400824cab5450bf4547cea59c7a68e1b57f4fd92c582a" diff --git a/pyproject.toml b/pyproject.toml index 246e9e1..49c1640 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,8 @@ pillow = ">=13.0.0,<14" testcontainers = ">=4.15,<5" zapros = ">=0.22.0,<0.23" -# Django and DRF integration typing and tests: +# Django integration typing and tests: +django-modern-rest = { version = ">=0.16,<0.17", extras = [ "pydantic" ] } django-stubs = "^6.1" djangorestframework-stubs = "^3.18" pytest-django = ">=4.12,<5" @@ -288,13 +289,14 @@ addopts = [ "--doctest-glob=*.rst", "--doctest-glob=README.md", "--ignore=docs/conf.py", + "--ignore=docs/_ext/", + "--ignore=docs/examples/", "--ignore=docs/_build/", # Output: "--tb=short", # Coverage: "--cov=imgproxykit", "--cov=tests", - "--cov=docs/examples", "--cov-report=term-missing:skip-covered", "--cov-report=html", "--cov-report=xml", From 4e046e7f394ad6c031a83afe313f175bfa3f45e3 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Sat, 31 Oct 2026 19:10:14 +0500 Subject: [PATCH 2/3] Update docs --- CHANGELOG.md | 4 + docs/examples/integrations/dmr/model_file.py | 45 ++++++ docs/examples/integrations/dmr/url.py | 29 ++++ docs/examples/integrations/dmr/variants.py | 39 +++++ docs/pages/integrations/dmr.rst | 150 +++++++++++++++++++ docs/pages/user-guide/client.rst | 15 -- 6 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 docs/examples/integrations/dmr/model_file.py create mode 100644 docs/examples/integrations/dmr/url.py create mode 100644 docs/examples/integrations/dmr/variants.py create mode 100644 docs/pages/integrations/dmr.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee69b5..b6c7979 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,10 @@ but with a deprecation period. - `PathSerializer` now requires the `use_short_options` keyword-only argument, #80 +### Misc + +- Reworked documentation and added a Django Modern REST integration example, #109 + ## 0.4.0 ### Features diff --git a/docs/examples/integrations/dmr/model_file.py b/docs/examples/integrations/dmr/model_file.py new file mode 100644 index 0000000..8209130 --- /dev/null +++ b/docs/examples/integrations/dmr/model_file.py @@ -0,0 +1,45 @@ +from django.db import models +from django.shortcuts import get_object_or_404 +from dmr import Controller, Path +from dmr.plugins.pydantic import PydanticFastSerializer +from pydantic import BaseModel + +from imgproxykit.integrations.django import imgproxy_url + + +class Product(models.Model): + """Product with an optional uploaded image.""" + + image = models.ImageField(upload_to='products/', blank=True) + + class Meta: + app_label = 'products' + + +class ProductPath(BaseModel): + """Identify the requested product.""" + + product_id: int + + +class ProductResponse(BaseModel): + """Product with an optional image URL.""" + + id: int + image: str | None + + +class ProductController(Controller[PydanticFastSerializer]): + """Serve a product with its image URL.""" + + def get(self, parsed_path: Path[ProductPath]) -> ProductResponse: + """Return a product and transform its image when present.""" + product = get_object_or_404(Product, pk=parsed_path.product_id) + return ProductResponse( + id=product.pk, + image=( + imgproxy_url(product.image, 'thumbnail') + if product.image # (1)! + else None + ), + ) diff --git a/docs/examples/integrations/dmr/url.py b/docs/examples/integrations/dmr/url.py new file mode 100644 index 0000000..262d3dd --- /dev/null +++ b/docs/examples/integrations/dmr/url.py @@ -0,0 +1,29 @@ +from dmr import Controller, Path +from dmr.plugins.pydantic import PydanticFastSerializer +from pydantic import BaseModel + +from imgproxykit.integrations.django import imgproxy_url + + +class ProductPath(BaseModel): + """Identify the product requested by the URL.""" + + product_id: int + + +class ProductResponse(BaseModel): + """Product and its generated image URL.""" + + id: int + image: str + + +class ProductController(Controller[PydanticFastSerializer]): + """Serve a product with its image URL.""" + + def get(self, parsed_path: Path[ProductPath]) -> ProductResponse: + """Return a product with a transformed image URL.""" + product_id = parsed_path.product_id + source = f'https://originals.example.com/products/{product_id}.jpg' + image = imgproxy_url(source, 'thumbnail') # (1)! + return ProductResponse(id=product_id, image=image) # (2)! diff --git a/docs/examples/integrations/dmr/variants.py b/docs/examples/integrations/dmr/variants.py new file mode 100644 index 0000000..f18ba0c --- /dev/null +++ b/docs/examples/integrations/dmr/variants.py @@ -0,0 +1,39 @@ +from dmr import Controller, Path +from dmr.plugins.pydantic import PydanticFastSerializer +from pydantic import BaseModel + +from imgproxykit.integrations.django import imgproxy_variants + + +class ProductPath(BaseModel): + """Identify the product requested by the URL.""" + + product_id: int + + +class ProductImages(BaseModel): + """Named image variants exposed by the API.""" + + small: str + large: str + + +class ProductResponse(BaseModel): + """Product and its named image variants.""" + + id: int + images: ProductImages # (1)! + + +class ProductController(Controller[PydanticFastSerializer]): + """Serve a product with its image variants.""" + + def get(self, parsed_path: Path[ProductPath]) -> ProductResponse: + """Return a product with its configured image variants.""" + product_id = parsed_path.product_id + source = f'https://originals.example.com/products/{product_id}.jpg' + images = imgproxy_variants(source, 'product') # (2)! + return ProductResponse( + id=product_id, + images=ProductImages(**images), # (3)! + ) diff --git a/docs/pages/integrations/dmr.rst b/docs/pages/integrations/dmr.rst new file mode 100644 index 0000000..9f9d803 --- /dev/null +++ b/docs/pages/integrations/dmr.rst @@ -0,0 +1,150 @@ +Django Modern REST +================== + +`Django Modern REST (DMR) +`__ is a typed REST +framework for Django with sync and async support and OpenAPI schema generation. +Use imgproxykit's Django shortcuts in DMR controllers to return image URLs +and named variants in typed response models. + +Explore the `DMR getting started guide +`__ +and the `project on GitHub +`__. + +Installation +------------ + +In your DMR project, install imgproxykit with Django support: + +.. md-tab-set:: + + .. md-tab-item:: :iconify:`material-icon-theme:uv` uv + + .. code-block:: bash + + uv add 'imgproxykit[django]' + + .. md-tab-item:: :iconify:`devicon:poetry` poetry + + .. code-block:: bash + + poetry add 'imgproxykit[django]' + + .. md-tab-item:: :iconify:`devicon:pypi` pip + + .. code-block:: bash + + pip install 'imgproxykit[django]' + +The integration uses the Django shortcuts directly. No DMR-specific plugin +or addition to ``INSTALLED_APPS`` is required. + +Configuration +------------- + +Define ``IMGPROXYKIT`` in your Django settings. These settings are shared with +the :doc:`Django integration `: + +.. literalinclude:: /examples/integrations/django/settings.py + :language: python + :lines: 1-19 + +.. code-annotations:: + + 1. Configure the shared client with your imgproxy server's URL. + 2. Use your server's hex-encoded key and salt, or omit both if the server + accepts unsigned URLs. + 3. Define the ``thumbnail`` transformation used by the URL example. + 4. Define the ``product`` variant set used by the variants example. + +Return an image URL +------------------- + +Generate a URL with ``imgproxy_url`` and return it in a Pydantic response model. +URL generation makes no network requests; imgproxy processes the image when +its URL is requested. + +The examples use ``PydanticFastSerializer`` for JSON requests and responses. +See DMR's `Pydantic plugin guide +`__ +for serializer options. + +.. literalinclude:: /examples/integrations/dmr/url.py + :language: python + +.. code-annotations:: + + 1. Apply ``IMGPROXYKIT['transforms']['thumbnail']`` to the source image. + 2. Return the generated URL as a string in the response model. + 3. Register the controller with Django's ``path()``. ``Path[ProductPath]`` + provides the typed product ID to the handler. + +See DMR's guides to `controllers +`__ +and `path parameters +`__ +for request parsing and handler definitions. + +Return variants +--------------- + +For several image sizes, use ``imgproxy_variants`` and a nested response model. +Keep the imports, ``ProductPath``, and route from the previous example, +replacing the shortcut import with +``from imgproxykit.integrations.django import imgproxy_variants``: + +.. literalinclude:: /examples/integrations/dmr/variants.py + :language: python + :start-at: class ProductImages + +.. code-annotations:: + + 1. Expose ``small`` and ``large`` as named fields in the response model + and its OpenAPI schema. + 2. Generate URLs with ``IMGPROXYKIT['variants']['product']``. + 3. Validate the generated mapping against the response model. Keep its + fields in sync with the configured variant names. + +Use ``images: dict[str, str]`` and return the mapping directly when variant +names can vary. A dedicated model describes a fixed set of fields in the API. +See DMR's `OpenAPI guide +`__ +to publish the schema and interactive API documentation. + +Model file fields +----------------- + +For a product loaded from your database, pass ``product.image`` directly to +``imgproxy_url`` in the controller. Here, ``Product`` is your Django model +with an optional image field, and ``ProductPath`` is defined as above. +Imports, the Django model, and the route are omitted from this snippet: + +.. literalinclude:: /examples/integrations/dmr/model_file.py + :language: python + :start-at: class ProductResponse + +.. code-annotations:: + + 1. Generate a URL only when the file field has an associated file; + otherwise return ``None``, serialized as JSON ``null``. + +Without the empty-field check, the default source +resolver raises ``ValueError`` for a field without an associated file. + +Source resolution +----------------- + +Both shortcuts use the shared Django source resolver: strings are passed +unchanged and model file fields use ``.url``. Relative URLs are not expanded +automatically. See :doc:`django` to configure relative URLs and storage URIs. + +Next steps +---------- + +* `DMR documentation `__ + covers controllers, validation, serializers, and API schemas. +* `DMR on GitHub `__ + provides the source code, issues, and contribution guide. +* :doc:`../user-guide/variants` describes reusable variant sets. +* :doc:`../api-reference/django` documents the shared client and shortcuts. diff --git a/docs/pages/user-guide/client.rst b/docs/pages/user-guide/client.rst index 4417b34..44bd0df 100644 --- a/docs/pages/user-guide/client.rst +++ b/docs/pages/user-guide/client.rst @@ -134,21 +134,6 @@ and :meth:`~imgproxykit.Client.variants`. generated URLs to browsers and other clients. Use your own credentials when connecting to a production server. -.. tip:: - - Keep the server on port 8080 running for the remaining chapters. - When finished, stop it: - - .. code-block:: bash - - docker stop imgproxy-guide - - If you also started the signing example, stop that container: - - .. code-block:: bash - - docker stop imgproxy-guide-signed - Next steps ---------- From df010487063e02d096484326b57acf949e1e37f1 Mon Sep 17 00:00:00 2001 From: Alexander Kondratev Date: Sat, 31 Oct 2026 19:24:38 +0500 Subject: [PATCH 3/3] Update docs --- docs/examples/integrations/dmr/url.py | 29 +++++++---------- docs/examples/integrations/dmr/variants.py | 32 +++++++------------ docs/examples/integrations/fastapi/client.py | 24 +++++++------- .../integrations/fastapi/multiple_clients.py | 22 ++++++------- .../examples/integrations/fastapi/variants.py | 29 ++++++++--------- docs/pages/integrations/django.rst | 18 ++++++++--- docs/pages/integrations/dmr.rst | 9 +++--- docs/pages/integrations/drf.rst | 17 ++++++++-- docs/pages/integrations/fastapi.rst | 18 ++++++++--- 9 files changed, 107 insertions(+), 91 deletions(-) diff --git a/docs/examples/integrations/dmr/url.py b/docs/examples/integrations/dmr/url.py index 262d3dd..afa6f64 100644 --- a/docs/examples/integrations/dmr/url.py +++ b/docs/examples/integrations/dmr/url.py @@ -1,29 +1,24 @@ -from dmr import Controller, Path +from typing import Final + +from dmr import Controller from dmr.plugins.pydantic import PydanticFastSerializer from pydantic import BaseModel from imgproxykit.integrations.django import imgproxy_url - -class ProductPath(BaseModel): - """Identify the product requested by the URL.""" - - product_id: int +_SOURCE_URL: Final = 'https://originals.example.com/photo.jpg' -class ProductResponse(BaseModel): - """Product and its generated image URL.""" +class ImageResponse(BaseModel): + """Generated image URL.""" - id: int image: str -class ProductController(Controller[PydanticFastSerializer]): - """Serve a product with its image URL.""" +class ImageController(Controller[PydanticFastSerializer]): + """Serve a transformed image URL.""" - def get(self, parsed_path: Path[ProductPath]) -> ProductResponse: - """Return a product with a transformed image URL.""" - product_id = parsed_path.product_id - source = f'https://originals.example.com/products/{product_id}.jpg' - image = imgproxy_url(source, 'thumbnail') # (1)! - return ProductResponse(id=product_id, image=image) # (2)! + def get(self) -> ImageResponse: + """Return a transformed image URL.""" + image = imgproxy_url(_SOURCE_URL, 'thumbnail') # (1)! + return ImageResponse(image=image) # (2)! diff --git a/docs/examples/integrations/dmr/variants.py b/docs/examples/integrations/dmr/variants.py index f18ba0c..4d40709 100644 --- a/docs/examples/integrations/dmr/variants.py +++ b/docs/examples/integrations/dmr/variants.py @@ -1,14 +1,12 @@ -from dmr import Controller, Path +from typing import Final + +from dmr import Controller from dmr.plugins.pydantic import PydanticFastSerializer from pydantic import BaseModel from imgproxykit.integrations.django import imgproxy_variants - -class ProductPath(BaseModel): - """Identify the product requested by the URL.""" - - product_id: int +_SOURCE_URL: Final = 'https://originals.example.com/photo.jpg' class ProductImages(BaseModel): @@ -18,22 +16,16 @@ class ProductImages(BaseModel): large: str -class ProductResponse(BaseModel): - """Product and its named image variants.""" +class ImageResponse(BaseModel): + """Named image variants.""" - id: int images: ProductImages # (1)! -class ProductController(Controller[PydanticFastSerializer]): - """Serve a product with its image variants.""" +class ImageController(Controller[PydanticFastSerializer]): + """Serve named image variants.""" - def get(self, parsed_path: Path[ProductPath]) -> ProductResponse: - """Return a product with its configured image variants.""" - product_id = parsed_path.product_id - source = f'https://originals.example.com/products/{product_id}.jpg' - images = imgproxy_variants(source, 'product') # (2)! - return ProductResponse( - id=product_id, - images=ProductImages(**images), # (3)! - ) + def get(self) -> ImageResponse: + """Return the configured image variants.""" + images = imgproxy_variants(_SOURCE_URL, 'product') # (2)! + return ImageResponse(images=ProductImages(**images)) # (3)! diff --git a/docs/examples/integrations/fastapi/client.py b/docs/examples/integrations/fastapi/client.py index db1135c..55cebdc 100644 --- a/docs/examples/integrations/fastapi/client.py +++ b/docs/examples/integrations/fastapi/client.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Final from fastapi import Depends, FastAPI from pydantic import BaseModel @@ -6,23 +6,23 @@ from imgproxykit import Client, Transform from imgproxykit.integrations.fastapi import add_imgproxy +_SOURCE_URL: Final = 'https://originals.example.com/photo.jpg' +_THUMBNAIL: Final = Transform(width=300, format='webp') + app = FastAPI() get_imgproxy = add_imgproxy(app, Client('https://imgproxy.example.com')) # (1)! -class ProductResponse(BaseModel): - """Product and its image URL returned by the API.""" +class ImageResponse(BaseModel): + """Generated image URL.""" - id: int image: str -@app.get('/products/{product_id}') -async def get_product( - product_id: int, +@app.get('/image') +async def get_image( imgproxy: Annotated[Client, Depends(get_imgproxy)], # (2)! -) -> ProductResponse: - """Return a product with a transformed image URL.""" - source = f'https://originals.example.com/products/{product_id}.jpg' - image = imgproxy.url(source, transform=Transform(width=300, format='webp')) - return ProductResponse(id=product_id, image=image) +) -> ImageResponse: + """Return a transformed image URL.""" + image = imgproxy.url(_SOURCE_URL, transform=_THUMBNAIL) + return ImageResponse(image=image) diff --git a/docs/examples/integrations/fastapi/multiple_clients.py b/docs/examples/integrations/fastapi/multiple_clients.py index 60544c0..aaab0d7 100644 --- a/docs/examples/integrations/fastapi/multiple_clients.py +++ b/docs/examples/integrations/fastapi/multiple_clients.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Final from fastapi import Depends, FastAPI from pydantic import BaseModel @@ -6,6 +6,8 @@ from imgproxykit import Client from imgproxykit.integrations.fastapi import add_imgproxy +_SOURCE_URL: Final = 'https://originals.example.com/photo.jpg' + app = FastAPI() get_signed_imgproxy = add_imgproxy( # (1)! @@ -23,22 +25,20 @@ ) -class ProductImageUrls(BaseModel): - """Signed and unsigned URLs for a product image.""" +class ImageUrls(BaseModel): + """Signed and unsigned image URLs.""" signed: str unsigned: str -@app.get('/products/{product_id}/images') -async def get_product_images( - product_id: int, +@app.get('/images') +async def get_images( signed: Annotated[Client, Depends(get_signed_imgproxy)], # (3)! unsigned: Annotated[Client, Depends(get_unsigned_imgproxy)], -) -> ProductImageUrls: +) -> ImageUrls: """Build image URLs with each client's signing configuration.""" - source = f'https://originals.example.com/products/{product_id}.jpg' - return ProductImageUrls( - signed=signed.url(source), - unsigned=unsigned.url(source), + return ImageUrls( + signed=signed.url(_SOURCE_URL), + unsigned=unsigned.url(_SOURCE_URL), ) diff --git a/docs/examples/integrations/fastapi/variants.py b/docs/examples/integrations/fastapi/variants.py index 91cd596..3602993 100644 --- a/docs/examples/integrations/fastapi/variants.py +++ b/docs/examples/integrations/fastapi/variants.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Annotated +from typing import Annotated, Final from fastapi import Depends, FastAPI from pydantic import BaseModel @@ -7,34 +7,33 @@ from imgproxykit import Client, Transform from imgproxykit.integrations.fastapi import add_imgproxy +_SOURCE_URL: Final = 'https://originals.example.com/photo.jpg' + app = FastAPI() get_imgproxy = add_imgproxy(app, Client('https://imgproxy.example.com')) -base = Transform(format='webp') +_BASE: Final = Transform(format='webp') @dataclass(frozen=True, slots=True) class ProductImages: """Declare the image response fields and their transformations.""" - thumbnail: Annotated[str, base.extend(width=320)] # (1)! - preview: Annotated[str, base.extend(width=1280)] + thumbnail: Annotated[str, _BASE.extend(width=320)] # (1)! + preview: Annotated[str, _BASE.extend(width=1280)] -class ProductResponse(BaseModel): - """Product and its image variants returned by the API.""" +class ImageResponse(BaseModel): + """Named image variants.""" - id: int images: ProductImages # (2)! -@app.get('/products/{product_id}') -async def get_product( - product_id: int, +@app.get('/image') +async def get_image( imgproxy: Annotated[Client, Depends(get_imgproxy)], -) -> ProductResponse: - """Return a product with image variants.""" - source = f'https://originals.example.com/products/{product_id}.jpg' - images = imgproxy.variants(source, variants=ProductImages) # (3)! - return ProductResponse(id=product_id, images=images) +) -> ImageResponse: + """Return named image variants.""" + images = imgproxy.variants(_SOURCE_URL, variants=ProductImages) # (3)! + return ImageResponse(images=images) diff --git a/docs/pages/integrations/django.rst b/docs/pages/integrations/django.rst index da93ab0..4fe9a3a 100644 --- a/docs/pages/integrations/django.rst +++ b/docs/pages/integrations/django.rst @@ -1,14 +1,20 @@ Django ====== -Generate imgproxy URLs from source strings and model file fields in Python -code or Django templates. Configure the client and reusable transformations -in Django settings, then generate individual image URLs or named sets of -image variants. +`Django `__ is a Python web framework. +Use imgproxykit's shortcuts and template tags to generate image URLs and +named variants from source strings and model file fields, with the client +and reusable transformations configured in Django settings. + +Explore the `Django getting started guide +`__ +and the `project on GitHub `__. Installation ------------ +In your Django project, install imgproxykit with Django support: + .. md-tab-set:: .. md-tab-item:: :iconify:`material-icon-theme:uv` uv @@ -177,6 +183,10 @@ The setting also accepts a callable directly instead of an import path. Next steps ---------- +* `Django documentation `__ + covers models, views, templates, and project configuration. +* `Django on GitHub `__ + provides the source code, issues, and contribution guide. * :doc:`dmr` returns image URLs and variants from typed controllers. * :doc:`drf` returns image URLs and variants from serializer fields. * :doc:`../api-reference/django` documents the client, URL generation functions, diff --git a/docs/pages/integrations/dmr.rst b/docs/pages/integrations/dmr.rst index 9f9d803..8d373d5 100644 --- a/docs/pages/integrations/dmr.rst +++ b/docs/pages/integrations/dmr.rst @@ -77,8 +77,6 @@ for serializer options. 1. Apply ``IMGPROXYKIT['transforms']['thumbnail']`` to the source image. 2. Return the generated URL as a string in the response model. - 3. Register the controller with Django's ``path()``. ``Path[ProductPath]`` - provides the typed product ID to the handler. See DMR's guides to `controllers `__ @@ -90,7 +88,7 @@ Return variants --------------- For several image sizes, use ``imgproxy_variants`` and a nested response model. -Keep the imports, ``ProductPath``, and route from the previous example, +Keep the imports and ``_SOURCE_URL`` from the previous example, replacing the shortcut import with ``from imgproxykit.integrations.django import imgproxy_variants``: @@ -117,8 +115,9 @@ Model file fields For a product loaded from your database, pass ``product.image`` directly to ``imgproxy_url`` in the controller. Here, ``Product`` is your Django model -with an optional image field, and ``ProductPath`` is defined as above. -Imports, the Django model, and the route are omitted from this snippet: +with an optional image field. ``ProductPath`` declares a ``product_id: int`` +path parameter using a Pydantic model. Imports, the Django model, path model, +and the route are omitted from this snippet: .. literalinclude:: /examples/integrations/dmr/model_file.py :language: python diff --git a/docs/pages/integrations/drf.rst b/docs/pages/integrations/drf.rst index 0b00ac1..c8bdc26 100644 --- a/docs/pages/integrations/drf.rst +++ b/docs/pages/integrations/drf.rst @@ -1,12 +1,21 @@ Django REST Framework ===================== -Return image URLs and named variants from DRF serializers. Use transformations -from Django settings or define them directly on serializer fields. +`Django REST Framework (DRF) `__ +is a toolkit for building Web APIs with Django. Use imgproxykit's serializer +fields to return image URLs and named variants, with transformations from +Django settings or defined directly on the fields. + +Explore the `DRF quickstart +`__ +and the `project on GitHub +`__. Installation ------------ +In your DRF project, install imgproxykit with Django REST Framework support: + .. md-tab-set:: .. md-tab-item:: :iconify:`material-icon-theme:uv` uv @@ -124,5 +133,9 @@ See :doc:`django` to configure source resolution. Next steps ---------- +* `DRF documentation `__ + covers serializers, views, authentication, and API schemas. +* `DRF on GitHub `__ + provides the source code, issues, and contribution guide. * :doc:`django` describes shared settings and source resolution. * :doc:`../api-reference/drf` documents the serializer fields and arguments. diff --git a/docs/pages/integrations/fastapi.rst b/docs/pages/integrations/fastapi.rst index eeee33d..2610967 100644 --- a/docs/pages/integrations/fastapi.rst +++ b/docs/pages/integrations/fastapi.rst @@ -1,12 +1,19 @@ FastAPI ======= -Register an imgproxy client and inject it into FastAPI route handlers with -``Depends``. +`FastAPI `__ is a Python API framework +based on type hints, with data validation and OpenAPI schema generation. +Use imgproxykit's dependency integration to inject a client into route +handlers and return image URLs and named variants in typed response models. + +Explore the `FastAPI tutorial `__ +and the `project on GitHub `__. Installation ------------ +In your FastAPI project, install imgproxykit with FastAPI support: + .. md-tab-set:: .. md-tab-item:: :iconify:`material-icon-theme:uv` uv @@ -35,7 +42,6 @@ the application. Keep the returned dependency for use in your routes: .. literalinclude:: /examples/integrations/fastapi/client.py :language: python - :lines: 1-28 .. code-annotations:: @@ -55,7 +61,6 @@ response model: .. literalinclude:: /examples/integrations/fastapi/variants.py :language: python - :lines: 1-40 .. code-annotations:: @@ -75,7 +80,6 @@ Register each client separately and use its returned dependency in .. literalinclude:: /examples/integrations/fastapi/multiple_clients.py :language: python - :lines: 1-44 .. code-annotations:: @@ -90,5 +94,9 @@ Unsigned URLs require an imgproxy server configured to accept them. Next steps ---------- +* `FastAPI documentation `__ + covers route handlers, dependencies, validation, and API schemas. +* `FastAPI on GitHub `__ + provides the source code, issues, and contribution guide. * :doc:`../user-guide/variants` shows how to generate several image URLs. * :doc:`../api-reference/fastapi` documents the registration function.