From 6fb16b0247cae44617da5a211080377382e85fdc Mon Sep 17 00:00:00 2001 From: Elias Hernandis Date: Thu, 26 Dec 2024 17:57:38 +0100 Subject: [PATCH] improve docs --- anchor/forms/fields.py | 6 +- anchor/forms/widgets.py | 4 +- .../commands/anchor_purge_unattached.py | 2 + anchor/models/attachment.py | 53 ++++- anchor/models/base.py | 10 +- anchor/models/blob/blob.py | 122 ++++++++++- anchor/models/blob/representations.py | 65 +++++- anchor/models/fields/single_attachment.py | 72 +++++-- anchor/models/variation.py | 45 ++++ anchor/services/processors/__init__.py | 15 ++ anchor/services/processors/base.py | 27 +++ anchor/services/processors/pillow.py | 37 ++++ anchor/services/transformers/__init__.py | 8 + anchor/services/transformers/base.py | 11 +- anchor/services/urls/__init__.py | 20 +- anchor/services/urls/base.py | 13 ++ anchor/services/urls/file_system.py | 17 +- anchor/services/urls/s3.py | 5 + anchor/settings.py | 7 +- anchor/support/base58.py | 3 +- anchor/support/signing.py | 5 + anchor/templatetags/anchor.py | 13 +- .../django_admin_attachments_inline.png.webp | Bin 62664 -> 0 bytes docs/api/forms.rst | 9 - docs/api/index.rst | 5 +- docs/api/models.rst | 47 ++++- docs/api/services/processors.rst | 7 + docs/api/services/transformers.rst | 7 + docs/api/services/urls.rst | 6 + docs/api/transformers.rst | 6 - docs/conf.py | 12 +- docs/configuration.rst | 17 ++ docs/getting_started.rst | 196 +++++++----------- docs/index.rst | 22 +- docs/installation.rst | 38 +++- tests/models/blob/test_representations.py | 3 +- 36 files changed, 721 insertions(+), 214 deletions(-) delete mode 100644 docs/_static/img/django_admin_attachments_inline.png.webp delete mode 100644 docs/api/forms.rst create mode 100644 docs/api/services/processors.rst create mode 100644 docs/api/services/transformers.rst create mode 100644 docs/api/services/urls.rst delete mode 100644 docs/api/transformers.rst create mode 100644 docs/configuration.rst diff --git a/anchor/forms/fields.py b/anchor/forms/fields.py index 2a66e93..0a5eb20 100644 --- a/anchor/forms/fields.py +++ b/anchor/forms/fields.py @@ -1,7 +1,7 @@ from django import forms -from .widgets import ClearableBlobInput +from .widgets import SingleAttachmentInput -class BlobField(forms.FileField): - widget = ClearableBlobInput +class SingleAttachmentField(forms.FileField): + widget = SingleAttachmentInput diff --git a/anchor/forms/widgets.py b/anchor/forms/widgets.py index 94ca94c..733bc4f 100644 --- a/anchor/forms/widgets.py +++ b/anchor/forms/widgets.py @@ -1,10 +1,10 @@ from django.forms.widgets import ClearableFileInput -class ClearableBlobInput(ClearableFileInput): +class SingleAttachmentInput(ClearableFileInput): # make this widget work with the Django admin choices = [] -class AdminBlobInput(ClearableBlobInput): +class AdminSingleAttachmentInput(SingleAttachmentInput): template_name = "anchor/widgets/admin_blob_input.html" diff --git a/anchor/management/commands/anchor_purge_unattached.py b/anchor/management/commands/anchor_purge_unattached.py index 10dcaec..48a400e 100644 --- a/anchor/management/commands/anchor_purge_unattached.py +++ b/anchor/management/commands/anchor_purge_unattached.py @@ -5,6 +5,8 @@ class Command(BaseCommand): + help = "Purge and delete blobs not used by any attachment" + def add_arguments(self, parser): parser.add_argument( "--dry-run", action="store_true", help="Dry run the command" diff --git a/anchor/models/attachment.py b/anchor/models/attachment.py index a4a781a..8fc1d81 100644 --- a/anchor/models/attachment.py +++ b/anchor/models/attachment.py @@ -1,17 +1,18 @@ from typing import Any from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.db import models from anchor.models.base import BaseModel -class AttachmentManager(models.Manager): - pass +class Attachment(BaseModel): + """ + An attachment relates your own models to Blobs. + Most properties are proxies to the Blob. + """ -class Attachment(BaseModel): class Meta: constraints = ( models.constraints.UniqueConstraint( @@ -26,8 +27,6 @@ class Meta: ), ) - objects = AttachmentManager() - blob = models.ForeignKey( "anchor.Blob", on_delete=models.PROTECT, @@ -36,37 +35,77 @@ class Meta: blank=True, verbose_name="blob", ) + """ + A reference to the Blob which stores the file for this attachment. + """ content_type = models.ForeignKey( - ContentType, + "contenttypes.ContentType", on_delete=models.CASCADE, verbose_name="content type", db_index=False, related_name="+", ) + """ + Content Type (as in Django content types) of the object this attachment + points to. + """ + object_id = models.CharField(max_length=64, verbose_name="object id") + """ + ID of the object this attachment points to. + """ + content_object = GenericForeignKey("content_type", "object_id") + """ + The actual object this attachment points to. + """ order = models.IntegerField(default=0, verbose_name="order") + """ + The order of this attachment in the list of attachments. + """ + name = models.CharField(max_length=256, default="attachments", verbose_name="name") + """ + The name of this attachment. + """ def __str__(self) -> str: return str(self.blob) def url(self, **kwargs): + """ + Returns a URL to the file's location in the storage backend. + """ return self.blob.url(**kwargs) @property def signed_id(self): + """ + Returns a signed ID for this attachment. + """ return self.blob.signed_id @property def filename(self): + """ + The filename of the file stored in the storage backend. + """ return self.blob.filename @property def is_image(self) -> bool: + """ + Whether the file is an image. + """ return self.blob.is_image def representation(self, transformations: dict[str, Any]): + """ + Returns a representation of the file. See :py:class:`the docs on + representations + ` for full + details. + """ return self.blob.representation(transformations) diff --git a/anchor/models/base.py b/anchor/models/base.py index 09a3c48..6ab4c03 100644 --- a/anchor/models/base.py +++ b/anchor/models/base.py @@ -2,7 +2,6 @@ from django.db import models from django.utils import timezone -from django.utils.translation import gettext_lazy as _ from anchor.support import base58 @@ -11,6 +10,11 @@ def generate_pk(): + """ + Generates a primary key with the same entropy as a UUID but encoded in + base58, which works much nicer with URLs than the default hex-based UUID + encoding. + """ return ( base58.b58encode(uuid.uuid4().bytes, alphabet=SHORT_UUID_ALPHABET) .decode("ascii") @@ -26,9 +30,7 @@ class BaseModel(models.Model): editable=False, default=generate_pk, ) - created_at = models.DateTimeField( - default=timezone.now, verbose_name=_("created at") - ) + created_at = models.DateTimeField(default=timezone.now, verbose_name="created at") class Meta: abstract = True diff --git a/anchor/models/blob/blob.py b/anchor/models/blob/blob.py index d81485c..ac5d30e 100644 --- a/anchor/models/blob/blob.py +++ b/anchor/models/blob/blob.py @@ -32,7 +32,7 @@ def unattached(self): Caution: the query generated by this method is expensive to compute. You might want to apply additional filters, for example on the blob - `created_at` field. + ``created_at`` field. """ return self.filter(attachments__isnull=True) @@ -49,6 +49,10 @@ def from_path(self, path: str, **kwargs): class Blob(RepresentationsMixin, BaseModel): + """ + Stores metadata for files stored by Django Anchor. + """ + KEY_LENGTH = 30 class Meta: @@ -60,21 +64,46 @@ class Meta: key = models.CharField( max_length=256, null=False, editable=False, verbose_name="key" ) + """ + A pointer to the file that the storage backend can understand. For instance + in file-system backends, this is a path to the file, relative to the + ``MEDIA_ROOT``. + """ + filename = models.CharField( max_length=256, verbose_name="original filename", null=True, default=None ) + """ + The original filename of the file as it was uploaded. + """ + mime_type = models.CharField( max_length=64, default=anchor_settings.DEFAULT_MIME_TYPE, verbose_name="MIME type", editable=False, ) + """ + A MIME type for the file derived from the extension. + + MIME types are used to determine how files should be served: ``image/*`` + are served inline, while other types are served as attachments for security. + """ + backend = models.CharField( max_length=64, default="default", verbose_name="backend", editable=False, ) + """ + The name of the storage backend as defined in ``settings.STORAGES`` where + the file is stored. + + This property is just the name of the backend. To get a storage instance + with the backend's configuration see :py:attr:`storage`. + """ + byte_size = models.PositiveBigIntegerField( verbose_name="size", help_text="size in bytes", @@ -83,6 +112,11 @@ class Meta: default=None, editable=False, ) + """ + Size of the file in bytes, calculated automatically when the file is + uploaded. + """ + checksum = models.CharField( max_length=256, null=True, @@ -90,12 +124,21 @@ class Meta: verbose_name="checksum", editable=False, ) + """ + The MD5 checksum of the file. + """ + metadata = models.JSONField( null=True, blank=True, default=dict, verbose_name="metadata", ) + """ + Arbitrary metadata file, e.g. including the ``width`` and ``height`` of an image. + + If you need to store custom metadata, refer to the :py:attr:`custom_metadata` property. + """ upload_to: str | Callable[[Self], str] | None = None @@ -119,6 +162,9 @@ def __init__(self, *args, upload_to=None, backend=None, **kwargs): @property def signed_id(self): + """ + A signed ID of the Blob, used to generate URLs. + """ return self.get_signed_id() def get_signed_id( @@ -127,6 +173,23 @@ def get_signed_id( expires_in: timezone.timedelta = None, expires_at: timezone.datetime = None, ): + """ + Returns a signed ID of the blob. + + Signed IDs can be shared with users securely because they can include an + expiration. + + Args: + purpose (str, optional): A string identifying the purpose of the signed ID. + Used to restrict usage of the signed ID to specific contexts. + expires_in (timezone.timedelta, optional): Time duration after which the + signed ID will expire. + expires_at (timezone.datetime, optional): Specific datetime at which the + signed ID will expire. + + Returns: + str: A signed ID string that can be used to securely reference this blob. + """ return ( type(self) ._get_signer() @@ -137,6 +200,20 @@ def get_signed_id( @classmethod def unsign_id(cls, signed_id: str, purpose: str = None): + """ + Un-signs a signed ID of a blob. + + Args: + signed_id (str): The signed ID to un-sign. + purpose (str, optional): The purpose with which the signed ID was created. + + Raises: + BadSignature: If the signature is invalid, has expired, or was signed + with a different purpose. + + Returns: + str: The key of the blob. + """ return cls._get_signer().unsign(signed_id, purpose) @classmethod @@ -192,7 +269,10 @@ def guess_mime_type(self, file: DjangoFile): return anchor_settings.DEFAULT_MIME_TYPE def calculate_checksum(self, file: DjangoFile) -> str: - """Computes the MD5 hash of the given file.""" + """ + Computes the MD5 hash of the given file and returns it as a (URL-safe) + base64-encoded string. + """ m = hashlib.md5() for c in file.chunks(chunk_size=1024): if isinstance(c, str): @@ -204,9 +284,24 @@ def calculate_checksum(self, file: DjangoFile) -> str: @property def storage(self) -> Storage: + """ + The Django storage backend where the file is persisted. + + Instantiated by looking up the configuration in ``settings`` for this + object's :py:attr:`backend`. + """ return storages.create_storage(storages.backends[self.backend]) def generate_key(self): + """ + Generates a random key to store this blob in the storage backend. + + Keys are hard to guess, but shouldn't be shared directly with users, + preferring to use :py:attr:`signed_id` instead, which can be expired. + + They use the base32 encoding to ensure no compatibility issues arise in + case insensitive file systems. + """ return ( base64.b32encode(token_bytes(self.KEY_LENGTH)) .decode("utf-8") @@ -215,6 +310,14 @@ def generate_key(self): ) def url(self, expires_in: timezone.timedelta = None, disposition: str = "inline"): + """ + Returns a URL to the file's location in the storage backend. + + Depending on the backend, this URL can be permanent (like in + file-system-based backends). Instead of sharing this URL directly, refer + to the :py:func:`blob_url ` + template tag to generate a signed, expiring URL. + """ return self.url_service.url( self.key, expires_in=expires_in, disposition=disposition ) @@ -224,17 +327,32 @@ def url_service(self): return get_for_backend(self.backend) def open(self, mode="rb"): + """ + Opens the file from the storage backend. + + This method might involve a download if the storage backend is not + local. It can be used as a context manager. + """ return self.storage.open(self.key, mode) @property def is_image(self): + """ + Whether the file is an image. + """ return self.mime_type.startswith("image/") def purge(self): + """ + Deletes the file from the storage backend. + """ self.storage.delete(self.key) @property def custom_metadata(self): + """ + Arbitrary metadata you want to store for this blob. + """ return (self.metadata or {}).get("custom", {}) @custom_metadata.setter diff --git a/anchor/models/blob/representations.py b/anchor/models/blob/representations.py index f21dffb..b682903 100644 --- a/anchor/models/blob/representations.py +++ b/anchor/models/blob/representations.py @@ -4,7 +4,55 @@ from anchor.settings import anchor_settings +class NotRepresentableError(ValueError): + """ + Raised when attempting to create a variant or preview which is not + compatible with the original file. + """ + + pass + + class RepresentationsMixin: + """ + Adds methods to the :py:class:`Blob ` model to + generate representations of files. + + Image files (those with a MIME type starting with ``image/``) can be varied + by applying a series of transformations like resizing them or changing their + format. + + Other kinds of files do not admit the same transformations as images, but + can still be represented as an image, for instance, by generating a + thumbnail of the first page of a PDF or a frame of a video. + + Previews are not yet implemented, but the :py:meth:`representation` method + is designed to wrap both and decide internally if it should call + :py:meth:`variant` or :py:meth:`preview` depending on the kind of file. + + If you call :py:meth:`representation` on a file that is not representable + (e.g. trying to resize a SVG file), a :py:exc:`NotRepresentableError` will + be raised. + """ + + def representation(self, transformations: dict[str, Any]): + """ + Returns a :py:class:`Variant ` or ``Preview`` object wrapping the + result of applying the given ``transformations`` to this file. + """ + if self.is_variable: + return self.variant(transformations) + else: + return self.preview() + + @property + def is_representable(self) -> bool: + """ + Checks whether the file can represented by an image or, if it is already + an image, whether it can be transformed. + """ + return self.is_variable or self.is_previewable + def variant(self, transformations: dict[str, Any]): if not self.is_variable: raise ValueError("Cannot transform non-variable Blob") @@ -17,16 +65,6 @@ def variant(self, transformations: dict[str, Any]): def is_variable(self) -> bool: return self.mime_type.startswith("image/") - def representation(self, transformations: dict[str, Any]): - if self.is_variable: - return self.variant(transformations) - - raise ValueError("Cannot represent non-variable Blob") - - @property - def is_representable(self) -> bool: - return self.is_variable - @property def default_variant_transformations(self) -> dict[str, Any]: return {"format": anchor_settings.DEFAULT_VARIANT_FORMAT} @@ -39,3 +77,10 @@ def variant_class(self): if anchor_settings.TRACK_VARIANTS: return VariantWithRecord return Variant + + @property + def is_previewable(self): + return False + + def preview(self): + raise NotImplementedError() diff --git a/anchor/models/fields/single_attachment.py b/anchor/models/fields/single_attachment.py index 0cca1a9..2b263cb 100644 --- a/anchor/models/fields/single_attachment.py +++ b/anchor/models/fields/single_attachment.py @@ -12,8 +12,27 @@ class SingleAttachmentRel(GenericRel): + """ + Holds information about the relation between an Attachment and the Model + where the SingleAttachmentField is defined. + """ + + field: "SingleAttachmentField" + + def __init__(self, field: "SingleAttachmentField"): + self.field = field + super().__init__( + field=field, + to="anchor.Attachment", + related_name="+", + related_query_name="+", + ) + @cached_property def cache_name(self): + # Use the name given to the field in the model instance to avoid + # collisions when there are multiple SingleAttachmentFields defined on + # the same instance return self.field.attname @@ -37,6 +56,9 @@ def __get__(self, instance, cls=None) -> Attachment | None: return None def __set__(self, instance, value): + # Be compatible with how Django handles files in the forms API. A + # None value signifies that the file was not updated, while a False + # value signifies that the file should be deleted. if value is None: return @@ -66,10 +88,11 @@ def __set__(self, instance, value): ) Attachment.objects.update_or_create( - object_id=instance.id, - content_type=ContentType.objects.get_for_model(instance), - name=self.name, - order=0, + # object_id=instance.id, + # content_type=ContentType.objects.get_for_model(instance), + # name=self.name, + # order=0, + **self.related.field.get_forward_related_filter(instance), defaults={"blob": blob}, ) @@ -119,6 +142,35 @@ def instance_attr(i): class SingleAttachmentField(GenericRelation): + """ + Enables a model to hold a single attachment. + + When accessing this field, you'll get an :py:class:`Attachment + ` instance. To set the file attachment, + you can assign a plain file-like object, a :py:class:`Blob + ` instance or an :py:class:`Attachment + ` instance. + + Example: + A SingleAttachmentField allows attaching a single file to a model + instance: + + >>> from django.db import models + >>> from anchor.models.fields import SingleAttachmentField + >>> + >>> class Movie(models.Model): + ... title = models.CharField(max_length=100) + ... cover = SingleAttachmentField( + ... upload_to="movie-covers", + ... help_text="A colorful image of the movie." + ... ) + ... + >>> movie = Movie.objects.create(title="The Matrix") + >>> movie.cover = uploaded_file # Attach a file + >>> movie.cover.url() # Get URL to original file + '/media/movie-covers/matrix-cover.jpg' + """ + rel_class = SingleAttachmentRel def __init__( @@ -141,13 +193,7 @@ def __init__( kwargs["from_fields"] = [] kwargs["serialize"] = False - self.rel = self.rel_class( - self, - to="anchor.Attachment", - related_name="+", - related_query_name="+", - limit_choices_to=None, - ) + self.rel = self.rel_class(field=self) # Bypass the GenericRelation constructor to be able to set editable=True super(GenericRelation, self).__init__( @@ -170,7 +216,7 @@ def contribute_to_class(self, cls: type[Model], name: str, **kwargs) -> None: ) def formfield(self, **kwargs): - from anchor.forms.fields import BlobField + from anchor.forms.fields import SingleAttachmentField super().formfield(**kwargs) defaults = { @@ -180,7 +226,7 @@ def formfield(self, **kwargs): } defaults.update(kwargs) - return BlobField(**defaults) + return SingleAttachmentField(**defaults) def get_forward_related_filter(self, obj): return { diff --git a/anchor/models/variation.py b/anchor/models/variation.py index 54b57eb..4c0daec 100644 --- a/anchor/models/variation.py +++ b/anchor/models/variation.py @@ -11,6 +11,10 @@ class Variation: + """ + Represents a (set of) transformations of an image file. + """ + PURPOSE = "variation" transformations: dict[str, Any] @@ -19,6 +23,12 @@ def __init__(self, transformations: dict[str, Any]): @classmethod def wrap(cls, value: str | dict[str, Any] | Self) -> Self: + """ + Returns a variation object, either by decoding a variation key or by + wrapping a dictionary of transformations. + + If the argument is already a variation object, it is returned as is. + """ if isinstance(value, cls): return value elif isinstance(value, str): @@ -27,25 +37,49 @@ def wrap(cls, value: str | dict[str, Any] | Self) -> Self: return cls(value) def default_to(self, default_transformations: dict[str, Any]) -> None: + """ + Updates the keys missing from this object's ``transformations`` with the + given ``default_transformations``. + """ self.transformations = {**default_transformations, **self.transformations} @property def key(self) -> str: + """ + Returns a variation key for this object. + + Keys are signed to ensure they are not tampered with, but they are + permanent so they should be shared with care. + """ return type(self).encode(self.transformations) @property def digest(self) -> str: + """ + A hash of the transformations dictionary in this variation. + + This is a good way to check if two variations are the same, but keep in + mind that order is important in transformations, so two Variations with + the same transformations in different orders will have different + digests. + """ m = hashlib.sha1() m.update(json.dumps(self.transformations).encode("utf-8")) return b58encode(m.digest()).decode("utf-8") @classmethod def decode(cls, key: str) -> Self: + """ + Decodes a signed variation key and returns a Variation. + """ transformations = cls._get_signer().unsign(key, purpose=cls.PURPOSE) return cls(transformations) @classmethod def encode(cls, transformations: dict[str, Any]) -> str: + """ + Encodes a transformations dictionary as a signed string. + """ return cls._get_signer().sign(transformations, purpose=cls.PURPOSE) @classmethod @@ -62,12 +96,23 @@ def transform(self, file): @property def transformer(self) -> BaseTransformer: + """ + Returns the transformer to be used to perform the transformations in + this variation. + """ return ImageTransformer( {k: v for (k, v) in self.transformations.items() if k != "format"} ) @property def mime_type(self) -> str: + """ + Returns the MIME type that the result of applying this variation to an + image file will have. + + It is determined from the ``format`` transformation: e.g. specifying + ``'format': 'webp'`` will return ``'image/webp'``. + """ random_filename = f"random.{self.transformations['format']}" return ( mimetypes.guess_type(random_filename)[0] diff --git a/anchor/services/processors/__init__.py b/anchor/services/processors/__init__.py index e69de29..0554a21 100644 --- a/anchor/services/processors/__init__.py +++ b/anchor/services/processors/__init__.py @@ -0,0 +1,15 @@ +""" +Processors hold the implementation details of how to transform or preview files. + +They are called by :py:mod:`transformers ` with a +source file and are expected to produce an output after applying +transformations. + +Check out the interface for processors in :py:class:`BaseProcessor` and an +implementation example in :py:class:`PillowProcessor`. +""" + +from .base import BaseProcessor +from .pillow import PillowProcessor + +__all__ = ["PillowProcessor", "BaseProcessor"] diff --git a/anchor/services/processors/base.py b/anchor/services/processors/base.py index 859d585..09424ff 100644 --- a/anchor/services/processors/base.py +++ b/anchor/services/processors/base.py @@ -1,6 +1,33 @@ class BaseProcessor: + """ + Interface for file processors. + + A file processor must implement the :py:meth:`source` method and + :py:meth:`save` methods outlined below at the minimum. + + For convenience, the :py:meth:`source` method returns the processor itself, + so you can chain methods together. Check out the :py:class:`PillowProcessor + ` for an example + implementation. + """ + def source(self, file): + """ + Set up the processor to work with the given file. + + The `file` passed is a file-like object that responds to ``read``, like + a ``DjangoFile`` or an ``io`` stream. + """ raise NotImplementedError() def save(self, file, format: str): + """ + Save the processed file to the given file-like object. + + The `file` passed is a file-like object that responds to ``write``, like + a ``TemporaryFile`` or an ``io`` stream. + + The `format` is a string representing the file format to save the file + as, like ``'jpeg'`` or ``'png'``. + """ raise NotImplementedError() diff --git a/anchor/services/processors/pillow.py b/anchor/services/processors/pillow.py index 935b2a6..40f2fe6 100644 --- a/anchor/services/processors/pillow.py +++ b/anchor/services/processors/pillow.py @@ -4,14 +4,51 @@ class PillowProcessor(BaseProcessor): + """ + A file processor that uses the `Pillow `_ + library to transform images. + + To use this processor, make sure to install the Pillow library: ``pip + install Pillow``. + """ + def source(self, file): self.source = Image.open(file) return self def resize_to_fit(self, width: int, height: int): + """ + Resize the image to fit within the given width and height, preserving + aspect ratio. + + Aspect ratio is maintained, so the final image dimensions may be smaller + than the provided rectangle. If the image is smaller than the provided + dimensions, it will be upscaled. + """ + self.source.thumbnail((width, height)) + return self + + def resize_to_limit(self, width: int, height: int): + """ + Downsize the image to fit within the given width and height, preserving + aspect ratio. + + If the image is already smaller than the provided dimensions, nothing is + done. + """ + if self.source.width <= width and self.source.height <= height: + return self + self.source.thumbnail((width, height)) return self + def rotate(self, degrees: int): + """ + Rotates the image by the given angle. + """ + self.source.rotate(degrees) + return self + def save(self, file, format: str): self.source.save(file, format=format) return self diff --git a/anchor/services/transformers/__init__.py b/anchor/services/transformers/__init__.py index 1ac2f70..f636c01 100644 --- a/anchor/services/transformers/__init__.py +++ b/anchor/services/transformers/__init__.py @@ -1,3 +1,11 @@ +""" +Transformers expose a uniform API to apply transformations to files. + +They are called by :py:class:`variations ` +and delegate actual transformations to :py:mod:`processors +`. +""" + from .base import BaseTransformer from .image import ImageTransformer diff --git a/anchor/services/transformers/base.py b/anchor/services/transformers/base.py index 64b0932..3a4a7de 100644 --- a/anchor/services/transformers/base.py +++ b/anchor/services/transformers/base.py @@ -4,6 +4,12 @@ class BaseTransformer: + """ + Interface for a transformer. + + Subclasses need to implement :py:meth:`process`. + """ + transformations: dict[str, Any] def __init__(self, transformations: dict[str, Any]): @@ -22,7 +28,10 @@ def transform(self, file, format: str): output.close() def process(self, file, format: str): - """Returns an open temporary file with the transformed image.""" + """ + Given a buffer and an output format, returns an open temporary file with + the resulting file after applying the transformations in this object. + """ raise NotImplementedError() def _get_temporary_file(self, format: str): diff --git a/anchor/services/urls/__init__.py b/anchor/services/urls/__init__.py index 5a6eb8f..b11fdba 100644 --- a/anchor/services/urls/__init__.py +++ b/anchor/services/urls/__init__.py @@ -1,12 +1,26 @@ from django.core.files.storage import storages -from anchor.services.urls.file_system import FileSystemURLGenerator -from anchor.services.urls.s3 import S3URLGenerator - from .base import BaseURLGenerator +from .file_system import FileSystemURLGenerator +from .s3 import S3URLGenerator + +__all__ = [ + "BaseURLGenerator", + "FileSystemURLGenerator", + "S3URLGenerator", + "get_for_backend", +] def get_for_backend(backend: str) -> BaseURLGenerator: + """ + Given a Django storage backend name, return a ``URLGenerator`` instance that + can work with it. + + If no suitable generator is found, this function will return the + :py:class:`BaseURLGenerator` which delegates URL generation to the storage + backend. + """ backend_config = storages.backends[backend] if backend_config["BACKEND"] == "django.core.files.storage.FileSystemStorage": return FileSystemURLGenerator(backend) diff --git a/anchor/services/urls/base.py b/anchor/services/urls/base.py index df02b88..2869cea 100644 --- a/anchor/services/urls/base.py +++ b/anchor/services/urls/base.py @@ -3,6 +3,16 @@ class BaseURLGenerator: + """ + Generates (signed) URLs for blobs. + + URLGenerators are a workaround against the limitations of Django's Storage + interface, which doesn't support passing parameters for URL generation. + These allow us to set parameters on URLs such as the content disposition + which are necessary to ensure external backends like S3 can serve blobs + securely. + """ + backend: str storage: Storage @@ -18,4 +28,7 @@ def url( disposition: str = "inline", filename: str = None, ): + """ + Generate a signed URL for the given storage key. + """ return self.storage.url(key) diff --git a/anchor/services/urls/file_system.py b/anchor/services/urls/file_system.py index 9c6ed8f..a0aea3e 100644 --- a/anchor/services/urls/file_system.py +++ b/anchor/services/urls/file_system.py @@ -7,6 +7,15 @@ class FileSystemURLGenerator(BaseURLGenerator): + """ + Generate signed and expiring URLs for files stored in file-system storage + backends. + + This generator exists so that we can have more control over how files stored + via Django's default FileSystemBackend are served by making requests go + through our file system view. + """ + def url( self, name: str, @@ -16,9 +25,7 @@ def url( disposition: str = None, ) -> str: key = self.get_key(name, mime_type=mime_type, disposition=disposition) - signed_key = AnchorSigner().sign( - key, expires_in=expires_in, purpose="file_system" - ) + signed_key = self.signer.sign(key, expires_in=expires_in, purpose="file_system") kwargs = {"signed_key": signed_key} if filename: kwargs["filename"] = filename @@ -29,3 +36,7 @@ def get_key(self, name: str, **kwargs) -> str: if kwargs: key.update(kwargs) return key + + @property + def signer(self) -> AnchorSigner: + return AnchorSigner() diff --git a/anchor/services/urls/s3.py b/anchor/services/urls/s3.py index ff036e9..03aaf8c 100644 --- a/anchor/services/urls/s3.py +++ b/anchor/services/urls/s3.py @@ -4,6 +4,11 @@ class S3URLGenerator(BaseURLGenerator): + """ + URL Generator for use with the S3Backend from the ``django-storages`` + package. + """ + def url( self, key: str, diff --git a/anchor/settings.py b/anchor/settings.py index 5278042..aa45b36 100644 --- a/anchor/settings.py +++ b/anchor/settings.py @@ -16,7 +16,8 @@ class AnchorSettings: DEFAULT_MIME_TYPE: str = "application/octet-stream" """ - The default MIME type to use for Blobs when it cannot be guessed from the file extension. + The default MIME type to use for files when it cannot be guessed from the + file extension. """ FILE_SYSTEM_BACKEND_EXPIRATION: timedelta = timedelta(hours=1) @@ -39,6 +40,10 @@ class AnchorSettings: The default format to use for variants. """ + def __init__(self, *args, **kwargs): + # Hide constructor signature from the docs + super().__init__(*args, **kwargs) + def __getattribute__(self, name: str) -> Any: user_settings = getattr(settings, ANCHOR_SETTINGS_NAME, {}) return user_settings.get(name, super().__getattribute__(name)) diff --git a/anchor/support/base58.py b/anchor/support/base58.py index 4f41d0d..a6c2e98 100644 --- a/anchor/support/base58.py +++ b/anchor/support/base58.py @@ -1,5 +1,6 @@ """ -Base58 encoding adapted from the base58 package by David Keijser released under the MIT license. +Base58 encoding adapted from the base58 package by David Keijser released under +the MIT license. See https://github.com/keis/base58 for full credits. """ diff --git a/anchor/support/signing.py b/anchor/support/signing.py index abcde11..f446ab7 100644 --- a/anchor/support/signing.py +++ b/anchor/support/signing.py @@ -1,3 +1,8 @@ +""" +Extends Django's signing module to make it easier to work with object keys and +key expirations. +""" + import base64 from typing import Any, Type diff --git a/anchor/templatetags/anchor.py b/anchor/templatetags/anchor.py index 0811931..152ab42 100644 --- a/anchor/templatetags/anchor.py +++ b/anchor/templatetags/anchor.py @@ -1,3 +1,8 @@ +""" +Blob URLs should not be exposed directly to users. Instead, use signed, +short-lived URLs generated with these functions. +""" + from typing import Any from django import template @@ -11,6 +16,9 @@ @register.simple_tag def blob_url(value: Blob | Attachment | None): + """ + Return a signed URL for the given Attachment or Blob. + """ if value is None: return "" @@ -21,7 +29,10 @@ def blob_url(value: Blob | Attachment | None): @register.simple_tag -def variant_url(value: Variant | Blob | Attachment | None, **transformations): +def representation_url(value: Variant | Blob | Attachment | None, **transformations): + """ + Return a signed URL for a transformation of the given Attachment or Blob. + """ if value is None: return "" diff --git a/docs/_static/img/django_admin_attachments_inline.png.webp b/docs/_static/img/django_admin_attachments_inline.png.webp deleted file mode 100644 index 360319d8c1a61a4d6fe60e6710257205fb714116..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 62664 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 which take care of storing a + pointer to each file uploaded plus some metadata, and +- :py:class:`Attachments ` which act as a + bridge between your own models and :py:class:`Blobs + `, allowing you to attach files to your own + records. + +These, together with the :py:class:`VariantRecord +` are the only models backed by a +database table. The rest of the classes within the ``anchor.models`` module only +contain business logic. + +.. autoclass:: anchor.models.blob.blob.Blob + :members: + +.. autoclass:: anchor.models.attachment.Attachment + :members: + +Fields +------ + +Model fields are how you allow your models to hold files. + +.. autoclass:: anchor.models.fields.SingleAttachmentField + + +Representations +--------------- + +.. automodule:: anchor.models.blob.representations + :members: + +.. automodule:: anchor.models.variant + :members: + +.. automodule:: anchor.models.variant_record :members: -.. automodule:: anchor.models.blob +.. automodule:: anchor.models.variant_with_record :members: -.. automodule:: anchor.models.fields +.. automodule:: anchor.models.variation :members: diff --git a/docs/api/services/processors.rst b/docs/api/services/processors.rst new file mode 100644 index 0000000..009dc81 --- /dev/null +++ b/docs/api/services/processors.rst @@ -0,0 +1,7 @@ +========== +Processors +========== + +.. automodule:: anchor.services.processors + :members: + :show-inheritance: diff --git a/docs/api/services/transformers.rst b/docs/api/services/transformers.rst new file mode 100644 index 0000000..646a27c --- /dev/null +++ b/docs/api/services/transformers.rst @@ -0,0 +1,7 @@ +============ +Transformers +============ + +.. automodule:: anchor.services.transformers + :members: + :show-inheritance: diff --git a/docs/api/services/urls.rst b/docs/api/services/urls.rst new file mode 100644 index 0000000..8952029 --- /dev/null +++ b/docs/api/services/urls.rst @@ -0,0 +1,6 @@ +==== +URLs +==== + +.. automodule:: anchor.services.urls + :members: diff --git a/docs/api/transformers.rst b/docs/api/transformers.rst deleted file mode 100644 index ae56907..0000000 --- a/docs/api/transformers.rst +++ /dev/null @@ -1,6 +0,0 @@ -============================= -Transformers -============================= - -.. automodule:: anchor.transformers - :members: diff --git a/docs/conf.py b/docs/conf.py index 55b749b..231f386 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,22 +1,22 @@ import os import sys -import toml + import django project = "Django Anchor" copyright = "2024, Elias Hernandis" author = "Elias Hernandis" -# Read version from pyproject.toml -with open("../pyproject.toml", "r") as f: - pyproject = toml.load(f) - release = pyproject["tool"]["poetry"]["version"] # Make source available for autodoc -os.environ["DJANGO_SETTINGS_MODULE"] = "tests.test_settings" +os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" sys.path.insert(0, os.path.abspath("..")) django.setup() +import anchor # noqa: E402 + +release = anchor.__version__ + # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 0000000..7359d6b --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,17 @@ +============= +Configuration +============= + +Django Anchor exposes several settings which can be changed to tune the +application to your needs. Define these settings under ``ANCHOR`` in your project +settings like this: + +.. code-block:: python + + ANCHOR = { + 'DEFAULT_STORAGE_BACKEND': 'my_storage_backend', + } + + +.. autoclass:: anchor.settings.AnchorSettings + :members: diff --git a/docs/getting_started.rst b/docs/getting_started.rst index d7f4c15..2442c9e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -5,44 +5,72 @@ Getting Started Installation ============ -Django Anchor is installed just like any other Django package: add the package -as a dependency and then add ``anchor`` to your ``INSTALLED_APPS`` setting. +Django Anchor is installed just like any other Django package: -Check out the :doc:`installation guide ` for more details. +- add the package as a dependency +- add ``anchor`` to your ``INSTALLED_APPS`` setting +- add ``anchor.urls`` to your URL config +- run ``python manage.py migrate`` to create the necessary tables + +Check out the :doc:`installation guide ` for more details, +including how to install the optional dependencies for image transformations. Introduction ============ -Django Anchor allows you to add files to your Django models. It is intended to -replace Django FileFields and ImageFields and provide a bit more functionality. -Files can be attached in one of two ways: +Django Anchor allows you to add files to your Django models. It is essentially a +clone for Ruby on Rails' ActiveStorage ported to Python and following the +conventions of the Django ecosystem. Django Anchor replaces Django's ``FileField`` +and ``ImageField`` and enhances them with a few features: -- **Attaching a single file** by adding an - :py:class:`anchor.models.fields.BlobField` to your model. -- **Attaching multiple files** by creating - :py:class:`anchor.models.attachment.Attachment` objects which link - :py:class:`anchor.models.blob.Blob` objects to your model objects via a generic relationship. +- Allows generating and serving variants of image files. You can dynamically + resize and otherwise transform images to serve optimized versions straight + from a template tag. +- Allows generating and serving signed URLs for files, even when using the + default file-system storage backend. +- Removes the need for additional columns in models to store references to + files. Instead, Django Anchor tracks file attachments using a generic + relationship. Single File Attachments ======================= -We'll focus on adding single objects first, building on the example from the -demo project. Let's say you have a ``Movie`` model and want to upload cover -images. First, add a ``BlobField`` to your model: +Let's say you have a ``Movie`` model and want to upload cover images. First, add +a :py:class:`SingleAttachmentField ` +to your model: .. code-block:: python from django.db import models - from anchor.models.fields import BlobField + from anchor.models.fields import SingleAttachmentField class Movie(models.Model): title = models.CharField(max_length=100) - cover = BlobField() + cover = SingleAttachmentField() + +That's pretty much it! No need to run ``makemigrations`` or ``migrate`` since +Django Anchor doesn't actually need any columns added to the model. + +The ``cover`` field works just like any other model field: + +.. code-block:: python -That's pretty much it! Just like with any other model field, you need to run -``python manage.py makemigrations`` and ``python manage.py migrate`` to create -the migration and apply changes to the database. + # Create a new movie + movie = Movie.objects.create(title="My Movie") + + # Attach an uploaded file + movie.cover = uploaded_file + + # Get a URL to the file + movie.cover.url() + + # Get a URL to a miniature version of the file + movie.cover.representation(resize_to_fit=(200, 200), format="webp").url() + + # Delete the file + movie.cover.purge() + movie.cover.delete() Rendering attachments in templates ================================== @@ -50,9 +78,9 @@ Rendering attachments in templates One of the core functionalities of Django Anchor is the ability to render versions of the original file attached to a model that are optimized for a particular size or converted to another format. This is done using the -:py:func:`blob_thumbnail ` template tag, which takes a -``Blob`` object as the first argument and optional format parameters to build a -variant. +:py:func:`representation_url ` +template tag, which takes an ``Attachment`` object as the first argument and +optional format parameters to build a variant. Let's say you want to render a grid of Movie cover thumbnails in a list view. Your template could look something like this: @@ -63,23 +91,21 @@ Your template could look something like this:
    {% for movie in movies %}
  • - {{ movie.title }} + {{ movie.title }}

    {{ movie.title }}

  • {% endfor %}
-Using BlobFields in forms -========================= +Using SingleAttachmentFields in forms +===================================== -Django Anchor includes a form field for handling file uploads that is designed -to be compatible with Django's FileField. It will be used by default if you use -model forms and you can add it to your own forms as well: +Django Anchor file fields work out of the box with Django's form system. .. code-block:: python from django import forms - from anchor.forms.fields import BlobField + from anchor.forms.fields import SingleAttachmentField class MovieForm(forms.ModelForm): class Meta: @@ -90,112 +116,32 @@ model forms and you can add it to your own forms as well: class MovieForm(forms.Form): title = forms.CharField(max_length=100) - cover = BlobField() - - -Multiple File Attachments -========================= - -If you need to attach a list any number of files to a model you need to use the -:py:class:`Attachment ` model. This model -links Blob objects to your model objects via a `generic Django relations -`_. - -To do so, just create Attachment objects for each file you want to attach like -this: - -.. code-block:: python - - from django.db import models - from anchor.models.attachment import Attachment - - movie = Movie.objects.create(title='My Movie') - scene1_attachment = Attachment.objects.create( - blob=Blob.objects.from_path('path/to/scene1.jpg'), - content_object=movie, - name='scenes', - order=0, - ) - scene2_attachment = Attachment.objects.create( - blob=Blob.objects.from_path('path/to/scene2.jpg'), - content_object=movie, - name='scenes', - order=1, - ) - scene3_attachment = Attachment.objects.create( - blob=Blob.objects.from_path('path/to/scene3.jpg'), - content_object=movie, - name='scenes', - order=2, - ) - -The ``name`` field is used to group attachments together. It doesn't need to be -set (it defaults to ``'attachments'``) but is helpful if you have multiple -collections of files that you want to attach to a model. - -The ``order`` field is used to sort the attachments in the order you want them -and must be unique for each attachment in the same collection (with the same -``name`` and ``content_object``). - -Django attachments doesn't provide a default way to generate the reverse generic -relationship but you can do so yourself by adding a property to your model: - -.. code-block:: python - - from django.db import models - from anchor.models.attachment import Attachment + cover = SingleAttachmentField() - class Movie(models.Model): - title = models.CharField(max_length=100) - @property - def scenes(self): - return ( - Attachment.objects.select_related("blob") - .filter_by_object(self, name="scenes") - .order_by("order") - .all() - ) +Admin integration +================= -Keep in mind that this is not optimal if you're loading multiple movies at the -same time (since it'll perform N+1 queries). Suggestions on how to improve this -or make some standard way within the Django Anchor package are always welcome! - -To render attachments in templates, you can use the same -:py:func:`blob_thumbnail ` template -tag as with BlobFields: +Django Anchor nicely integrates with the Django admin, just like File fields do. -.. code-block:: html+django +.. image:: _static/img/django_admin_default_widget.png.webp + :alt: Django Anchor admin widget for SingleAttachmentFields - {% load anchor %} -
    - {% for attachment in movie.scenes %} -
  • - {{ movie.title }} -
  • - {% endfor %} -
+You can get a preview of the attached file by overriding the form field for +the ``SingleAttachmentField`` model field with a widget that renders a thumbnail: -Admin integration -================= +.. code-block:: python -Django Anchor provides a custom admin widget for BlobFields that allows you to -use them like normal FileFields in the Django admin: + from anchor.forms.widgets import AdminSingleAttachmentInput -.. image:: _static/img/django_admin_default_widget.png.webp - :alt: Django Anchor admin widget for BlobFields + class MovieAdmin(admin.ModelAdmin): + formfield_overrides = { + SingleAttachmentField: {'widget': AdminSingleAttachmentInput}, + } -If you inherit from :py:class:`anchor.admin.BlobAdminMixin` in your admin class, -you'll get a widget with a preview of the file instead of a link to the File: +That makes the admin widget look like this: .. image:: _static/img/django_admin_thumbnail_widget.png.webp - :alt: Django Anchor admin widget for BlobFields with preview - -Django Anchor also provides an admin inline to manage attachments. Add -:py:class:`anchor.admin.AttachmentInline` to your model admin to manage -attachments in the Django admin: - -.. image:: _static/img/django_admin_attachments_inline.png.webp - :alt: Django Anchor admin inline for Attachment objects + :alt: Django Anchor admin widget for SingleAttachmentFields with preview diff --git a/docs/index.rst b/docs/index.rst index 0144819..8cf7a40 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,8 +1,26 @@ -Welcome to the Django Anchor documentation! +============= +Django Anchor +============= + +Django Anchor is a reusable Django app that allows you to attach files to models. + +Anchor works very similarly to Django's ``FileField`` and ``ImageField`` model +fields, but adds a few extra features: + +- Images can be resized, converted to another format and otherwise transformed. +- Files are served through signed URLs that can expire after a configurable + amount of time, even when using the default file-system storage backend. + +Django Anchor is essentially a port of the excellent `Active Storage +`_ Ruby on +Rails feature, but leveraging existing Django abstractions and packages of the +Python ecosystem. Some features are not yet implemented, but the core concepts +are there two eventually be able to support them. Table of contents ================= .. toctree:: - installation getting_started + installation + configuration api/index diff --git a/docs/installation.rst b/docs/installation.rst index aa61457..6b6e776 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -10,24 +10,44 @@ Installation or by adding ``django-anchor`` to your ``requirements.txt`` or ``pyproject.toml`` files if you have one. + If you intend to transform images you'll also need to install ``Pillow``. See + below for more details. + 2. Add ``anchor`` to ``settings.INSTALLED_APPS`` 3. Add URL configuration to your project: - ```python + .. code-block:: python + + urlpatterns = [ + path('anchor/', include('anchor.urls')), + ] - urlpatterns = [ - path('anchor/', include('anchor.urls')), - ] - ``` 4. Run migrations: - ```bash - python manage.py migrate - ``` + .. code-block:: bash + + python manage.py migrate -By default, anchor works with your default storage backend. If you want to use a + +By default, Anchor works with your ``default`` storage backend. If you want to use a different storage backend, define it under ``STORAGES`` in your project settings and set ``ANCHOR['DEFAULT_STORAGE_BACKEND']`` to the name of your preferred storage backend. + +Check out the full configuration options in :doc:`configuration `. + + +Additional dependencies for image transformations +================================================= + +Using Anchor to generate representations of images requires the ``Pillow`` +Python package. You can add it to your dependencies, and there's no need to add +anything to the ``INSTALLED_APPS`` setting. + +Keep in mind that while Pillow is a powerful library with lots of available +operations, Anchor only supports a handful of them. Check out the +:py:class:`PillowProcessor documentation +` for a list of supported +operations. diff --git a/tests/models/blob/test_representations.py b/tests/models/blob/test_representations.py index 067674d..6cf71a0 100644 --- a/tests/models/blob/test_representations.py +++ b/tests/models/blob/test_representations.py @@ -1,6 +1,7 @@ from django.test import SimpleTestCase from anchor.models import Blob +from anchor.models.blob.representations import NotRepresentableError class TestRepresentations(SimpleTestCase): @@ -44,5 +45,5 @@ def test_cannot_transform_non_variable_blob(self): def test_cannot_represent_non_variable_blob(self): text = Blob(mime_type="text/plain") - with self.assertRaises(ValueError): + with self.assertRaises(NotRepresentableError): text.representation({"format": "png"})