Skip to main content

Fluent, type-safe image manipulation and polymorphic media library for Arvel.

Project description

arvel-image

PyPI MIT License

Image transforms and a polymorphic media library for Arvel, in one wheel.

  • Image — fluent, Pillow-backed transforms. Resize, crop, fit, format, quality, optimize.
  • HasMedia — attach files (any kind, not just images) to any model. One class attribute, one upload call, eager-loaded reads, automatic JSON serialization.

Status: Pre-alpha.


Documentation: https://arvel.dev/packages/image


Install

uv add arvel-image
# or: pip install arvel-image

For AVIF / HEIC support:

uv add 'arvel-image[heif]'

Register the provider in bootstrap/providers.py and run the migration:

from arvel_image import ImageServiceProvider

providers = [
    # ...other providers...
    ImageServiceProvider,
]
arvel migrate

Quick start — attach an image to a model

The 30-second version. Three things to write, everything else is automatic.

from arvel.database import Model, Timestamps, id_, string
from arvel_image import HasMedia


class Product(HasMedia, Model, Timestamps):
    __tablename__ = "products"
    __media_collection__ = "images"   # one string, that's the whole config

    id: int = id_()
    name: str = string(200)


# write
product = await Product.create(name="Sneakers")
await product.add_image(file_bytes, file_name="hero.jpg")
await product.add_image("https://cdn.example.com/photo.png")
await product.add_image("/tmp/upload.webp")

# read
product = await Product.with_("media").first()
print(product.first_media.url())          # original
print(product.image_url("thumbnail"))     # named conversion, with sensible fallbacks
for m in product.get_media():
    print(m.url(), m.srcset())

# serialize — to_dict() includes serialized media automatically
return product.to_dict()
# {
#   "id": 1, "name": "Sneakers",
#   "media": [
#     {"url": "...", "conversions": {...}, "srcsets": {...}, "placeholder_svg": "...", ...}
#   ]
# }

That's the whole API for the common case. Read on for conversions, multi-collection hosts, queued work, and the standalone Image transform API.


HasMedia — the model trait

Declaring a collection

Set __media_collection__ to the bucket name. That's all. Every read and write on this model targets it — no per-call collection= arg.

class Product(HasMedia, Model, Timestamps):
    __media_collection__ = "images"

Put HasMedia before Model in the base list. The framework enforces this at class-definition time — wrong order raises TypeError immediately, so the silent to_dict() shadowing trap can't ship to production.

If you need MIME limits, size caps, conversions, or a fallback URL, override register_media_collections:

from arvel_image import HasMedia, MediaCollection, Conversion


class Product(HasMedia, Model, Timestamps):
    __media_collection__ = "images"

    def register_media_collections(self) -> None:
        (
            MediaCollection("images")
            .accept_mime_types(["image/jpeg", "image/png", "image/webp"])
            .max_file_size(5 * 1024 * 1024)               # 5 MB
            .use_fallback_url("/img/placeholder.svg")
            .with_conversions(
                Conversion("thumbnail").fit("cover", 150, 150).format("webp").quality(85),
                Conversion("card").fit("cover", 400, 300).generate_responsive_images(),
                Conversion("full").fit("contain", 1200, 900).quality(90),
            )
            .register_on(self)
        )

Writing — add_image()

One call. It figures out the source type for you.

await product.add_image(file_bytes, file_name="hero.jpg")  # bytes / bytearray / memoryview
await product.add_image(uploaded_file, file_name="hero.jpg")  # file-like (.read())
await product.add_image("/var/uploads/hero.jpg")               # local path
await product.add_image("https://cdn.example.com/img.png")     # HTTP(S), SSRF-guarded
await product.add_image("data:image/png;base64,iVBORw0KGgo...")  # data URI

SSRF guard rejects file://, ftp://, loopback, and private-IP URLs out of the box.

Advanced uploads — image_builder()

When you need custom properties, a specific disk, a queued conversion run, or responsive variants toggled per-upload, switch to the builder:

media = await (
    product
    .image_builder(file_bytes, file_name="hero.jpg")
    .with_custom_properties({"alt": "Hero shot", "role": "primary"})
    .to_disk("s3")
    .with_responsive_images()
    .queued()                           # offload conversions to the queue
    .save()                             # terminate; defaults to __media_collection__
)

image_builder only accepts in-memory sources (bytes, path, file-like). Use add_image directly for URLs or base64.

Reading

Everything reads from the eager-loaded media relation. Load it once with .with_("media") (per query) or .load("media") (per instance / collection), then call as many sync helpers as you want — no extra queries.

product = await Product.with_("media").find(pid)

product.get_media()             # list[Media] for __media_collection__, ordered
product.first_media             # Media | None
product.last_media              # Media | None
product.image_url()             # str | None (original of first media)
product.image_url("thumbnail")  # str | None (named conversion, falls back gracefully)
product.image_url("thumbnail", fallback="/img/default.png")

Deleting

await product.first_media.delete()    # one row + its files
await product.clear_images()          # everything in __media_collection__

Serializing

HasMedia.to_dict() overrides the base to_dict() and appends a serialized media array when the relation is eager-loaded. No kit-side serializers, no manual [m.to_dict() for m in product.media].

return product.to_dict()
# {
#   "id": 1,
#   "name": "Sneakers",
#   "media": [
#     {
#       "id": "42",
#       "uuid": "0193...",
#       "collection_name": "images",
#       "file_name": "hero.jpg",
#       "mime_type": "image/jpeg",
#       "size": 184320,
#       "url": "https://cdn.example.com/42/hero.jpg",
#       "conversions": {"thumbnail": "...", "card": "...", "full": "..."},
#       "srcsets": {"card": "...100w, ...400w, ...800w"},
#       "placeholder_svg": "data:image/svg+xml;base64,...",
#       "custom_properties": {"alt": "Hero shot"},
#       "order": 1,
#       "created_at": "2026-06-04T00:21:00+00:00"
#     }
#   ]
# }

If media wasn't eager-loaded, the key is absent — never a surprise N+1 in your serializer.


Conversions

Conversions are declared on the collection and run automatically after every add_image.

Conversion("thumbnail").fit("cover", 150, 150).format("webp").quality(85)
Conversion("card").fit("cover", 400, 300).generate_responsive_images()
Conversion("full").fit("contain", 1200, 900).quality(90)
Conversion("narrow").to_width(400)                       # height adjusts to keep aspect

Available chain methods:

Method Description
.fit(mode, width, height) "cover" or "contain"
.resize(width=…, height=…) Stretch to exact size
.crop(left=…, top=…, width=…, height=…) Fixed window
.to_width(px) / .to_height(px) Single-axis, preserves aspect ratio
.format(fmt) "jpeg", "png", "webp", "gif"
.quality(q) 1–100
.generate_responsive_images() Emit <srcset> variants for this conversion

Responsive images

Enable per upload, per conversion, or per collection:

# per upload
await product.image_builder(bytes_, file_name="hero.jpg").with_responsive_images().save()

# per conversion
Conversion("card").fit("cover", 400, 300).generate_responsive_images()

# per collection (applies to the original)
MediaCollection("images").generate_responsive_images().register_on(self)

Then in templates:

<img
  src="{{ media.url('card') }}"
  srcset="{{ media.srcset('card') }}"
  sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
  loading="lazy"
>

A blurred SVG placeholder is generated automatically — read it from media.placeholder_svg() or the serialized placeholder_svg field.

Queued conversions

For uploads where the user doesn't need to wait for thumbnails:

await product.image_builder(bytes_, file_name="hero.jpg").queued().save()

A QueuedConversionJob runs the conversions in the background. The original is available immediately; conversions appear as the job processes them.


MediaCollection — full reference

(
    MediaCollection("gallery")
    .only_keep_latest(10)                           # prune older rows beyond N
    .accept_mime_types(["image/jpeg", "image/png"])
    .max_file_size(5 * 1024 * 1024)                 # bytes
    .use_disk("s3")                                 # originals
    .use_conversions_disk("s3-public")              # derivatives
    .use_fallback_url("/img/placeholder.jpg")
    .generate_responsive_images()                   # default for uploads
    .with_conversions(
        Conversion("thumb").fit("cover", 150, 150),
    )
    .register_on(self)
)

Pass single_file=True to the constructor or chain .single_file() to make every upload replace the previous one. single_file and only_keep_latest(N) are mutually exclusive — pick the right one for the model.


Multi-collection hosts

The single-collection case is the default. When a model genuinely needs more than one bucket (say, a User with both avatar and cover), use the explicit _in(...) helpers:

class User(HasMedia, Model, Timestamps):
    __media_collection__ = "avatar"   # default for add_image / get_media / first_media

    def register_media_collections(self) -> None:
        MediaCollection("avatar", single_file=True).register_on(self)
        MediaCollection("cover", single_file=True).register_on(self)


await user.add_image(file_bytes, file_name="me.jpg")                       # → avatar
await user.add_image(file_bytes, file_name="bg.jpg", collection="cover")   # → cover

user.get_media()                  # avatar
user.media_in("cover")            # cover
user.media_in("*")                # every collection merged

await user.clear_images()         # clears avatar
await user.clear_media_in("cover")
await user.clear_media_in_except("cover", kept=keep_me)

Sharing media across hosts — __morph_class__

Two model classes can share the same media rows when one is a read-only view of the other. Set __morph_class__ on both to the canonical name, and reads + writes both resolve to those rows:

class Product(HasMedia, Model, Timestamps):
    __tablename__ = "products"
    __media_collection__ = "images"
    __morph_class__ = "Product"           # canonical


class ProductCatalog(HasMedia, Model, Timestamps):
    """Read-only materialized view over `products`."""
    __tablename__ = "products_catalog"
    __media_collection__ = "images"
    __morph_class__ = "Product"           # share Product's media rows


# Either class sees the same media.
await ProductCatalog.with_("media").first()           # batches against "Product"
await product_catalog.image_url("thumbnail")          # served from cache
await product.add_image(bytes_, file_name="hero.jpg") # writes under "Product"

Media.copy(target) and Media.move(target) honor the target's __morph_class__ too, so a row moved to a host with a different morph class records the destination's morph alias as its model_type.


Media — what each row gives you

m: Media = product.first_media

m.url()                          # original
m.url("thumbnail")               # named conversion
m.srcset()                       # original responsive variants
m.srcset("card")                 # conversion-level responsive variants
m.placeholder_svg()              # blurred SVG placeholder, base64 data URI
m.has_generated_conversion("thumbnail")
m.to_dict()                      # full payload (see above)

await m.delete()                 # row + files
await m.copy(other_host)         # duplicate to another HasMedia model
await m.move(other_host)         # move ownership

Image — standalone transforms

Image is a fluent, lazy wrapper around Pillow. No subprocesses, no shelling out. Use it directly when you need a one-off transform that isn't tied to a model.

from arvel_image import Image

(
    Image.load("photo.jpg")
    .optimize()                      # bake EXIF orientation into pixels
    .fit("cover", 800, 600)
    .format("webp")
    .quality(85)
    .save("photo.webp")
)

thumbnail: bytes = (
    Image.load("avatar.jpg").fit("cover", 256, 256).format("png").to_bytes()
)

The chain is lazy — nothing decodes until a terminal runs. In an async handler, use the *_async terminals to offload the whole pipeline to a worker thread:

data: bytes = await (
    Image.load(source).fit("cover", 256, 256).format("webp").to_bytes_async()
)

await Image.load(source).fit("cover", 256, 256).save_async("avatar.webp")

Operations

Method Description
Image.load(source) Path, file-like, or bytes
.fit(mode, width, height) "cover" or "contain"
.resize(width=…, height=…) Stretch to exact size
.crop(left=…, top=…, width=…, height=…) Fixed window
.to_width(px) / .to_height(px) Single-axis, preserves aspect ratio
.format(fmt) "jpeg", "png", "webp", "gif"
.quality(q) 1–100 (honoured by JPEG and WEBP)
.optimize() Bake EXIF orientation into pixels (exif_transpose)
.strip_exif() Explicitly zero out EXIF / XMP on encode
.save(path) / .save_async(path) Write to disk
.to_bytes() / .to_bytes_async() Return raw bytes

Properties: .width and .height return the current size (force a decode).


Recipe: a product detail endpoint

End-to-end, what it looks like in a real handler:

from arvel.routing import Route

from app.models.product import Product


@Route.get("/products/{id}")
async def show(id: int) -> dict:
    product = await (
        Product.where(Product.id == id)
        .with_("media", "category", "vendor")
        .first_or_fail()
    )
    return product.to_dict()

Frontend gets id, name, category, vendor, and a media array with URLs, conversions, and srcsets — in one query, zero N+1, zero serializer code.


License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

arvel_image-0.8.0.tar.gz (94.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

arvel_image-0.8.0-py3-none-any.whl (52.9 kB view details)

Uploaded Python 3

File details

Details for the file arvel_image-0.8.0.tar.gz.

File metadata

  • Download URL: arvel_image-0.8.0.tar.gz
  • Upload date:
  • Size: 94.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for arvel_image-0.8.0.tar.gz
Algorithm Hash digest
SHA256 8f082ca7dccde3a7d406e531d7300913730fd280820448fa88bfca47375de033
MD5 854b28bc5d105b18e1daabfe80ac9c39
BLAKE2b-256 46650f034d4e519ef36637d57557c6425d00ce80b74ae76d0280dc38c64e8f69

See more details on using hashes here.

Provenance

The following attestation bundles were made for arvel_image-0.8.0.tar.gz:

Publisher: publish.yml on mohamed-rekiba/arvel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file arvel_image-0.8.0-py3-none-any.whl.

File metadata

  • Download URL: arvel_image-0.8.0-py3-none-any.whl
  • Upload date:
  • Size: 52.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.13

File hashes

Hashes for arvel_image-0.8.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5cc437717a18bafc32fbf791b4301ff6ed127c7c4892e1486b76ad6020247248
MD5 c73cfa52c4be632207cf89f157a821c2
BLAKE2b-256 92f5682e4c49c875173affee7075697257a91038abc75beaa8740b62ee33323e

See more details on using hashes here.

Provenance

The following attestation bundles were made for arvel_image-0.8.0-py3-none-any.whl:

Publisher: publish.yml on mohamed-rekiba/arvel

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page