├── .github └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── docs ├── advanced-usage.md ├── api.md ├── configuration.md ├── getting-started.md └── index.md ├── easy_images ├── RELEASE.md ├── __init__.py ├── apps.py ├── core.py ├── engine.py ├── management │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ └── build_img_queue.py │ └── process_queue.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── options.py ├── signals.py ├── templatetags │ ├── __init__.py │ └── easy_images.py └── types_.py ├── mkdocs.yml ├── pdm.lock ├── pyproject.toml ├── pyvips ├── __init__.pyi └── vimage.pyi ├── tests ├── conftest.py ├── easy_images_tests │ ├── __init__.py │ └── models.py ├── settings.py ├── test_command.py ├── test_core.py ├── test_engine.py ├── test_models.py ├── test_options.py ├── test_signals.py └── test_templatetags.py └── uv.lock /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths: 7 | - "docs/**" 8 | - "mkdocs.yml" 9 | 10 | permissions: 11 | contents: write 12 | 13 | jobs: 14 | deploy-docs: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Install uv 19 | uses: astral-sh/setup-uv@v5 20 | - name: Deploy to GitHub Pages 21 | run: uvx mkdocs gh-deploy --force 22 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | tests: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Install system dependencies 16 | run: | 17 | sudo apt-get update 18 | sudo apt-get install --no-install-recommends --yes libvips 19 | - uses: actions/checkout@v4 20 | - name: Set up Python 3.10 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: "3.10" 24 | - name: Install uv 25 | uses: astral-sh/setup-uv@v5 26 | - name: Install project and dependencies 27 | run: uv pip install --system ".[tests]" 28 | - name: Test with pytest 29 | run: uv run pytest 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | example.jpg 2 | dist/ 3 | .coverage 4 | site 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "charliermarsh.ruff" 4 | ], 5 | "unwantedRecommendations": [ 6 | "ms-python.isort" 7 | ] 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.analysis.ignore": [".venv"], 4 | "python.testing.pytestArgs": ["tests"], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true, 7 | "[python]": { 8 | "editor.formatOnSave": true, 9 | "editor.codeActionsOnSave": { 10 | "source.fixAll": "explicit", 11 | "source.organizeImports": "explicit" 12 | }, 13 | "editor.defaultFormatter": "charliermarsh.ruff" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Django easy images 3 | 4 | Easily build responsive HTML `` tags by thumbnailing Django images using the VIPS fast image processing library. 5 | 6 | When an `` is generated, any thumbnails that don't already exist are queued for building (if aren't already queued) and left out of the HTML. 7 | For example, an image built from `Img(width="md")` will generate: 8 | 9 | ```html 10 | Profile photo for John Doe 11 | ``` 12 | 13 | But after the images are built, the HTML will be: 14 | 15 | ```html 16 | Profile photo for John Doe 24 | ``` 25 | 26 | ## Installation & Configuration 27 | 28 | To install django-easy-images, simply run the following command: 29 | 30 | ```bash 31 | pip install django-easy-images 32 | ``` 33 | 34 | Once installed, add the `easy_images` app in your Django settings file: 35 | 36 | ```python 37 | INSTALLED_APPS = [ 38 | "easy_images", 39 | # ... 40 | ] 41 | ``` 42 | Since this uses pyvips, you'll need to have the [libvips library installed on your system](https://www.libvips.org/install.html). 43 | 44 | ## Documentation 45 | 46 | Project documentation is built using [mkdocs](https://www.mkdocs.org/). To build and serve the documentation locally: 47 | 48 | ```bash 49 | pip install mkdocs 50 | mkdocs serve 51 | ``` 52 | 53 | Then open http://localhost:8000 in your browser. 54 | 55 | The documentation includes: 56 | - Usage examples 57 | - API reference 58 | - Configuration options 59 | Since this uses pyvips, you'll need to have the [libvips library installed on your system](https://www.libvips.org/install.html). 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 | 74 | 79 | 80 | 81 | 82 | 83 | 88 | 89 | 90 |
MacOs 66 | 67 | brew install vips 68 | 69 |
Ubuntu 75 | 76 | sudo apt-get install --no-install-recommends libvips 77 | 78 |
Arch 84 | 85 | sudo pacman -S libvips 86 | 87 |
91 | 92 | ## Usage 93 | 94 | You use the `Img` class or `{% img %}` template tag to render a Django FieldFile (or ImageFieldFile) containing an image as a responsive HTML `` tag. 95 | 96 | ### Summary 97 | 98 | 1. Define your `Img` classes in your app's `images.py` file. 99 | 2. Use these in your views / templates to generate the `` tags. 100 | 3. Either set up a cron job to run the `build_img_queue` management command to build images, or use a celery task and the `queued_img` signal to build images as they are queued. 101 | 4. Optionally, use the `Img.queue` method in your `apps.py` file to queue images for building as soon as they are uploaded (building the src/srcset inline if needed). 102 | 103 | ### Img class 104 | 105 | The `Img` class is used to create a generator for HTML `` elements. Here's an example of how to use it: 106 | 107 | ```python 108 | from easy_images import Img 109 | thumb = Img(width="md") 110 | thumb(profile.photo, alt=f"Profile photo for {{ profile.name }}").as_html() 111 | ``` 112 | 113 | As you can see, `thumb` is an `Img` instance that is used to generate an HTML `` element for the `profile.photo` image. The output would look something like this: 114 | 115 | ```html 116 | Profile photo for John Doe 124 | ``` 125 | 126 | In the following [options section](#options) you can see all the different options that you can pass to the `Img` instance. 127 | 128 | There other optional arguments that you can pass to the instance: 129 | 130 | #### `alt` 131 | 132 | The `alt` text for the image. 133 | 134 | #### `build` 135 | 136 | Determines determines what should be built inline. Valid values are: 137 | 138 | - `None`: All images will be built out-of-band from the request *(default)*. 139 | - `"src"`: The base `src` image will be built inline, but the `srcset` images will be built out-of-band from the request. 140 | - `"srcset"`: Both the base `src` image and all `srcset` images will be built inline. 141 | 142 | #### `img_attrs` 143 | 144 | A dictionary of any additional attributes to add to the `` element. 145 | 146 | ### The `{% img %}` tag 147 | 148 | The `img` template tag is another way to generate a responsive HTML `` element. 149 | 150 | ```jinja 151 | {% load easy_images %} 152 | {% img report.image width="md" alt="" %} 153 | ``` 154 | 155 | You can also pass a `Img` instance to the `img` template tag: 156 | 157 | ```jinja 158 | {% load easy_images %} 159 | {% img report.image thumb alt="" %} 160 | ``` 161 | 162 | The template tag never builds images inline. 163 | 164 | ## Building images. 165 | 166 | Whenever a image is requested, any image versions not already built will be queued for building and excluded from the HTML. 167 | 168 | To build the images in this queue, you can either: 169 | 170 | - run the `build_img_queue` management command (usually in a cron job), or 171 | - process it in a task using celery or another task runner (probably using the [`queued_img` signal](#queued_img-signal)). 172 | 173 | ## Options 174 | 175 | The `Img` class and the `img` template tag can be called with the following options. 176 | 177 | #### `width` 178 | 179 | Limit the width of the image. Either use an integer, or one of the following tailwind sizes as a string: "xs", "sm", "md", "lg", "screen-sm", "screen-md", "screen-lg", "screen-xl" or "screen-2xl" 180 | 181 | #### `ratio` 182 | 183 | The aspect ratio of the image to build. 184 | 185 | Use a float representing the ratio (e.g. `4/5`) or one of the following strings: "square", "video" (meaning 16/9), "video_vertical", "golden" (using the golden ratio), "golden_vertical". 186 | 187 | The default is `"video"` (16/9). 188 | 189 | #### `crop` 190 | 191 | Whether to crop the image. 192 | 193 | The default is `True`. 194 | 195 | Use a boolean, or tuple of two floats, or the comma separated string equivalent. `True` is replaced with to `(0.5, 0.5)` meaning the image is cropped from the center. The numbers are percentages of the image size. 196 | 197 | You can also use the following keywords: `tl` (top left), `tr` (top right), `bl` (bottom left), `br` (bottom right), `l`, `r`, `t` or `b`. This will set the percentage to 0 or 100 for the appropriate axis. 198 | 199 | If crop is `False`, the image will be resized so that it will cover the requested ratio but not cropped down. 200 | This is useful when you want to handle positioning in CSS using `object-fit`. 201 | 202 | #### `contain` 203 | 204 | When resizing the image (and not cropping), contain the image within the requested ratio. This ensures it will always fit within the requested dimensions. It also stops the image from being upscaled. 205 | 206 | The default is `False`, meaning the image will be resized down to cover the requested ratio (which means the image dimensions may be larger than the requested dimensions). 207 | 208 | #### `focal_window` 209 | 210 | A focal window to zoom in on when shrinking the image. Use a tuple of four floats (or a comma separated string equivalent) where the first pair of percentages is the top left corner and the second pair of percentages is the bottom right corner. 211 | 212 | #### `quality` 213 | 214 | The quality of the image. For example, `quality=90` means that the image will be compressed with a quality of 90. The default is 80. 215 | 216 | #### `densities` 217 | 218 | A list of higher density versions of the image to also create. 219 | 220 | The default is `[2]`. 221 | 222 | #### `sizes` 223 | 224 | A dictionary of sizes to use at different media queries. The keys should either be an integer to represent a max-width, or a string to represent a specific media query. The keys can either be an int/string to represent the width, or a dictionary of options (that must contain a width). 225 | 226 | If `densities` is set, a higher density version of the largest size (excluding 'print' media) will also be built to give the browser more options. 227 | 228 | For example: 229 | 230 | ```python 231 | img_with_sizes = Img( 232 | # Base size 233 | width=300, 234 | # Alternate sizes for different media queries 235 | sizes={ 236 | # Print media query, larger width with higher quality 237 | "print": {"width": 450, "quality": 90}, 238 | # A viewport max width of 800, smaller width. 239 | 800: 100 240 | }, 241 | ) 242 | print(img_with_sizes(model_instance.image, build="srcset").as_html()) 243 | ``` 244 | 245 | will output: 246 | 247 | ```html 248 | 249 | ``` 250 | 251 | #### `format` 252 | 253 | The image format to build the `srcset` versions with. The valid values are `"webp"` *(default)*, `"avif"` or `"jpeg"`. 254 | Note that AVIF uses a lot of memory to build images. 255 | 256 | The base `src` image format will always be built as a JPEG for backwards compatibility. 257 | 258 | ## Signals 259 | 260 | ### Queue from model. 261 | 262 | ### `file_post_save` signal 263 | 264 | This signal is triggered for each that `FileField` that was uncommitted when it's model instance is saved. 265 | 266 | It can be used to build & pre-queue images for a model instance. 267 | 268 | The most simplest usage is via the `Img` instance's helper method called `queue`. Here's an example of using that in a model's `apps.py` file: 269 | 270 | ```python 271 | from django.apps import AppConfig 272 | 273 | from my_app.images import thumbnail 274 | 275 | class MyAppConfig(AppConfig): 276 | name = 'my_app' 277 | 278 | def ready(self): 279 | from my_app.models import Profile 280 | 281 | thumbnail.queue(Profile, build="src") 282 | ``` 283 | 284 | By default, `queue` listens for saves to any `ImageField` on the model. Use the `fields` argument to limit which fields to queue images for: 285 | * `None` means all file fields on the model 286 | * a field class or subclass that the field must be *(default is `ImageField`)* 287 | * a list of field names to match (the signal will still only fire on file fields) 288 | 289 | ### `queued_img` signal 290 | 291 | This signal is triggered whenever an image element is missing and was not already queued for building. 292 | 293 | It can be used to process the queue in a task using celery or another task runner. Here's an example `tasks.py`: 294 | 295 | ```python 296 | from easy_images.management.process_queue import process_queue 297 | 298 | @app.task 299 | def build_img_queue(): 300 | process_queue() 301 | ``` 302 | 303 | In your apps `apps.py` file, connect this receiver: 304 | 305 | ```python 306 | from django.apps import AppConfig 307 | 308 | from easy_images.signals import queued_img 309 | 310 | class MyAppConfig(AppConfig): 311 | name = 'my_app' 312 | 313 | def ready(self): 314 | from my_app.tasks import build_img_queue 315 | 316 | # Kick off build task as soon as any image is queued. 317 | queued_img.connect(lambda **kwargs: build_img_queue.delay(), weak=False) 318 | # Also start the build task as soon as the app is ready in case there are already queued images. 319 | build_img_queue.delay() 320 | ``` 321 | -------------------------------------------------------------------------------- /docs/advanced-usage.md: -------------------------------------------------------------------------------- 1 | # Advanced Usage 2 | 3 | ## Queue Management 4 | 5 | ### Building Images 6 | 7 | Images are built either: 8 | 9 | 1. Via cron job running `build_img_queue` 10 | 2. Or via task runner using the `queued_img` signal 11 | 12 | ### Manual Queueing 13 | 14 | Queue images manually using the `queue` method: 15 | 16 | ```python 17 | from easy_images import Img 18 | from my_app.models import Product 19 | 20 | thumbnail = Img(width=300) 21 | thumbnail.queue(Product, fields=['main_image']) 22 | ``` 23 | 24 | ### Automatic Queueing 25 | 26 | Automatically queue images when a FileField is saved using [signals](#signals): 27 | 28 | ```python 29 | from django.apps import AppConfig 30 | from my_app.images import thumbnail 31 | 32 | class MyAppConfig(AppConfig): 33 | def ready(self): 34 | from my_app.models import Profile 35 | thumbnail.queue(Profile, build="src") 36 | ``` 37 | 38 | ## Performance Tips 39 | 40 | 1. For high-traffic sites, use `build="src"` to generate base images immediately 41 | 2. Set up Celery for distributed image processing 42 | 3. Use `format="webp"` for best compression/performance balance 43 | 4. Limit `densities` to `[2]` unless high-DPI support is critical (or turn it off entirely) 44 | 5. Consider pre-generating common image sizes during deployment 45 | 46 | ## Signals 47 | 48 | ### `file_post_save` 49 | Triggered when a FileField is saved. Use to automatically queue images: 50 | 51 | ```python 52 | from django.apps import AppConfig 53 | from my_app.images import thumbnail 54 | 55 | class MyAppConfig(AppConfig): 56 | def ready(self): 57 | from my_app.models import Profile 58 | thumbnail.queue(Profile, build="src") 59 | ``` 60 | 61 | ### `queued_img` 62 | Triggered when images need building. Use with Celery: 63 | 64 | ```python 65 | from easy_images.management.process_queue import process_queue 66 | from easy_images.signals import queued_img 67 | 68 | @app.task 69 | def build_img_queue(): 70 | process_queue() 71 | 72 | # In apps.py: 73 | queued_img.connect(lambda **kwargs: build_img_queue.delay(), weak=False) 74 | ``` 75 | 76 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | ## Core Modules 4 | 5 | ### `easy_images.core` 6 | 7 | Main functionality for image processing with these key classes: 8 | 9 | #### `Img` - Image Configuration 10 | ```python 11 | from easy_images import Img 12 | 13 | # Basic usage 14 | img = Img(width=800, format='webp', quality=85) 15 | processed_img = img(my_model.image_field) 16 | 17 | # Chaining configurations 18 | responsive_img = ( 19 | Img(width=1200) 20 | .extend(width=800, sizes={'768': 600, '480': 400}) 21 | ) 22 | 23 | # Automatic processing on model save 24 | img.queue(MyModel) # Processes all ImageFields on MyModel 25 | ``` 26 | 27 | #### `ImageBatch` - Batch Processing 28 | ```python 29 | from easy_images import ImageBatch 30 | 31 | batch = ImageBatch() 32 | img1 = batch.add(source_file=model1.image_field, options={'width': 800}) 33 | img2 = batch.add(source_file=model2.image_field, options={'width': 600}) 34 | 35 | # Get HTML for all images 36 | html1 = img1.as_html() 37 | html2 = img2.as_html() 38 | ``` 39 | 40 | #### `BoundImg` - Processed Image 41 | ```python 42 | # Get image URLs 43 | main_url = processed_img.base_url() 44 | srcset = processed_img.srcset # List of available sizes 45 | 46 | # Build images immediately (instead of queue) 47 | processed_img.build('all') # Options: 'all', 'base', 'srcset' 48 | ``` 49 | 50 | ### `easy_images.engine` 51 | Image processing engine implementation 52 | 53 | ### `easy_images.models` 54 | Database models for storing image information 55 | 56 | ## Management Commands 57 | 58 | ### `build_img_queue` 59 | Processes pending images in the queue: 60 | ```bash 61 | python manage.py build_img_queue 62 | ``` 63 | 64 | ## Template Tags 65 | 66 | ### `easy_images` 67 | Template tags for rendering processed images: 68 | ```html 69 | {% load easy_images %} 70 | 71 | 72 | {% easy_image obj.image_field width=800 %} 73 | 74 | 75 | {% easy_image obj.image_field width=1200 sizes="(max-width: 768px) 600px, (max-width: 480px) 400px" %} 76 | ``` -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration Options 2 | 3 | ## Image Processing Options 4 | 5 | ### `width` 6 | Limit the width of the image. Can be: 7 | 8 | - Integer (pixels) 9 | - Tailwind size string: "xs", "sm", "md", "lg", "screen-sm", "screen-md", "screen-lg", "screen-xl", "screen-2xl" 10 | 11 | ```python 12 | Img(width=300) # Fixed width 13 | Img(width="md") # Responsive width 14 | ``` 15 | 16 | ### `ratio` 17 | The aspect ratio of the image. Can be: 18 | 19 | - Float (e.g. `4/5`) 20 | - String: "square", "video" (16/9), "video_vertical", "golden", "golden_vertical" 21 | 22 | ```python 23 | Img(ratio="square") # 1:1 ratio 24 | Img(ratio=16/9) # Custom ratio 25 | ``` 26 | 27 | ### `crop` 28 | How to crop the image: 29 | 30 | - `True` (default): Crop from center (0.5, 0.5) 31 | - `False`: Don't crop (use CSS object-fit instead) 32 | - Tuple: (x%, y%) crop position 33 | - String: Position keywords like "tl", "tr", "bl", "br", "l", "r", "t", "b" 34 | 35 | ```python 36 | Img(crop="tl") # Crop from top-left 37 | Img(crop=False) # No cropping 38 | ``` 39 | 40 | ### `quality` 41 | Image compression quality (default: 80) 42 | 43 | ```python 44 | Img(quality=90) # Higher quality 45 | ``` 46 | 47 | ## Advanced Options 48 | 49 | ### `sizes` 50 | Responsive sizes for different media queries: 51 | 52 | ```python 53 | Img( 54 | width=300, 55 | sizes={ 56 | "print": {"width": 450, "quality": 90}, 57 | 800: 100 # Max-width 800px 58 | } 59 | ) 60 | ``` 61 | 62 | ### `format` 63 | `srcset` image format (default: "webp"): 64 | 65 | - "webp" (recommended) 66 | - "avif" (memory intensive) 67 | - "jpeg" 68 | 69 | ```python 70 | Img(format="avif") # Use AVIF format 71 | ``` 72 | 73 | ### `focal_window` 74 | Zoom area specified as (x1%, y1%, x2%, y2%): 75 | 76 | ```python 77 | Img(focal_window=(25, 25, 75, 75)) # Zoom center 50% of image 78 | ``` 79 | 80 | ### `densities` 81 | Higher density versions to generate (default: `[2]`): 82 | 83 | ```python 84 | Img(densities=[1.5, 2, 3]) # Generate 1.5x, 2x and 3x versions 85 | ``` -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | ```bash 6 | pip install django-easy-images 7 | ``` 8 | 9 | Add to your Django settings: 10 | ```python 11 | INSTALLED_APPS = [ 12 | "easy_images", 13 | # ... 14 | ] 15 | ``` 16 | 17 | ### Dependencies 18 | You'll need [libvips](https://www.libvips.org/install.html) installed: 19 | 20 | - **MacOS**: `brew install vips` 21 | - **Ubuntu**: `sudo apt-get install --no-install-recommends libvips` 22 | - **Arch**: `sudo pacman -S libvips` 23 | 24 | ## Basic Usage 25 | 26 | ### Using the Img class 27 | ```python 28 | from easy_images import Img 29 | 30 | # Create an image configuration 31 | thumb = Img(width="md") 32 | 33 | # Generate HTML for an image 34 | html = thumb(profile.photo, alt="Profile photo").as_html() 35 | ``` 36 | 37 | If you're going to be building several images, consider using the [`ImageBatch`](api.md#imagebatch) class to process them in bulk. 38 | 39 | ### Using template tags 40 | ```html 41 | {% load easy_images %} 42 | 43 | 44 | {% img report.image width="md" alt="" %} 45 | 46 | 47 | {% img report.image thumb alt="" %} 48 | ``` 49 | 50 | ## Next Steps 51 | - [Configuration Options](configuration.md) 52 | - [Advanced Usage](advanced-usage.md) -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Django Easy Images Documentation 2 | 3 | Welcome to the documentation for Django Easy Images - a powerful image processing solution for Django projects. 4 | 5 | ## What is Django Easy Images? 6 | 7 | Django Easy Images makes it simple to: 8 | 9 | - Generate responsive HTML `` tags 10 | - Automatically create thumbnails and optimized versions 11 | - Queue image processing for better performance 12 | - Support modern formats like WebP and AVIF 13 | 14 | ## Quick Start 15 | 16 | ```python 17 | from easy_images import Img 18 | 19 | # Create a thumbnail configuration 20 | profile_thumb = Img(width=200, ratio="square") 21 | 22 | # Generate HTML for a profile photo 23 | html = profile_thumb(user.profile.photo, alt="User profile").as_html() 24 | ``` 25 | 26 | ## Documentation Sections 27 | 28 | 1. [Getting Started](getting-started.md) - Installation and basic usage 29 | 2. [Configuration](configuration.md) - All available image processing options 30 | 3. [Advanced Usage](advanced-usage.md) - Signals, queue management and performance tips 31 | 4. [API Reference](api.md) - Detailed technical documentation 32 | 33 | ## Need Help? 34 | 35 | Found an issue or have questions? Please [open an issue](https://github.com/SmileyChris/django-easy-images) on GitHub. -------------------------------------------------------------------------------- /easy_images/RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | To roll a release, make sure you have your PyPI credentials in your keyring and that you have the ``keyring`` tool installed. 4 | 5 | ## Publishing to PyPI 6 | 7 | Tag the current release and push it: 8 | 9 | ```bash 10 | git tag -a vX.Y.Z -m "Release vX.Y.Z" 11 | git push --tags 12 | ``` 13 | 14 | Then run: 15 | 16 | ```bash 17 | rm -rf dist 18 | uv build 19 | uv publish 20 | ``` 21 | 22 | 23 | ## Adding your credentials to the keyring 24 | 25 | Install keyring (used by uv for publishing) and set your credentials: 26 | 27 | ```bash 28 | uv tool install keyring 29 | keyring set 'https://upload.pypi.org/legacy/' __token__ 30 | ``` 31 | 32 | Next, add these environment variables: 33 | 34 | ``` 35 | UV_KEYRING_PROVIDER=subprocess 36 | UV_PUBLISH_USERNAME=__token__ 37 | ``` 38 | -------------------------------------------------------------------------------- /easy_images/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Img # noqa: F401 2 | -------------------------------------------------------------------------------- /easy_images/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig, apps 2 | from django.db.models import FileField 3 | 4 | 5 | class EasyImagesConfig(AppConfig): 6 | default_auto_field = "django.db.models.BigAutoField" 7 | name = "easy_images" 8 | 9 | def ready(self): 10 | from django.db.models.signals import post_save, pre_save 11 | 12 | from easy_images.models import EasyImage 13 | from easy_images.signals import ( 14 | find_uncommitted_filefields, 15 | signal_committed_filefields, 16 | ) 17 | 18 | # Only connect the signals to (non-EasyImage) models that have FileFields. 19 | for model in apps.get_models(): 20 | if issubclass(model, EasyImage): 21 | continue 22 | if not any(isinstance(f, FileField) for f in model._meta.get_fields()): 23 | continue 24 | pre_save.connect(find_uncommitted_filefields, sender=model) 25 | post_save.connect(signal_committed_filefields, sender=model) 26 | -------------------------------------------------------------------------------- /easy_images/core.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import mimetypes 4 | from typing import TYPE_CHECKING, Any, NamedTuple, cast, get_args 5 | from uuid import UUID 6 | 7 | from django.db.models import F, FileField, ImageField, Model 8 | from django.db.models.fields.files import FieldFile 9 | from django.utils import timezone 10 | from django.utils.html import escape 11 | from typing_extensions import Unpack 12 | 13 | from easy_images.options import ParsedOptions 14 | from easy_images.signals import file_post_save, queued_img 15 | from easy_images.types_ import ( 16 | BuildChoices, 17 | ImgOptions, 18 | Options, 19 | WidthChoices, 20 | ) 21 | 22 | if TYPE_CHECKING: 23 | from .models import EasyImage 24 | 25 | 26 | format_map = {"avif": "image/avif", "webp": "image/webp", "jpeg": "image/jpeg"} 27 | 28 | option_defaults: ImgOptions = { 29 | "quality": 80, 30 | "ratio": "video", 31 | "crop": True, 32 | "contain": True, 33 | "densities": [2], 34 | "format": "webp", 35 | } 36 | 37 | 38 | class Img: 39 | """Configuration object for generating images.""" 40 | 41 | _batch: "ImageBatch" 42 | 43 | def __init__( 44 | self, batch: "ImageBatch | None" = None, **options: Unpack[ImgOptions] 45 | ): 46 | all_options = option_defaults.copy() 47 | all_options.update(options) 48 | self.options = all_options 49 | self._batch = batch or ImageBatch() # Store/create batch 50 | 51 | def extend(self, **options: Unpack[ImgOptions]) -> Img: 52 | """Create a new Img instance with updated options.""" 53 | new_options = self.options.copy() 54 | # Deep copy 'base' options if they exist in both current and new options 55 | if ( 56 | "base" in self.options 57 | and self.options["base"] 58 | and "base" in options 59 | and options["base"] is not None 60 | ): 61 | new_base = self.options["base"].copy() 62 | new_base.update(options["base"]) 63 | options["base"] = new_base # Update the options dict that will be used 64 | new_options.update(options) 65 | return Img(**new_options) 66 | 67 | def __call__( 68 | self, 69 | source: FieldFile, 70 | alt: str | None = None, 71 | build: BuildChoices = None, 72 | send_signal=True, 73 | ) -> "BoundImg": # Return new BoundImg 74 | """Add this image configuration to the batch for processing.""" 75 | # Delegate to the batch's add method 76 | return self._batch.add( 77 | source_file=source, 78 | img=self, 79 | alt=alt, 80 | build=build, 81 | send_signal=send_signal, 82 | ) 83 | 84 | def queue( 85 | self, 86 | model: type[Model], 87 | *, 88 | fields: type[FileField] | list[str] | None = ImageField, 89 | build: BuildChoices = None, 90 | send_signal: bool = True, 91 | dispatch_uid: str | None = None, 92 | ): 93 | """ 94 | Listen for saves to files on a specific model and bind this Img config. 95 | 96 | By default, this will listen for saves to any ImageField on the model. 97 | 98 | :param model: The model to listen for saves on. 99 | :param fields: The field type or specific field names to listen for saves on. 100 | If None, listens for saves on any FieldFile. 101 | Defaults to ImageField. 102 | :param build: The build option to use when building the image immediately after save. 103 | :param send_signal: Whether to send the queued_img signal if there are versions 104 | of the image that need to be built. 105 | :param dispatch_uid: A unique ID for the signal receiver, used for disconnecting. 106 | """ 107 | 108 | def handle_file(fieldfile: FieldFile, **kwargs): 109 | should_process = False 110 | if fields is None: # Listen to all FieldFiles if fields is None 111 | should_process = True 112 | elif isinstance(fields, list): # List of field names 113 | if fieldfile.field.name in fields: 114 | should_process = True 115 | elif isinstance( 116 | fieldfile.field, fields 117 | ): # Specific field type (e.g., ImageField) 118 | should_process = True 119 | 120 | if should_process: 121 | self(fieldfile, build=build, send_signal=send_signal) 122 | 123 | # Pass dispatch_uid to connect 124 | file_post_save.connect( 125 | handle_file, sender=model, weak=False, dispatch_uid=dispatch_uid 126 | ) 127 | 128 | 129 | class SrcSetItem(NamedTuple): 130 | """ 131 | Represents a single item in the generated srcset. 132 | """ 133 | 134 | thumb: "EasyImage" 135 | options: Options # Original options used for this item 136 | 137 | 138 | class ImageBatch: 139 | """ 140 | Manages a collection of image requests for batched loading. 141 | """ 142 | 143 | def __init__(self): 144 | self._is_loaded: bool = False 145 | self._all_pk_to_options: dict[UUID, ParsedOptions] = {} 146 | self._loaded_images: dict[UUID, "EasyImage"] = {} 147 | # Maps request_id -> {details: dict} 148 | self._requests: dict[int, dict] = {} 149 | self._next_request_id: int = 0 150 | # Store source file info needed for loading missing images 151 | # Maps (name, storage_name) -> FieldFile (or just necessary info) 152 | self._source_files: dict[tuple[str, str], FieldFile] = {} 153 | 154 | def add( 155 | self, 156 | source_file: FieldFile, 157 | img: Img, 158 | alt: str | None, 159 | build: BuildChoices | None, 160 | send_signal: bool, 161 | ) -> "BoundImg": 162 | """ 163 | Adds an image request to the batch. 164 | """ 165 | # Local import inside method to avoid circular dependency issues at import time 166 | from .models import EasyImage, get_storage_name 167 | 168 | request_id = self._next_request_id 169 | self._next_request_id += 1 170 | 171 | storage_name = get_storage_name(source_file.storage) 172 | source_key = (source_file.name, storage_name) 173 | if source_key not in self._source_files: 174 | self._source_files[source_key] = source_file # Store the FieldFile itself 175 | 176 | # --- Calculate PKs and Options --- 177 | # This logic needs to be extracted and potentially refactored for clarity 178 | request_pk_to_options: dict[UUID, ParsedOptions] = {} 179 | request_base_pk: UUID | None = None 180 | request_srcset_pks: list[UUID] = [] 181 | request_srcset_pk_options: dict[UUID, Options] = {} 182 | request_sizes_attr_list: list[str] = [] 183 | instance = source_file.instance 184 | 185 | base_width: int | None = None 186 | if "width" in img.options and img.options["width"] is not None: 187 | raw_base_opts = cast(Options, img.options.copy()) 188 | raw_base_opts["mimetype"] = "image/jpeg" 189 | base_parsed_options = ParsedOptions(instance, **raw_base_opts) 190 | request_base_pk = EasyImage.objects.hash( 191 | name=source_file.name, storage=storage_name, options=base_parsed_options 192 | ) 193 | request_pk_to_options[request_base_pk] = base_parsed_options 194 | base_width = base_parsed_options.width 195 | 196 | densities = list(img.options.get("densities") or []) 197 | srcset_base_options = cast(Options, img.options.copy()) 198 | 199 | if fmt := img.options.get("format"): 200 | mime = format_map.get(fmt) 201 | if mime: 202 | srcset_base_options["mimetype"] = mime 203 | if densities and 1 not in densities and mime != "image/jpeg": 204 | densities.insert(0, 1) 205 | else: 206 | source_type = mimetypes.guess_type(source_file.name)[0] 207 | srcset_base_options["mimetype"] = source_type or "image/jpeg" 208 | else: 209 | source_type = mimetypes.guess_type(source_file.name)[0] 210 | srcset_base_options["mimetype"] = source_type or "image/jpeg" 211 | 212 | sizes = img.options.get("sizes") 213 | max_width_options: Options | None = None 214 | max_width_for_density = base_width 215 | 216 | if sizes and base_width is not None: 217 | img_opts_for_sizes = srcset_base_options.copy() 218 | img_opts_for_sizes["srcset_width"] = base_width 219 | max_width_options = img_opts_for_sizes 220 | max_width_for_density = base_width 221 | 222 | for media, size_info in sizes.items(): 223 | media_options = srcset_base_options.copy() 224 | if isinstance(size_info, dict): 225 | media_options.update(size_info) 226 | else: 227 | if isinstance(size_info, int): 228 | media_options["width"] = size_info 229 | elif isinstance(size_info, str): 230 | valid_choices = get_args(WidthChoices) 231 | if size_info in valid_choices: 232 | media_options["width"] = cast(WidthChoices, size_info) 233 | else: 234 | raise ValueError( 235 | f"Invalid string '{size_info}' for size. Expected int, dict, or {valid_choices}" 236 | ) 237 | else: 238 | raise TypeError( 239 | f"Unexpected type for size option: {type(size_info)}" 240 | ) 241 | 242 | parsed_media_options = ParsedOptions(instance, **media_options) 243 | if not parsed_media_options.width: 244 | raise ValueError( 245 | f"Size options must resolve to width: {media_options}" 246 | ) 247 | 248 | media_options["srcset_width"] = parsed_media_options.width 249 | media_pk = EasyImage.objects.hash( 250 | name=source_file.name, 251 | storage=storage_name, 252 | options=parsed_media_options, 253 | ) 254 | request_pk_to_options[media_pk] = parsed_media_options 255 | request_srcset_pks.append(media_pk) 256 | request_srcset_pk_options[media_pk] = media_options 257 | 258 | media_str = ( 259 | f"(max-width: {media}px)" if isinstance(media, int) else str(media) 260 | ) 261 | if ( 262 | parsed_media_options.width > max_width_for_density 263 | and "print" not in media_str 264 | ): 265 | max_width_options = media_options 266 | max_width_for_density = parsed_media_options.width 267 | request_sizes_attr_list.append( 268 | f"{media_str} {parsed_media_options.width}px" 269 | ) 270 | 271 | request_sizes_attr_list.append(f"{max_width_for_density}px") 272 | 273 | if max_width_options: 274 | parsed_max_opts = ParsedOptions(instance, **max_width_options) 275 | max_pk = EasyImage.objects.hash( 276 | name=source_file.name, storage=storage_name, options=parsed_max_opts 277 | ) 278 | if max_pk not in request_pk_to_options: 279 | request_pk_to_options[max_pk] = parsed_max_opts 280 | request_srcset_pks.append(max_pk) 281 | request_srcset_pk_options[max_pk] = max_width_options 282 | 283 | max_density = max(densities) if densities else 1 284 | if max_density > 1 and max_width_options is not None: 285 | high_density_options = max_width_options.copy() 286 | high_density_options["width_multiplier"] = max_density 287 | parsed_high_density = ParsedOptions(instance, **high_density_options) 288 | high_density_pk = EasyImage.objects.hash( 289 | name=source_file.name, 290 | storage=storage_name, 291 | options=parsed_high_density, 292 | ) 293 | request_pk_to_options[high_density_pk] = parsed_high_density 294 | request_srcset_pks.append(high_density_pk) 295 | request_srcset_pk_options[high_density_pk] = high_density_options 296 | 297 | elif densities: 298 | for density in densities: 299 | density_options = srcset_base_options.copy() 300 | density_options["width_multiplier"] = density 301 | parsed_density_opts = ParsedOptions(instance, **density_options) 302 | density_pk = EasyImage.objects.hash( 303 | name=source_file.name, 304 | storage=storage_name, 305 | options=parsed_density_opts, 306 | ) 307 | request_pk_to_options[density_pk] = parsed_density_opts 308 | request_srcset_pks.append(density_pk) 309 | request_srcset_pk_options[density_pk] = density_options 310 | 311 | request_sizes_attr = ", ".join(request_sizes_attr_list) 312 | # --- End PK/Option Calculation --- 313 | 314 | # Store request details 315 | self._requests[request_id] = { 316 | "source_name": source_file.name, 317 | "storage_name": storage_name, 318 | "alt": alt if isinstance(alt, str) else img.options.get("alt", ""), 319 | "build": build, # Store build choice for later use 320 | "send_signal": send_signal, 321 | "pk_to_options": request_pk_to_options, 322 | "base_pk": request_base_pk, 323 | "srcset_pks": request_srcset_pks, 324 | "srcset_pk_options": request_srcset_pk_options, 325 | "sizes_attr": request_sizes_attr, 326 | "source_name_fallback": source_file.name, # Store original name for fallback 327 | } 328 | 329 | # Merge this request's PKs into the batch-wide collection 330 | self._all_pk_to_options.update(request_pk_to_options) 331 | 332 | # --- Handle Immediate Build Request --- 333 | if build: 334 | # We need to ensure this specific request's images are loaded/created 335 | # and then trigger the build. This might force the whole batch load. 336 | self._ensure_loaded() 337 | self.build_images_for_request(request_id, build) 338 | 339 | # --- Handle Signal --- 340 | # Check if any of the calculated PKs for *this specific request* already exist. 341 | # If none exist and send_signal is True, it means this is the first time 342 | # we're encountering this image configuration, so send the signal. 343 | bound_img = BoundImg(self, request_id) # Create instance first 344 | if send_signal: 345 | request_pks = list(request_pk_to_options.keys()) 346 | if request_pks: # Only check DB if there are PKs to check 347 | # Check if *any* of these PKs exist. If the count is 0, none exist. 348 | if not EasyImage.objects.filter(pk__in=request_pks).exists(): 349 | # Pass the BoundImg instance itself, mimicking old behavior 350 | queued_img.send(sender=BoundImg, instance=bound_img) 351 | 352 | return bound_img 353 | 354 | def _ensure_loaded(self): 355 | """Loads EasyImage data from DB for all requests in the batch.""" 356 | if self._is_loaded: 357 | return 358 | 359 | # Local import 360 | from .models import EasyImage, ImageStatus # Add ImageStatus 361 | 362 | all_pks = list(self._all_pk_to_options.keys()) 363 | if not all_pks: 364 | self._is_loaded = True 365 | return 366 | 367 | existing_images = { 368 | img.pk: img for img in EasyImage.objects.filter(pk__in=all_pks) 369 | } 370 | missing_pks = set(all_pks) - set(existing_images.keys()) 371 | 372 | new_instances_to_create: list[EasyImage] = [] 373 | pks_that_were_missing: set[UUID] = set() # Track which PKs we attempt to create 374 | 375 | if missing_pks: 376 | # Group missing PKs by their original source file info to create instances correctly 377 | missing_grouped: dict[tuple[str, str], list[UUID]] = {} 378 | pk_to_source_key: dict[UUID, tuple[str, str]] = {} 379 | 380 | for req_id, req_data in self._requests.items(): 381 | source_key = (req_data["source_name"], req_data["storage_name"]) 382 | for pk in req_data.get("pk_to_options", {}): 383 | if pk in missing_pks: 384 | pk_to_source_key[pk] = source_key 385 | if source_key not in missing_grouped: 386 | missing_grouped[source_key] = [] 387 | missing_grouped[source_key].append(pk) 388 | 389 | # Create instances for each group 390 | for (name, storage), pks_in_group in missing_grouped.items(): 391 | for pk in pks_in_group: 392 | options = self._all_pk_to_options[pk] 393 | new_instances_to_create.append( 394 | EasyImage( 395 | pk=pk, 396 | storage=storage, 397 | name=name, 398 | args=options.to_dict(), 399 | # status defaults to QUEUED 400 | ) 401 | ) 402 | pks_that_were_missing.add( 403 | pk 404 | ) # Mark this PK as one we tried to create 405 | 406 | if new_instances_to_create: 407 | # Use ignore_conflicts=True for race conditions 408 | created_instances = EasyImage.objects.bulk_create( 409 | new_instances_to_create, ignore_conflicts=True 410 | ) 411 | # Update cache with successfully created instances 412 | existing_images.update({img.pk: img for img in created_instances}) 413 | 414 | # If ignore_conflicts happened, some instances in new_instances_to_create 415 | # might not be in created_instances. We need to re-fetch them. 416 | created_pks = {img.pk for img in created_instances} 417 | refetch_pks = pks_that_were_missing - created_pks 418 | if refetch_pks: 419 | refetched_images = { 420 | img.pk: img 421 | for img in EasyImage.objects.filter(pk__in=refetch_pks) 422 | } 423 | existing_images.update(refetched_images) 424 | 425 | self._loaded_images = existing_images 426 | self._is_loaded = True 427 | 428 | # --- Handle Signals for Newly Created/Relevant Images --- 429 | # Iterate through requests and check if any of their PKs were missing AND send_signal is True 430 | pks_needing_signal = set() 431 | for req_id, req_data in self._requests.items(): 432 | if req_data.get("send_signal"): 433 | request_pks = set(req_data.get("pk_to_options", {}).keys()) 434 | # Check if any of this request's PKs were among those we attempted to create 435 | if request_pks.intersection(pks_that_were_missing): 436 | # Add all PKs for this request to the signal list? Or just the missing ones? 437 | # Let's add all PKs associated with the request that triggered the signal. 438 | pks_needing_signal.update(request_pks) 439 | 440 | if pks_needing_signal: 441 | # Send one signal with all relevant EasyImage instances? 442 | # The signal expects a single instance. We might need a new signal 443 | # or send multiple signals. Let's send multiple for now. 444 | relevant_instances = [ 445 | img 446 | for pk, img in self._loaded_images.items() 447 | if pk in pks_needing_signal 448 | ] 449 | for instance in relevant_instances: 450 | # Check if the instance status indicates it needs building (e.g., still QUEUED) 451 | # Refresh status just in case bulk_create didn't return the absolute latest 452 | try: 453 | instance.refresh_from_db(fields=["status"]) 454 | except EasyImage.DoesNotExist: 455 | continue # Skip if deleted somehow 456 | 457 | if instance.status == ImageStatus.QUEUED: 458 | # Pass the EasyImage instance itself to the signal 459 | queued_img.send(sender=EasyImage, instance=instance) 460 | 461 | def get_image(self, pk: UUID) -> "EasyImage | None": 462 | """ 463 | Retrieves a loaded EasyImage instance from the cache. 464 | """ 465 | # Ensure loaded? Or assume _ensure_loaded was called by BoundImg? 466 | # For safety, call it, but it will return quickly if already loaded. 467 | self._ensure_loaded() 468 | return self._loaded_images.get(pk) 469 | 470 | def build_images_for_request(self, request_id: int, build_choice: BuildChoices): 471 | """ 472 | Builds images for a specific request ID within the batch. 473 | """ 474 | # Local imports 475 | from . import engine 476 | from .models import EasyImage, ImageStatus 477 | 478 | self._ensure_loaded() # Make sure models are loaded/created 479 | 480 | request_data = self._requests.get(request_id) 481 | if not request_data: 482 | print(f"Warning: Request ID {request_id} not found in batch.") 483 | return # Request not found 484 | 485 | source_name = request_data["source_name"] 486 | storage_name = request_data["storage_name"] 487 | source_key = (source_name, storage_name) 488 | source_file = self._source_files.get(source_key) 489 | 490 | if not source_file: 491 | print(f"Warning: Source file info missing for {source_key} in batch.") 492 | # TODO: Handle error - maybe try to open from storage? 493 | return 494 | 495 | pk_to_options = request_data.get("pk_to_options", {}) 496 | base_pk = request_data.get("base_pk") 497 | srcset_pks = request_data.get("srcset_pks", []) 498 | 499 | build_targets_pks: list[UUID] = [] 500 | 501 | # Determine target PKs based on build_choice 502 | if build_choice == "srcset": 503 | build_targets_pks.extend(srcset_pks) 504 | elif build_choice == "src": 505 | if base_pk: 506 | build_targets_pks.append(base_pk) 507 | elif build_choice == "all": 508 | build_targets_pks.extend(pk_to_options.keys()) 509 | # If build_choice is None, build_targets_pks remains empty 510 | 511 | build_targets: list[tuple[EasyImage, ParsedOptions]] = [] 512 | pks_to_build_locally: set[UUID] = set() # Track PKs we intend to build now 513 | 514 | for pk in build_targets_pks: 515 | img_instance = self._loaded_images.get(pk) 516 | options = pk_to_options.get(pk) 517 | # Check instance exists, options exist, and image field is not yet populated 518 | if img_instance and options and not img_instance.image: 519 | # Check status - avoid building if already building or errored? 520 | # Let EasyImage.build handle status checks internally for atomicity. 521 | build_targets.append((img_instance, options)) 522 | pks_to_build_locally.add(pk) 523 | 524 | if not build_targets: 525 | # print(f"No images to build for request {request_id} with choice '{build_choice}'") 526 | return # Nothing to build for this request/choice 527 | 528 | # --- Perform Build (Adapted from old BoundImg._build_images) --- 529 | source_img: "engine.Image | None" = None 530 | try: 531 | # Load the source image efficiently just once for all targets in this request 532 | source_img = engine.efficient_load( 533 | file=source_file, options=[opts for _, opts in build_targets] 534 | ) 535 | except Exception as e: 536 | print(f"Error loading source image {source_file.name}: {e}") 537 | # Mark all targeted EasyImage instances as having a source error 538 | now = timezone.now() 539 | EasyImage.objects.filter(pk__in=list(pks_to_build_locally)).update( 540 | error_count=F("error_count") + 1, 541 | status=ImageStatus.SOURCE_ERROR, 542 | status_changed_date=now, 543 | ) 544 | # Update local status in the cache as well 545 | for im, _ in build_targets: 546 | if im: # Check instance exists in cache 547 | im.status = ImageStatus.SOURCE_ERROR 548 | im.status_changed_date = now 549 | im.error_count += 1 550 | else: 551 | # Build each target image if source loaded successfully 552 | for im, opts in build_targets: 553 | if not im: 554 | continue # Should not happen if build_targets is constructed correctly 555 | 556 | # Call the build method on the EasyImage instance 557 | # Pass the pre-loaded source_img. 558 | # The build method handles its own status updates and saving. 559 | built_ok = im.build(source_img=source_img, options=opts) 560 | # Refresh the instance in the cache if build was successful 561 | # to ensure the .image field (and .url) is populated. 562 | if built_ok: 563 | try: 564 | # Re-fetch the instance from DB to ensure we have the saved state 565 | refreshed_im = EasyImage.objects.get(pk=im.pk) 566 | # Update the cache with the re-fetched instance 567 | self._loaded_images[refreshed_im.pk] = refreshed_im 568 | except EasyImage.DoesNotExist: 569 | # Instance might have been deleted concurrently, ignore. 570 | pass 571 | 572 | 573 | class BoundImg: 574 | """ 575 | Represents a single image request within a batch. Accessing properties 576 | triggers the batch loading mechanism if not already loaded. 577 | """ 578 | 579 | _parent_batch: "ImageBatch" 580 | _request_id: int 581 | 582 | def __init__(self, parent_batch: "ImageBatch", request_id: int): 583 | self._parent_batch = parent_batch 584 | self._request_id = request_id 585 | 586 | # Add more specific type hints using TypeVar or overload if needed, 587 | # but for now, basic Any helps Pylance a bit. 588 | def _get_request_detail(self, key: str, default: Any = None) -> Any: 589 | """Helper to get details for this specific request from the batch.""" 590 | request_data = self._parent_batch._requests.get(self._request_id, {}) 591 | return request_data.get(key, default) 592 | 593 | @property 594 | def alt(self) -> str: 595 | # Alt text is stored directly, no loading needed initially 596 | return self._get_request_detail("alt", "") 597 | 598 | @property 599 | def base(self) -> "EasyImage | None": 600 | self._parent_batch._ensure_loaded() # Trigger load on first access 601 | base_pk = self._get_request_detail("base_pk") 602 | if base_pk: 603 | # Use the batch's getter which handles the cache 604 | return self._parent_batch.get_image(base_pk) 605 | return None 606 | 607 | @property 608 | def srcset(self) -> list[SrcSetItem]: # Use the new SrcSetItem defined below 609 | self._parent_batch._ensure_loaded() # Trigger load on first access 610 | srcset_items: list[SrcSetItem] = [] 611 | # Provide a default empty list to satisfy type checker for iteration 612 | srcset_pks = self._get_request_detail("srcset_pks", []) or [] 613 | # Need original options used for each srcset item for correct HTML/info 614 | srcset_pk_options = self._get_request_detail("srcset_pk_options", {}) 615 | 616 | for pk in srcset_pks: 617 | # Use the batch's getter 618 | thumb = self._parent_batch.get_image(pk) 619 | # Only include if the thumb exists in cache and has been successfully built 620 | if thumb and thumb.image: 621 | original_options = srcset_pk_options.get(pk) 622 | if ( 623 | original_options 624 | ): # Should always exist if pk is in _srcset_pk_options 625 | srcset_items.append( 626 | SrcSetItem(thumb=thumb, options=original_options) 627 | ) 628 | return srcset_items 629 | 630 | @property 631 | def sizes(self) -> str: 632 | # Sizes attribute string is calculated and stored during add() 633 | # Ensure a string is returned 634 | return str(self._get_request_detail("sizes_attr", "")) 635 | 636 | def as_html(self) -> str: 637 | """Generate the complete tag HTML with srcset and sizes.""" 638 | # Accessing properties triggers loading if needed 639 | base_img = self.base 640 | srcset_items = self.srcset 641 | sizes_attr = self.sizes 642 | alt_text = self.alt 643 | 644 | # Build srcset string 645 | srcset_parts = [] 646 | for item in srcset_items: 647 | # Ensure the image file exists and width info is available 648 | if item.thumb.image: 649 | # Use the width calculated and stored in the original options 650 | width_desc = item.options.get("srcset_width", item.thumb.width) 651 | if width_desc: 652 | try: 653 | # Accessing .url might fail if file is missing 654 | srcset_parts.append(f"{item.thumb.image.url} {width_desc}w") 655 | except (ValueError, FileNotFoundError): # Catch potential errors 656 | pass # Skip this srcset item if URL fails 657 | srcset_str = ", ".join(srcset_parts) 658 | 659 | # Build attributes list 660 | attrs = [] 661 | # Use base_url() which handles built URL and fallback 662 | src_url = self.base_url() 663 | if src_url: 664 | attrs.append(f'src="{escape(src_url)}"') 665 | 666 | attrs.append(f'alt="{escape(alt_text)}"') 667 | if srcset_str: 668 | attrs.append(f'srcset="{escape(srcset_str)}"') 669 | if sizes_attr: 670 | attrs.append(f'sizes="{escape(sizes_attr)}"') 671 | 672 | # Add width/height from base image if available and URL was successful 673 | if src_url and base_img: # Only add width/height if src was added 674 | if base_img.width is not None: 675 | attrs.append(f'width="{base_img.width}"') 676 | if base_img.height is not None: 677 | attrs.append(f'height="{base_img.height}"') 678 | 679 | # Filter out potential empty strings if base image failed etc. 680 | attrs = [attr for attr in attrs if attr] 681 | 682 | return f"" 683 | 684 | def base_url(self) -> str: 685 | """ 686 | Return the URL of the base image, falling back to the original source URL. 687 | """ 688 | base_img = self.base # Triggers load 689 | if base_img and base_img.image: 690 | try: 691 | # Attempt to get the URL of the built image 692 | img_url = base_img.image.url 693 | return img_url 694 | except (ValueError, FileNotFoundError): 695 | pass # Fall through to original URL if built image URL fails 696 | 697 | # Fallback: Try getting the URL from the original source file object 698 | try: 699 | source_name = self._get_request_detail("source_name_fallback") 700 | storage_name = self._get_request_detail("storage_name") 701 | if source_name and storage_name: 702 | source_key = (source_name, storage_name) 703 | original_file = self._parent_batch._source_files.get(source_key) 704 | if original_file and hasattr(original_file, "url"): 705 | return original_file.url 706 | except (AttributeError, ValueError): 707 | # If accessing original file or its URL fails, return empty string 708 | pass 709 | 710 | return "" # Ultimate fallback 711 | 712 | def build(self, build_choice: BuildChoices = "all"): 713 | """ 714 | Triggers the build process for this specific image request within the batch. 715 | """ 716 | # Delegate building to the parent batch, passing our request ID 717 | self._parent_batch.build_images_for_request(self._request_id, build_choice) 718 | 719 | def __str__(self) -> str: 720 | """ 721 | Return the base image URL. 722 | """ 723 | return self.base_url() 724 | 725 | def __bool__(self) -> bool: 726 | """ 727 | Return True if the base image exists and has a file. 728 | """ 729 | # This needs to trigger loading to check the actual image file 730 | base_img = self.base 731 | try: 732 | # Check image attribute and then try accessing file 733 | return bool(base_img and base_img.image and base_img.image.file) 734 | except (ValueError, FileNotFoundError): # Ensure FileNotFoundError is imported 735 | # If accessing .file raises error (e.g., missing), return False 736 | return False 737 | -------------------------------------------------------------------------------- /easy_images/engine.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | import math 5 | import os 6 | from mimetypes import guess_type 7 | from pathlib import Path 8 | from typing import TYPE_CHECKING 9 | 10 | from django.core.files import File 11 | from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile 12 | from django.db.models.fields.files import FieldFile 13 | 14 | from easy_images.core import ParsedOptions 15 | 16 | if TYPE_CHECKING: 17 | from pyvips import Image 18 | 19 | 20 | def scale_image( 21 | img: Image, 22 | target: tuple[int, int], 23 | /, 24 | crop: tuple[float, float] | bool | None = None, 25 | contain: bool = False, 26 | focal_window: tuple[float, float, float, float] | None = None, 27 | ): 28 | """ 29 | Scale an image to the given dimensions, optionally cropping it around a focal point 30 | or a focal window. 31 | """ 32 | w, h = img.width, img.height 33 | 34 | if crop: 35 | contain = False 36 | 37 | if contain: 38 | # Size image to contain the dimensions, also avoiding upscaling 39 | scale = min(target[0] / w, target[1] / h, 1) 40 | else: 41 | # Scale the image to cover the dimensions 42 | scale = max(target[0] / w, target[1] / h) 43 | 44 | # Focal window scaling 45 | if focal_window: 46 | f_left = focal_window[0] * w 47 | f_right = focal_window[2] * w 48 | f_top = focal_window[1] * h 49 | f_bottom = focal_window[3] * h 50 | # If the focal window is larger than the target, crop the image to the focal 51 | # window and scale it down to the target size. 52 | if f_right - f_left > target[0] and f_bottom - f_top > target[1]: 53 | img = img.extract_area(f_left, f_top, f_right - f_left, f_bottom - f_top) 54 | w, h = img.width, h 55 | if contain: 56 | scale = min(target[0] / w, target[1] / h, 1) 57 | else: 58 | scale = max(target[0] / w, target[1] / h) 59 | focal_window = None 60 | # Otherwise, if cropping then set the crop focal point to the center of the 61 | # focal window. 62 | elif crop is True: 63 | crop = ( 64 | (f_left + f_right) / 2, 65 | (f_top + f_bottom) / 2, 66 | ) 67 | 68 | img = img.resize(scale) 69 | w, h = img.width, img.height 70 | 71 | if not crop: 72 | return img 73 | 74 | if crop is True: 75 | crop = (0.5, 0.5) 76 | 77 | # Calculate the coordinates of the cropping box 78 | if focal_window: 79 | focal_point = ( 80 | int(focal_window[0] + crop[0] * (focal_window[2] - focal_window[0]) / 2), 81 | int(focal_window[1] + crop[1] * (focal_window[3] - focal_window[1]) / 2), 82 | ) 83 | else: 84 | focal_point = ( 85 | int(crop[0] * w), 86 | int(crop[1] * h), 87 | ) 88 | left = focal_point[0] - target[0] // 2 89 | top = focal_point[1] - target[1] // 2 90 | right = left + target[0] 91 | bottom = top + target[1] 92 | 93 | # Make sure the cropping box is within the image, otherwise move it. 94 | if left < 0: 95 | right -= left 96 | left = 0 97 | elif right > w: 98 | left -= right - w 99 | right = w 100 | if top < 0: 101 | bottom -= top 102 | top = 0 103 | elif bottom > h: 104 | top -= bottom - h 105 | bottom = h 106 | return img.extract_area(left, top, right - left, bottom - top) 107 | 108 | 109 | def efficient_load( 110 | file: str | Path | File, options: list[ParsedOptions] | ParsedOptions | None 111 | ) -> Image: 112 | """ 113 | Load an image from a file, using the most efficient method available. 114 | 115 | Pass a list of target sizes as tuples of ``(width, height)`` or ``(width_ratio, 116 | height_ratio)`` and the image will be loaded (optimally shrunk to at least 3x the 117 | largest target size if possible). 118 | """ 119 | if options and not isinstance(options, list): 120 | options = [options] 121 | # Use random access if there are multiple target sizes, since the source image will 122 | # be used multiple times. 123 | access = "random" if options and len(options) > 1 else "sequential" 124 | img = _new_image(file, access=access) 125 | if not options: 126 | return img 127 | x_scale = img.width / max(opt.source_x(img.width) for opt in options) 128 | y_scale = img.height / max(opt.source_y(img.height) for opt in options) 129 | min_scale = min(x_scale, y_scale) / 3 # At least 3x of the target size 130 | if min_scale < 2: 131 | return img 132 | shrink = min(2 ** (math.floor(math.log(min_scale, 2))), 8) 133 | return _new_image(file, shrink=shrink, access=access) 134 | 135 | 136 | def _new_image(file: str | Path | File, access, **kwargs): 137 | from pyvips import Image 138 | 139 | path = None 140 | if isinstance(file, File): 141 | if isinstance(file, FieldFile): 142 | try: 143 | path = file.path 144 | except Exception: 145 | pass 146 | elif isinstance(file, TemporaryUploadedFile): 147 | path = file.temporary_file_path() 148 | if not path: 149 | path = getattr(file, "path", None) 150 | if not path: 151 | content = file.read() 152 | if file.seekable(): 153 | file.seek(0) 154 | return Image.new_from_buffer(content, "", access=access, **kwargs) 155 | else: 156 | path = str(file) 157 | return Image.new_from_file(path, access=access, **kwargs) 158 | 159 | 160 | def vips_to_django( 161 | vips_image: Image, name: str, quality: int = 80 162 | ) -> TemporaryUploadedFile | InMemoryUploadedFile: 163 | """ 164 | Convert a PyVips image to a Django file. 165 | """ 166 | try: 167 | temp_file = TemporaryUploadedFile( 168 | name=name, 169 | size=0, 170 | content_type=guess_type(name)[0], 171 | charset=None, 172 | ) 173 | except OSError: 174 | # File can't be created, probably because it's a read-only file system? 175 | temp_file = None 176 | if temp_file: 177 | path = temp_file.temporary_file_path() 178 | vips_image.write_to_file(path, Q=quality) 179 | temp_file.size = os.path.getsize(path) # type: ignore 180 | return temp_file 181 | # Since file couldn't be created, try to write directly to memory instead. 182 | vips_image = vips_image.copy_memory() 183 | extension = os.path.splitext(name)[1] 184 | buffer = vips_image.write_to_buffer(extension, Q=quality) 185 | return InMemoryUploadedFile( 186 | file=io.BytesIO(buffer), 187 | field_name=None, 188 | name=name, 189 | content_type=guess_type(extension)[0], 190 | size=len(buffer), 191 | charset=None, 192 | ) 193 | 194 | 195 | def _test(): 196 | from pyvips import Image 197 | 198 | image = Image.new_from_file("example.jpg") 199 | print(f"Original image size: {image.width}x{image.height}") 200 | 201 | image = efficient_load("example.jpg", [ParsedOptions(width=100, ratio="video")]) 202 | print(f"Efficiently loaded image size: {image.width}x{image.height}") 203 | 204 | 205 | def _test2(): 206 | from PIL import Image as PILImage 207 | 208 | image = Image.new_from_file("example.jpg") 209 | image = scale_image(image, (700, 500), focal_window=(0.2, 0, 0.8, 0.5)) 210 | 211 | buffer = image.write_to_buffer(".jpg[Q=90]") 212 | 213 | # Convert buffer to PIL image 214 | pil_image = PILImage.open(io.BytesIO(buffer)) 215 | pil_image.show() 216 | 217 | 218 | if __name__ == "__main__": 219 | while True: 220 | _test() 221 | -------------------------------------------------------------------------------- /easy_images/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-easy-images/c01cb9a9822ec7c4a74c0f9835a687c396fbd7fd/easy_images/management/__init__.py -------------------------------------------------------------------------------- /easy_images/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-easy-images/c01cb9a9822ec7c4a74c0f9835a687c396fbd7fd/easy_images/management/commands/__init__.py -------------------------------------------------------------------------------- /easy_images/management/commands/build_img_queue.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | from django.db.models import Count, Q 3 | 4 | from easy_images.management.process_queue import process_queue 5 | from easy_images.models import EasyImage, ImageStatus 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Process EasyImages that need to be built" 10 | 11 | def add_arguments(self, parser): 12 | parser.add_argument( 13 | "--force", action="store_true", help="Force build images already generating" 14 | ) 15 | parser.add_argument( 16 | "--retry", 17 | type=int, 18 | help="Retry builds with errors with no more than this many failures", 19 | ) 20 | parser.add_argument( 21 | "--count-only", 22 | action="store_true", 23 | help=( 24 | "Just return a count the number of EasyImages that need to be" " built" 25 | ), 26 | ) 27 | 28 | def handle(self, *, verbosity, retry=None, force=None, count_only=False, **options): 29 | if count_only: 30 | count = EasyImage.objects.filter(image="").count() 31 | self.stdout.write(f"{count} thumbnails need building") 32 | counts = EasyImage.objects.filter(image="").aggregate( 33 | building=Count("pk", filter=Q(status=ImageStatus.BUILDING)), 34 | source_errors=Count("pk", filter=Q(status=ImageStatus.SOURCE_ERROR)), 35 | build_errors=Count("pk", filter=Q(status=ImageStatus.BUILD_ERROR)), 36 | ) 37 | if any(counts.values()): 38 | self.stdout.write("of which:") 39 | if counts["building"]: 40 | self.stdout.write( 41 | f" {counts['building']} marked as already building" 42 | ) 43 | if counts["source_errors"]: 44 | self.stdout.write(f" {counts['source_errors']} had source errors") 45 | if counts["build_errors"]: 46 | self.stdout.write(f" {counts['build_errors']} had build errors") 47 | return 48 | if verbosity: 49 | self.stdout.write("Building queued thumbnails...") 50 | if not force and verbosity: 51 | counts = EasyImage.objects.filter(image="").aggregate( 52 | building=Count("pk", filter=Q(status=ImageStatus.BUILDING)), 53 | source_errors=Count("pk", filter=Q(status=ImageStatus.SOURCE_ERROR)), 54 | build_errors=Count("pk", filter=Q(status=ImageStatus.BUILD_ERROR)), 55 | ) 56 | if retry: 57 | if retry: 58 | retry_counts = EasyImage.objects.filter( 59 | image="", error_count__lte=retry 60 | ).aggregate( 61 | source_errors=Count( 62 | "pk", filter=Q(status=ImageStatus.SOURCE_ERROR) 63 | ), 64 | build_errors=Count( 65 | "pk", filter=Q(status=ImageStatus.BUILD_ERROR) 66 | ), 67 | ) 68 | if counts["building"]: 69 | self.stdout.write( 70 | f"Skipping {counts['building']} marked as already building..." 71 | ) 72 | if counts["source_errors"]: 73 | if retry: 74 | skip = counts["source_errors"] - retry_counts["source_errors"] 75 | if skip: 76 | self.stdout.write( 77 | f"Retrying {retry_counts['source_errors']} with source errors ({skip} with more than {retry} retries skipped)..." 78 | ) 79 | else: 80 | self.stdout.write( 81 | f"Retrying {retry_counts['source_errors']} with source errors..." 82 | ) 83 | else: 84 | self.stdout.write( 85 | f"Skipping {counts['source_errors']} with source errors..." 86 | ) 87 | if counts["build_errors"]: 88 | if retry: 89 | skip = counts["build_errors"] - retry_counts["build_errors"] 90 | if skip: 91 | self.stdout.write( 92 | f"Retrying {retry_counts['build_errors']} with build errors ({skip} with more than {retry} retries skipped)..." 93 | ) 94 | else: 95 | self.stdout.write( 96 | f"Retrying {retry_counts['build_errors']} with build errors..." 97 | ) 98 | else: 99 | self.stdout.write( 100 | f"Skipping {counts['build_errors']} with build errors..." 101 | ) 102 | if verbosity: 103 | self.stdout.flush() 104 | built = process_queue(force=bool(force), retry=retry, verbose=verbosity > 1) 105 | if built is None: 106 | if verbosity: 107 | self.stdout.write("No thumbnails required building") 108 | return 109 | self.stdout.write( 110 | self.style.SUCCESS( 111 | f"Built {built} thumbnail{'' if built == 1 else 's'}" 112 | ) 113 | ) 114 | -------------------------------------------------------------------------------- /easy_images/management/process_queue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.db.models import Q 4 | from tqdm import tqdm 5 | 6 | from easy_images.models import EasyImage, ImageStatus 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def process_queue(force=False, retry: int | None = None, verbose=False): 12 | """ 13 | Process the image queue, building images that need building. 14 | 15 | :param bool force: Force building images, even those that are marked as already building 16 | or that had errors 17 | :param int retry: Also retry images with errors with no more than this many failures 18 | """ 19 | easy_images = EasyImage.objects.filter(image="") 20 | if not force: 21 | queued = Q(status=ImageStatus.QUEUED) 22 | if retry: 23 | retry_errors = Q( 24 | error_count__lte=retry, 25 | status__in=[ImageStatus.SOURCE_ERROR, ImageStatus.BUILD_ERROR], 26 | ) 27 | easy_images = easy_images.filter(queued | retry_errors) 28 | else: 29 | easy_images = easy_images.filter(queued) 30 | 31 | total = easy_images.count() 32 | if not total: 33 | return None 34 | 35 | built = 0 36 | for easy_image in tqdm(easy_images.iterator(), total=total): 37 | try: 38 | if easy_image.build(force=force, raise_error=verbose): 39 | built += 1 40 | except Exception: 41 | logger.exception(f"Error building {easy_image}") 42 | return built 43 | -------------------------------------------------------------------------------- /easy_images/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.11 on 2024-04-23 02:13 2 | 3 | from django.db import migrations, models 4 | 5 | import easy_images.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="EasyImage", 16 | fields=[ 17 | ( 18 | "id", 19 | models.UUIDField(editable=False, primary_key=True, serialize=False), 20 | ), 21 | ("created", models.DateTimeField(auto_now_add=True)), 22 | ( 23 | "status", 24 | models.PositiveSmallIntegerField( 25 | choices=easy_images.models.ImageStatus.choices, 26 | default=0, 27 | ), 28 | ), 29 | ("error_count", models.PositiveSmallIntegerField(default=0)), 30 | ("status_changed_date", models.DateTimeField(null=True)), 31 | ("storage", models.CharField(max_length=512)), 32 | ("name", models.CharField(max_length=512)), 33 | ("args", models.JSONField()), 34 | ( 35 | "image", 36 | models.ImageField( 37 | blank=True, 38 | height_field="height", 39 | storage=easy_images.models.pick_image_storage, 40 | upload_to="img/thumbs", 41 | width_field="width", 42 | ), 43 | ), 44 | ("height", models.IntegerField(null=True)), 45 | ("width", models.IntegerField(null=True)), 46 | ], 47 | options={ 48 | "indexes": [ 49 | models.Index( 50 | fields=["storage", "name"], name="easy_images_storage_and_name" 51 | ) 52 | ], 53 | }, 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /easy_images/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-easy-images/c01cb9a9822ec7c4a74c0f9835a687c396fbd7fd/easy_images/migrations/__init__.py -------------------------------------------------------------------------------- /easy_images/models.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | from uuid import UUID 5 | 6 | import django_stubs_ext 7 | from django.core.files.storage import ( 8 | Storage, 9 | storages, # type: ignore (storages isn't in the stubs) 10 | ) 11 | from django.core.files.storage.handler import InvalidStorageError 12 | from django.db import models 13 | from django.db.models.fields.files import FieldFile, ImageFieldFile 14 | from django.utils import timezone 15 | from django.utils.translation import gettext_lazy as _ 16 | 17 | from easy_images import engine 18 | from easy_images.options import ParsedOptions 19 | 20 | django_stubs_ext.monkeypatch() 21 | 22 | 23 | def pick_image_storage() -> Storage: 24 | try: 25 | return storages["easy_images"] 26 | except InvalidStorageError: 27 | return storages["default"] 28 | 29 | 30 | def image_name_and_storage(file: FieldFile) -> tuple[str, str]: 31 | return file.name, get_storage_name(file.storage) 32 | 33 | 34 | def get_storage_name(storage: Storage) -> str: 35 | for name in storages.backends: 36 | if storage == storages[name]: 37 | return name 38 | raise ValueError(f"Unknown storage: {storages}") 39 | 40 | 41 | class EasyImageManager(models.Manager["EasyImage"]): 42 | def hash(self, *, name: str, storage: str, options: ParsedOptions) -> UUID: 43 | hash = options.hash() 44 | hash.update(f":{storage}:{name}".encode()) 45 | return UUID(bytes=hash.digest()[:16]) 46 | 47 | def from_file(self, file: FieldFile, options: ParsedOptions): 48 | name, storage = image_name_and_storage(file) 49 | pk = self.hash(name=name, storage=storage, options=options) 50 | return self.get_or_create( 51 | pk=pk, 52 | defaults=dict( 53 | storage=storage, 54 | name=name, 55 | args=options.to_dict(), 56 | ), 57 | ) 58 | 59 | def all_for_file(self, file: FieldFile): 60 | name, storage = image_name_and_storage(file) 61 | return self.filter(name=name, storage=storage) 62 | 63 | 64 | class ImageStatus(models.IntegerChoices): 65 | QUEUED = 0, _("Queued") 66 | BUILDING = 1, _("Building") 67 | BUILT = 2, _("Built") 68 | SOURCE_ERROR = 3, _("Source error") 69 | BUILD_ERROR = 4, _("Build error") 70 | 71 | 72 | class EasyImage(models.Model): 73 | id = models.UUIDField(primary_key=True, editable=False) 74 | created = models.DateTimeField(auto_now_add=True) 75 | status = models.PositiveSmallIntegerField(choices=ImageStatus.choices, default=0) 76 | error_count = models.PositiveSmallIntegerField(default=0) 77 | status_changed_date = models.DateTimeField(null=True) 78 | storage = models.CharField(max_length=512) 79 | name = models.CharField(max_length=512) 80 | args = models.JSONField[dict[str, str]]() 81 | image = models.ImageField( 82 | storage=pick_image_storage, 83 | upload_to="img/thumbs", 84 | height_field="height", 85 | width_field="width", 86 | blank=True, 87 | ) 88 | height = models.IntegerField(null=True) 89 | width = models.IntegerField(null=True) 90 | 91 | objects: EasyImageManager = EasyImageManager() 92 | 93 | def save(self, *args, **kwargs): 94 | if not self.id: 95 | self.id = EasyImage.objects.hash( 96 | name=self.name, 97 | storage=self.storage, 98 | options=ParsedOptions(**self.args), 99 | ) 100 | super().save(*args, **kwargs) 101 | 102 | def build( 103 | self, 104 | source_img: engine.Image | None = None, 105 | options: ParsedOptions | None = None, 106 | force=False, 107 | raise_error=False, 108 | ): 109 | now = timezone.now() 110 | image_qs = EasyImage.objects.filter(pk=self.pk) 111 | if force: 112 | image_qs.update(status=ImageStatus.BUILDING, status_changed_date=now) 113 | elif self.image or not image_qs.exclude( 114 | models.Q(status=ImageStatus.BUILDING) | models.Q(status=ImageStatus.BUILT) 115 | ).update(status=ImageStatus.BUILDING, status_changed_date=now): 116 | # Already built (or being generated elsewhere). 117 | return False 118 | self.status = ImageStatus.BUILDING 119 | self.status_changed_date = now 120 | if not source_img: 121 | try: 122 | storage = storages[self.storage] 123 | file = storage.open(self.name) 124 | source_img = engine.efficient_load(file, options) 125 | except Exception: 126 | self.error_count += 1 127 | self.status = ImageStatus.SOURCE_ERROR 128 | self.status_changed_date = timezone.now() 129 | self.save() 130 | if raise_error: 131 | raise 132 | return False 133 | try: 134 | if not options: 135 | options = ParsedOptions(**self.args) 136 | if size := options.size: 137 | img = engine.scale_image( 138 | source_img, 139 | size, 140 | focal_window=options.window, 141 | crop=options.crop, 142 | contain=options.contain, 143 | ) 144 | else: 145 | img = source_img 146 | self.height = img.height 147 | self.width = img.width 148 | extension = { 149 | "image/jpeg": ".jpg", 150 | "image/webp": ".webp", 151 | "image/avif": ".avif", 152 | }.get(options.mimetype or "", ".jpg") 153 | file = engine.vips_to_django( 154 | img, f"{self.id.hex}{extension}", quality=options.quality 155 | ) 156 | except Exception: 157 | self.error_count += 1 158 | self.status = ImageStatus.BUILD_ERROR 159 | self.status_changed_date = timezone.now() 160 | self.save() 161 | if raise_error: 162 | raise 163 | return False 164 | self.image = cast( 165 | ImageFieldFile, # Avoid some typing issues 166 | file, 167 | ) 168 | self.status = ImageStatus.BUILT 169 | self.status_changed_date = timezone.now() 170 | self.save() 171 | file.close() 172 | return True 173 | 174 | class Meta: 175 | indexes = [ 176 | models.Index( 177 | fields=["storage", "name"], name="easy_images_storage_and_name" 178 | ), 179 | ] 180 | -------------------------------------------------------------------------------- /easy_images/options.py: -------------------------------------------------------------------------------- 1 | import json 2 | from hashlib import sha256 3 | from typing import cast 4 | 5 | from django.template import Context, Variable 6 | from django.utils.text import smart_split 7 | 8 | crop_options: dict[str, tuple[float, float]] = { 9 | "center": (0.5, 0.5), 10 | "tl": (0, 0), 11 | "tr": (1, 0), 12 | "bl": (0, 1), 13 | "br": (1, 1), 14 | "t": (0.5, 0), 15 | "b": (0.5, 1), 16 | "l": (0, 0.5), 17 | "r": (1, 0.5), 18 | } 19 | 20 | width_options: dict[str, int] = { 21 | "xs": 320, 22 | "sm": 384, 23 | "md": 448, 24 | "lg": 512, 25 | "screen-sm": 640, 26 | "screen-md": 768, 27 | "screen-lg": 1024, 28 | "screen-xl": 1280, 29 | "screen-2xl": 1536, 30 | } 31 | 32 | ratio_options: dict[str, float] = { 33 | "square": 1, 34 | "video": 16 / 9, 35 | "video_vertical": 9 / 16, 36 | "golden": 1.618033988749895, 37 | "golden_vertical": 1 / 1.618033988749895, 38 | } 39 | 40 | 41 | class ParsedOptions: 42 | __slots__ = ( 43 | "quality", 44 | "crop", 45 | "contain", 46 | "window", 47 | "width", 48 | "ratio", 49 | "mimetype", 50 | ) 51 | 52 | quality: int 53 | crop: tuple[float, float] | None 54 | contain: bool 55 | window: tuple[float, float, float, float] | None 56 | width: int | None 57 | ratio: float | None 58 | mimetype: str | None 59 | 60 | _defaults = {"contain": False} 61 | 62 | def __init__(self, bound=None, string="", /, **options): 63 | if string: 64 | for part in smart_split(string): 65 | key, value = part.split("=", 1) 66 | if key not in options: 67 | options[key] = Variable(value) 68 | context = Context() 69 | if bound: 70 | for key, value in bound.__dict__.items(): 71 | context[key] = value 72 | # Process known slots first 73 | processed_keys = set() 74 | for key in self.__slots__: 75 | processed_keys.add(key) 76 | if key in options and options[key] is not None: 77 | value = options[key] 78 | if isinstance(value, Variable): 79 | # Resolve Django template variables if present 80 | value = value.resolve(context) 81 | parse_func = getattr(self, f"parse_{key}") 82 | # Pass all options in case parse funcs need context (like width_multiplier) 83 | value = parse_func(value, **options) 84 | elif key in self._defaults: 85 | value = self._defaults[key] 86 | else: 87 | # Set default quality, others default to None implicitly via type hints 88 | value = 80 if key == "quality" else None 89 | setattr(self, key, value) 90 | 91 | # ParsedOptions only processes keys in its __slots__. 92 | # It does not validate or care about other keys passed in **options. 93 | # Validation of allowed keys for a specific context (like a template tag) 94 | # should happen in the calling code. 95 | 96 | @classmethod 97 | def from_str(cls, s: str): 98 | str_options: dict[str, str] = {} 99 | for part in s.split(" "): 100 | key, value = part.split("=", 1) 101 | str_options[key] = value 102 | return cls(**str_options) 103 | 104 | @staticmethod 105 | def parse_quality(value, **options) -> int: 106 | if not value: 107 | return 80 108 | try: 109 | return int(value) 110 | except (ValueError, TypeError): 111 | raise ValueError(f"Invalid quality value {value}") 112 | 113 | @staticmethod 114 | def parse_crop(value, **options) -> tuple[float, float] | None: 115 | if not value: 116 | return None 117 | if value is True: 118 | return (0.5, 0.5) 119 | try: 120 | # Check if value is a key in crop_options (requires hashable value) 121 | if value in crop_options: 122 | return crop_options[value] 123 | except TypeError: 124 | # value is not hashable (e.g., a list), proceed to other checks 125 | pass 126 | if isinstance(value, str): 127 | value = value.split(",") 128 | if isinstance(value, (tuple, list)) and len(value) == 2: 129 | try: 130 | return cast(tuple[float, float], tuple(float(n) for n in value)) 131 | except (ValueError, TypeError): 132 | pass 133 | raise ValueError(f"Invalid crop value {value}") 134 | 135 | @staticmethod 136 | def parse_contain(value, **options) -> bool: 137 | if isinstance(value, bool): 138 | return value 139 | if isinstance(value, str): 140 | val = value.lower() 141 | if val in ("true", "1", "yes"): 142 | return True 143 | if val in ("false", "0", "no"): 144 | return False 145 | # Allow integer 1 or 0 146 | if isinstance(value, int) and value in (0, 1): 147 | return bool(value) 148 | raise ValueError(f"Invalid contain value {value}") 149 | 150 | @staticmethod 151 | def parse_window(value, **options) -> tuple[float, float, float, float] | None: 152 | if isinstance(value, str): 153 | value = value.split(",") 154 | if isinstance(value, (tuple, list)) and len(value) == 4: 155 | try: 156 | return cast( 157 | tuple[float, float, float, float], tuple(float(n) for n in value) 158 | ) 159 | except (ValueError, TypeError): 160 | pass 161 | raise ValueError(f"Invalid window value {value}") 162 | 163 | @staticmethod 164 | def parse_width(value, **options) -> int | None: 165 | if value is None: 166 | return None 167 | try: 168 | # Check if value is a key in width_options (requires hashable value) 169 | if value in width_options: 170 | value = width_options[value] 171 | except TypeError: 172 | # value is not hashable (e.g., a list), proceed to other checks 173 | pass 174 | try: 175 | value = int(value) 176 | except (ValueError, TypeError): 177 | raise ValueError(f"Invalid width value {value}") 178 | # Ensure value is an integer before applying multiplier 179 | current_width = value 180 | 181 | if multiplier_val := options.get("width_multiplier"): 182 | try: 183 | multiplier = float(multiplier_val) 184 | current_width = int(current_width * multiplier) 185 | except (ValueError, TypeError): 186 | raise ValueError(f"Invalid width multiplier value {multiplier_val}") 187 | return current_width 188 | 189 | @staticmethod 190 | def parse_ratio(value, **options) -> float | None: 191 | if value is None: 192 | return None 193 | try: 194 | # Check if value is a key in ratio_options (requires hashable value) 195 | if value in ratio_options: 196 | return ratio_options[value] 197 | except TypeError: 198 | # value is not hashable (e.g., a list), proceed to other checks 199 | pass 200 | if isinstance(value, str): 201 | value = value.split("/") 202 | if isinstance(value, (tuple, list)) and len(value) == 2: 203 | try: 204 | return float(value[0]) / float(value[1]) 205 | except (ValueError, TypeError): 206 | pass 207 | # At this point, value could be a single number (int/float) 208 | # or a string representation of a number, or potentially 209 | # a list/tuple that wasn't handled above (which is invalid). 210 | try: 211 | # Attempt direct conversion if it's not a list/tuple already handled 212 | if not isinstance(value, (list, tuple)): 213 | return float(value) 214 | except (ValueError, TypeError): 215 | # If conversion fails, fall through to the exception 216 | pass 217 | # If we reach here, the value was invalid 218 | raise ValueError(f"Invalid ratio value {value}") 219 | 220 | @staticmethod 221 | def parse_mimetype(value, **options) -> str | None: 222 | if value is None: 223 | return None 224 | return str(value) 225 | 226 | def __str__(self): 227 | return json.dumps(self.to_dict(), sort_keys=True) 228 | 229 | def hash(self): 230 | return sha256(str(self).encode(), usedforsecurity=False) 231 | 232 | @property 233 | def size(self): 234 | if not self.width or not self.ratio: 235 | return None 236 | return self.width, int(self.width / self.ratio) 237 | 238 | def to_dict(self): 239 | return { 240 | key: getattr(self, key) 241 | for key in self.__slots__ 242 | if key not in self._defaults or getattr(self, key) != self._defaults[key] 243 | } 244 | 245 | def source_x(self, source_x: int): 246 | if self.window: 247 | return int(self.window[2] * source_x) - int(self.window[0] * source_x) 248 | return self.width or 0 249 | 250 | def source_y(self, source_y: int): 251 | if self.window: 252 | return int(self.window[3] * source_y) - int(self.window[1] * source_y) 253 | if not self.width or not self.ratio: 254 | return 0 255 | return int(self.width / self.ratio) 256 | -------------------------------------------------------------------------------- /easy_images/signals.py: -------------------------------------------------------------------------------- 1 | import django.dispatch 2 | from django.db.models import FileField 3 | 4 | file_post_save = django.dispatch.Signal() 5 | """ 6 | A signal sent after a model save for each ``FileField`` that was uncommitted before the 7 | save. 8 | 9 | * The ``sender`` argument will be the model class. 10 | * The ``fieldfile`` argument will be the instance of the field's file that was saved. 11 | """ 12 | 13 | queued_img = django.dispatch.Signal() 14 | """ 15 | A signal sent when an ``Img`` queues images for building. 16 | 17 | * The ``sender`` argument will be the ``Img`` instance. 18 | * The ``instance`` argument will be the instance of the field's file. 19 | """ 20 | 21 | 22 | def find_uncommitted_filefields(sender, instance, **kwargs): 23 | """ 24 | A pre_save signal handler which attaches an attribute to the model instance 25 | containing all uncommitted ``FileField``s, which can then be used by the 26 | :func:`signal_committed_filefields` post_save handler. 27 | """ 28 | from easy_images.models import EasyImage 29 | 30 | if issubclass(sender, EasyImage): 31 | # Don't record uncommitted fields for EasyImage instances. 32 | return 33 | uncommitted = instance._uncommitted_filefields = [] 34 | 35 | fields = [f for f in sender._meta.get_fields() if isinstance(f, FileField)] 36 | if kwargs.get("update_fields", None): 37 | # Limit to the fields that are being updated. 38 | fields = [f for f in fields if f.name in kwargs["update_fields"]] 39 | for field in fields: 40 | fieldfile = getattr(instance, field.name) 41 | if fieldfile and not fieldfile._committed: 42 | uncommitted.append(field.name) 43 | 44 | 45 | def signal_committed_filefields(sender, instance, **kwargs): 46 | """ 47 | A post_save signal handler which sends a signal for each ``FileField`` that 48 | was committed this save. 49 | """ 50 | for field_name in getattr(instance, "_uncommitted_filefields", ()): 51 | fieldfile = getattr(instance, field_name) 52 | # Don't send the signal for deleted files. 53 | if fieldfile: 54 | file_post_save.send(sender=sender, fieldfile=fieldfile) 55 | -------------------------------------------------------------------------------- /easy_images/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-easy-images/c01cb9a9822ec7c4a74c0f9835a687c396fbd7fd/easy_images/templatetags/__init__.py -------------------------------------------------------------------------------- /easy_images/templatetags/easy_images.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, cast # Import Protocol and Any 2 | 3 | from django import template 4 | from django.db.models.fields.files import FieldFile # Import FieldFile for type hint 5 | from django.template.base import token_kwargs 6 | 7 | from easy_images.core import BoundImg, Img 8 | from easy_images.options import ParsedOptions 9 | from easy_images.types_ import ImgOptions 10 | 11 | 12 | # Define a Protocol for the expected callable object (Img instance) 13 | class ImgRendererProtocol(Protocol): 14 | def __call__(self, source: FieldFile, *, alt: str | None = None) -> BoundImg: ... 15 | 16 | 17 | register = template.Library() 18 | 19 | 20 | class ImgNode(template.Node): 21 | def __init__(self, file, img_instance, options, as_var): 22 | self.file = file 23 | self.img_instance = img_instance 24 | self.options = options 25 | self.as_var = as_var 26 | 27 | def render(self, context): 28 | file = self.file.resolve(context) 29 | resolved_options = { 30 | key: value.resolve(context) for key, value in self.options.items() 31 | } 32 | 33 | # --- Validate all provided options --- 34 | all_input_keys = set(resolved_options.keys()) 35 | valid_parsed_options_keys = set(ParsedOptions.__slots__) 36 | tag_specific_keys = { 37 | "alt", 38 | "densities", 39 | "size", 40 | "format", 41 | } # Keys handled directly by the tag logic 42 | # token_kwargs converts hyphens to underscores, so check for 'img_' 43 | img_attr_keys = {k for k in all_input_keys if k.startswith("img_")} 44 | 45 | # Combine all known/valid keys 46 | known_keys = valid_parsed_options_keys | tag_specific_keys | img_attr_keys 47 | 48 | unknown_keys = all_input_keys - known_keys 49 | if unknown_keys: 50 | raise ValueError( 51 | f"Unknown options passed to 'img' tag: {', '.join(sorted(list(unknown_keys)))}" 52 | ) 53 | 54 | # --- Process Valid Options --- 55 | # Filter options intended for ParsedOptions 56 | options_for_parsed = { 57 | k: v for k, v in resolved_options.items() if k in valid_parsed_options_keys 58 | } 59 | 60 | # Parse only the relevant options using ParsedOptions 61 | base_opts = ParsedOptions(**options_for_parsed) 62 | 63 | # Initialize the final options dict from all non-None slots in base_opts 64 | # This ensures defaults (like quality=80) and parsed values (like width=50) are included. 65 | options = cast( 66 | ImgOptions, 67 | { 68 | key: getattr(base_opts, key) 69 | for key in ParsedOptions.__slots__ 70 | if getattr(base_opts, key) is not None 71 | }, 72 | ) 73 | 74 | # Collect img_ attributes separately first 75 | img_attrs = {} 76 | for key, value in resolved_options.items(): 77 | # Handle keys starting with 'img_' (underscore) 78 | if key.startswith("img_"): 79 | # Convert underscore to hyphen for HTML attribute name 80 | attr_name = key[4:].replace("_", "-") 81 | # Ensure value is a string for consistency in HTML attributes 82 | img_attrs[attr_name] = str(value) 83 | 84 | # Handle special overrides for options already processed by ParsedOptions 85 | if "densities" in resolved_options: 86 | value = resolved_options["densities"] 87 | options["densities"] = ( 88 | [float(d) for d in value.split(",")] 89 | if isinstance(value, str) 90 | else value 91 | ) 92 | if "size" in resolved_options: 93 | value = resolved_options["size"] 94 | if not isinstance(value, str) or "," not in value: 95 | raise ValueError( 96 | "size must be a string with a comma between the media and size" 97 | ) 98 | sizes = {} 99 | size_key, size_value = value.split(",") 100 | if size_key.isdigit(): 101 | size_key = int(size_key) 102 | sizes[size_key] = int(size_value) 103 | options["sizes"] = sizes # Overwrite sizes dict 104 | if "format" in resolved_options: 105 | options["format"] = resolved_options["format"] # Override format 106 | 107 | # Assign the collected img_attrs 108 | # Note: ParsedOptions doesn't handle img_attrs, so no merging needed. 109 | options["img_attrs"] = img_attrs 110 | 111 | # Resolve the optional Img instance from context 112 | img_renderer_resolved: object | None = None 113 | # Use the Protocol for type hinting 114 | img_renderer: ImgRendererProtocol | None = None 115 | if self.img_instance: 116 | img_renderer_resolved = self.img_instance.resolve(context) 117 | if not callable(img_renderer_resolved): 118 | raise ValueError( 119 | f"The provided img_instance '{self.img_instance.var}' did not resolve to a callable object." 120 | ) 121 | # Cast the resolved callable object to the Protocol 122 | img_renderer = cast(ImgRendererProtocol, img_renderer_resolved) 123 | 124 | # Use provided instance or create a new one 125 | if img_renderer: 126 | # When using a pre-configured instance, we pass the file and alt, 127 | # but don't override its existing options with the ones from the tag. 128 | # The user is expected to configure the instance beforehand. 129 | # We do pass the img_attrs collected from the tag though. 130 | # Use .get() for alt as validation happens before this point 131 | alt_text = resolved_options.get("alt", "") 132 | # Call with positional file and keyword alt, no img_attrs 133 | bound_image_from_instance: BoundImg = img_renderer(file, alt=alt_text) 134 | output = bound_image_from_instance.as_html() 135 | else: 136 | # Explicitly type hint the result of the call to Img(...) 137 | alt_text = resolved_options.get("alt", "") # Use .get() for safety 138 | 139 | bound_image: BoundImg = Img(**options)(file, alt=alt_text) 140 | output = bound_image.as_html() 141 | 142 | if self.as_var: 143 | context[self.as_var] = output 144 | return "" # Return empty string when using 'as var' 145 | return output 146 | 147 | 148 | @register.tag 149 | def img(parser, token): 150 | bits = token.split_contents() 151 | if len(bits) < 2: 152 | raise template.TemplateSyntaxError(f"{bits[0]} tag requires a field file") 153 | file = parser.compile_filter(bits[1]) 154 | if len(bits) < 3: 155 | raise template.TemplateSyntaxError( 156 | f"{bits[0]} tag requires an Img instance or options" 157 | ) 158 | options = bits[2:] 159 | img_instance = None 160 | if options and "=" not in options[0]: 161 | img_instance = parser.compile_filter(options[0]) 162 | options = options[1:] 163 | as_var = None 164 | if len(options) > 1 and options[-2] == "as": 165 | as_var = options[-1] 166 | options = options[:-2] 167 | options = token_kwargs(options, parser) 168 | if "alt" not in options: 169 | raise template.TemplateSyntaxError(f"{bits[0]} tag requires an alt attribute") 170 | 171 | return ImgNode(file, img_instance, options, as_var) 172 | -------------------------------------------------------------------------------- /easy_images/types_.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, annotations 2 | 3 | import re 4 | from typing import Literal, TypeAlias, TypedDict 5 | 6 | CropChoices: TypeAlias = Literal[ 7 | "center", 8 | "tl", 9 | "tr", 10 | "bl", 11 | "br", 12 | "t", 13 | "b", 14 | "l", 15 | "r", 16 | ] 17 | WidthChoices: TypeAlias = Literal[ 18 | "xs", 19 | "sm", 20 | "md", 21 | "lg", 22 | "screen-sm", 23 | "screen-md", 24 | "screen-lg", 25 | "screen-xl", 26 | "screen-2xl", 27 | ] 28 | RatioChoices: TypeAlias = Literal[ 29 | "square", 30 | "video", 31 | "video_vertical", 32 | "golden", 33 | "golden_vertical", 34 | ] 35 | 36 | alternative_re = re.compile(r"^(\d+w|\d(?:\.\d)?x)$") 37 | 38 | BuildChoices: TypeAlias = Literal["srcset", "src", "all", None] 39 | 40 | 41 | class Options(TypedDict, total=False): 42 | quality: int 43 | crop: tuple[float, float] | CropChoices | bool 44 | contain: bool 45 | window: tuple[float, float, float, float] | None 46 | width: int | WidthChoices | None 47 | ratio: float | tuple[float, float] | RatioChoices | None 48 | # Meta options: 49 | alt: str | None 50 | width_multiplier: float 51 | srcset_width: int 52 | mimetype: str 53 | 54 | 55 | class ImgOptions(Options, total=False): 56 | format: str 57 | densities: list[int | float] 58 | sizes: dict[str | int, int | str | Options] 59 | img_attrs: dict[str, str] 60 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Django Easy Images 2 | 3 | nav: 4 | - Getting Started: getting-started.md 5 | - Configuration: configuration.md 6 | - Advanced Usage: advanced-usage.md 7 | - API Reference: api.md 8 | 9 | theme: 10 | name: mkdocs 11 | 12 | markdown_extensions: 13 | - admonition 14 | - codehilite 15 | -------------------------------------------------------------------------------- /pdm.lock: -------------------------------------------------------------------------------- 1 | # This file is @generated by PDM. 2 | # It is not intended for manual editing. 3 | 4 | [metadata] 5 | groups = ["default", "tests"] 6 | strategy = ["cross_platform", "inherit_metadata"] 7 | lock_version = "4.4.1" 8 | content_hash = "sha256:648fe3fff48a99191042398bb67e0d03ff87f4bd0de5e6cdb494ab3fc304ae70" 9 | 10 | [[package]] 11 | name = "asgiref" 12 | version = "3.8.1" 13 | requires_python = ">=3.8" 14 | summary = "ASGI specs, helper code, and adapters" 15 | groups = ["default"] 16 | dependencies = [ 17 | "typing-extensions>=4; python_version < \"3.11\"", 18 | ] 19 | files = [ 20 | {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, 21 | {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, 22 | ] 23 | 24 | [[package]] 25 | name = "backports-zoneinfo" 26 | version = "0.2.1" 27 | requires_python = ">=3.6" 28 | summary = "Backport of the standard library zoneinfo module" 29 | groups = ["default"] 30 | marker = "python_version < \"3.9\"" 31 | files = [ 32 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, 33 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, 34 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, 35 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, 36 | {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, 37 | {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, 38 | ] 39 | 40 | [[package]] 41 | name = "cffi" 42 | version = "1.16.0" 43 | requires_python = ">=3.8" 44 | summary = "Foreign Function Interface for Python calling C code." 45 | groups = ["default"] 46 | dependencies = [ 47 | "pycparser", 48 | ] 49 | files = [ 50 | {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, 51 | {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, 52 | {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, 53 | {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, 54 | {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, 55 | {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, 56 | {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, 57 | {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, 58 | {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, 59 | {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, 60 | {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, 61 | {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, 62 | {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, 63 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, 64 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, 65 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, 66 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, 67 | {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, 68 | {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, 69 | {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, 70 | {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, 71 | {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, 72 | {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, 73 | {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, 74 | {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, 75 | {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, 76 | {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, 77 | {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, 78 | {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, 79 | {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, 80 | {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, 81 | {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, 82 | {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, 83 | {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, 84 | {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, 85 | {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, 86 | {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, 87 | {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, 88 | {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, 89 | {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, 90 | {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, 91 | {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, 92 | {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, 93 | {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, 94 | {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, 95 | {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, 96 | {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, 97 | {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, 98 | {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, 99 | {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, 100 | {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, 101 | {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, 102 | ] 103 | 104 | [[package]] 105 | name = "colorama" 106 | version = "0.4.6" 107 | requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 108 | summary = "Cross-platform colored terminal text." 109 | groups = ["default", "tests"] 110 | marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" 111 | files = [ 112 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 113 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 114 | ] 115 | 116 | [[package]] 117 | name = "django" 118 | version = "4.2.13" 119 | requires_python = ">=3.8" 120 | summary = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." 121 | groups = ["default"] 122 | dependencies = [ 123 | "asgiref<4,>=3.6.0", 124 | "backports-zoneinfo; python_version < \"3.9\"", 125 | "sqlparse>=0.3.1", 126 | "tzdata; sys_platform == \"win32\"", 127 | ] 128 | files = [ 129 | {file = "Django-4.2.13-py3-none-any.whl", hash = "sha256:a17fcba2aad3fc7d46fdb23215095dbbd64e6174bf4589171e732b18b07e426a"}, 130 | {file = "Django-4.2.13.tar.gz", hash = "sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5"}, 131 | ] 132 | 133 | [[package]] 134 | name = "django-stubs-ext" 135 | version = "5.0.2" 136 | requires_python = ">=3.8" 137 | summary = "Monkey-patching and extensions for django-stubs" 138 | groups = ["default"] 139 | dependencies = [ 140 | "django", 141 | "typing-extensions", 142 | ] 143 | files = [ 144 | {file = "django_stubs_ext-5.0.2-py3-none-any.whl", hash = "sha256:8d8efec5a86241266bec94a528fe21258ad90d78c67307f3ae5f36e81de97f12"}, 145 | {file = "django_stubs_ext-5.0.2.tar.gz", hash = "sha256:409c62585d7f996cef5c760e6e27ea3ff29f961c943747e67519c837422cad32"}, 146 | ] 147 | 148 | [[package]] 149 | name = "exceptiongroup" 150 | version = "1.2.0" 151 | requires_python = ">=3.7" 152 | summary = "Backport of PEP 654 (exception groups)" 153 | groups = ["tests"] 154 | marker = "python_version < \"3.11\"" 155 | files = [ 156 | {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, 157 | {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, 158 | ] 159 | 160 | [[package]] 161 | name = "iniconfig" 162 | version = "2.0.0" 163 | requires_python = ">=3.7" 164 | summary = "brain-dead simple config-ini parsing" 165 | groups = ["tests"] 166 | files = [ 167 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 168 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 169 | ] 170 | 171 | [[package]] 172 | name = "packaging" 173 | version = "24.0" 174 | requires_python = ">=3.7" 175 | summary = "Core utilities for Python packages" 176 | groups = ["tests"] 177 | files = [ 178 | {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, 179 | {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, 180 | ] 181 | 182 | [[package]] 183 | name = "pillow" 184 | version = "10.3.0" 185 | requires_python = ">=3.8" 186 | summary = "Python Imaging Library (Fork)" 187 | groups = ["default"] 188 | files = [ 189 | {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, 190 | {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, 191 | {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, 192 | {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, 193 | {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, 194 | {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, 195 | {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, 196 | {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, 197 | {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, 198 | {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, 199 | {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, 200 | {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, 201 | {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, 202 | {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, 203 | {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, 204 | {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, 205 | {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, 206 | {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, 207 | {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, 208 | {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, 209 | {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, 210 | {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, 211 | {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, 212 | {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, 213 | {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, 214 | {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, 215 | {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, 216 | {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, 217 | {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, 218 | {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, 219 | {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, 220 | {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, 221 | {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, 222 | {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, 223 | {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, 224 | {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, 225 | {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, 226 | {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, 227 | {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, 228 | {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, 229 | {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, 230 | {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, 231 | {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, 232 | {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, 233 | {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, 234 | {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, 235 | {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, 236 | {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, 237 | {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, 238 | {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, 239 | {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, 240 | {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, 241 | {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, 242 | {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, 243 | {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, 244 | {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, 245 | {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, 246 | {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, 247 | {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, 248 | {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, 249 | {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, 250 | {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, 251 | {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, 252 | {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, 253 | {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, 254 | {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, 255 | {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, 256 | {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, 257 | {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, 258 | ] 259 | 260 | [[package]] 261 | name = "pkgconfig" 262 | version = "1.5.5" 263 | requires_python = ">=3.3,<4.0" 264 | summary = "Interface Python with pkg-config" 265 | groups = ["default"] 266 | files = [ 267 | {file = "pkgconfig-1.5.5-py3-none-any.whl", hash = "sha256:d20023bbeb42ee6d428a0fac6e0904631f545985a10cdd71a20aa58bc47a4209"}, 268 | {file = "pkgconfig-1.5.5.tar.gz", hash = "sha256:deb4163ef11f75b520d822d9505c1f462761b4309b1bb713d08689759ea8b899"}, 269 | ] 270 | 271 | [[package]] 272 | name = "pluggy" 273 | version = "1.5.0" 274 | requires_python = ">=3.8" 275 | summary = "plugin and hook calling mechanisms for python" 276 | groups = ["tests"] 277 | files = [ 278 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 279 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 280 | ] 281 | 282 | [[package]] 283 | name = "pycparser" 284 | version = "2.22" 285 | requires_python = ">=3.8" 286 | summary = "C parser in Python" 287 | groups = ["default"] 288 | files = [ 289 | {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, 290 | {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, 291 | ] 292 | 293 | [[package]] 294 | name = "pytest" 295 | version = "8.2.2" 296 | requires_python = ">=3.8" 297 | summary = "pytest: simple powerful testing with Python" 298 | groups = ["tests"] 299 | dependencies = [ 300 | "colorama; sys_platform == \"win32\"", 301 | "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", 302 | "iniconfig", 303 | "packaging", 304 | "pluggy<2.0,>=1.5", 305 | "tomli>=1; python_version < \"3.11\"", 306 | ] 307 | files = [ 308 | {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, 309 | {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, 310 | ] 311 | 312 | [[package]] 313 | name = "pytest-django" 314 | version = "4.8.0" 315 | requires_python = ">=3.8" 316 | summary = "A Django plugin for pytest." 317 | groups = ["tests"] 318 | dependencies = [ 319 | "pytest>=7.0.0", 320 | ] 321 | files = [ 322 | {file = "pytest-django-4.8.0.tar.gz", hash = "sha256:5d054fe011c56f3b10f978f41a8efb2e5adfc7e680ef36fb571ada1f24779d90"}, 323 | {file = "pytest_django-4.8.0-py3-none-any.whl", hash = "sha256:ca1ddd1e0e4c227cf9e3e40a6afc6d106b3e70868fd2ac5798a22501271cd0c7"}, 324 | ] 325 | 326 | [[package]] 327 | name = "pyvips" 328 | version = "2.2.3" 329 | summary = "binding for the libvips image processing library, API mode" 330 | groups = ["default"] 331 | dependencies = [ 332 | "cffi>=1.0.0", 333 | "pkgconfig", 334 | ] 335 | files = [ 336 | {file = "pyvips-2.2.3.tar.gz", hash = "sha256:43bceced0db492654c93008246a58a508e0373ae1621116b87b322f2ac72212f"}, 337 | ] 338 | 339 | [[package]] 340 | name = "sqlparse" 341 | version = "0.4.4" 342 | requires_python = ">=3.5" 343 | summary = "A non-validating SQL parser." 344 | groups = ["default"] 345 | files = [ 346 | {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, 347 | {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, 348 | ] 349 | 350 | [[package]] 351 | name = "tomli" 352 | version = "2.0.1" 353 | requires_python = ">=3.7" 354 | summary = "A lil' TOML parser" 355 | groups = ["tests"] 356 | marker = "python_version < \"3.11\"" 357 | files = [ 358 | {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, 359 | {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, 360 | ] 361 | 362 | [[package]] 363 | name = "tqdm" 364 | version = "4.66.4" 365 | requires_python = ">=3.7" 366 | summary = "Fast, Extensible Progress Meter" 367 | groups = ["default"] 368 | dependencies = [ 369 | "colorama; platform_system == \"Windows\"", 370 | ] 371 | files = [ 372 | {file = "tqdm-4.66.4-py3-none-any.whl", hash = "sha256:b75ca56b413b030bc3f00af51fd2c1a1a5eac6a0c1cca83cbb37a5c52abce644"}, 373 | {file = "tqdm-4.66.4.tar.gz", hash = "sha256:e4d936c9de8727928f3be6079590e97d9abfe8d39a590be678eb5919ffc186bb"}, 374 | ] 375 | 376 | [[package]] 377 | name = "typing-extensions" 378 | version = "4.12.2" 379 | requires_python = ">=3.8" 380 | summary = "Backported and Experimental Type Hints for Python 3.8+" 381 | groups = ["default"] 382 | files = [ 383 | {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, 384 | {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, 385 | ] 386 | 387 | [[package]] 388 | name = "tzdata" 389 | version = "2024.1" 390 | requires_python = ">=2" 391 | summary = "Provider of IANA time zone data" 392 | groups = ["default"] 393 | marker = "sys_platform == \"win32\"" 394 | files = [ 395 | {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, 396 | {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, 397 | ] 398 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "django-easy-images" 3 | dynamic = ["version"] 4 | description = "Easily build responsive HTML `` tags from Django images" 5 | authors = [{ name = "Chris Beaven", email = "smileychris@gmail.com" }] 6 | dependencies = [ 7 | "django>=4.2", 8 | "typing-extensions>=4.11.0", 9 | "django-stubs-ext>=4.2.7", 10 | "pyvips>=2.2.2", 11 | "tqdm>=4.66.2", 12 | "pillow", 13 | ] 14 | requires-python = ">=3.8" 15 | readme = "README.md" 16 | classifiers = [ 17 | "Development Status :: 3 - Alpha", 18 | "Environment :: Web Environment", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: MIT License", 21 | "Framework :: Django", 22 | "Framework :: Django :: 4.2", 23 | "Framework :: Django :: 5.0", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.8", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Topic :: Software Development :: Libraries :: Python Modules", 29 | ] 30 | 31 | [project.urls] 32 | Repository = "https://github.com/SmileyChris/django-easy-images" 33 | Docs = "https://smileychris.github.io/django-easy-images/" 34 | 35 | [project.license] 36 | text = "MIT" 37 | 38 | [project.optional-dependencies] 39 | tests = ["pytest", "pytest-django>=4.8.0"] 40 | 41 | [build-system] 42 | requires = ["pdm-backend"] 43 | build-backend = "pdm.backend" 44 | 45 | 46 | [tool.pdm] 47 | distribution = true 48 | 49 | [tool.pdm.version] 50 | source = "scm" 51 | fallback_version = "0.0.0" 52 | 53 | 54 | [tool.pytest.ini_options] 55 | DJANGO_SETTINGS_MODULE = "tests.settings" 56 | django_find_project = false 57 | pythonpath = "." 58 | addopts = "--cov=easy_images --cov-report=term-missing" 59 | 60 | [dependency-groups] 61 | dev = ["mkdocs>=1.6.1", "pytest-cov>=5.0.0", "pytest-django>=4.11.0"] 62 | -------------------------------------------------------------------------------- /pyvips/__init__.pyi: -------------------------------------------------------------------------------- 1 | from .vimage import * # noqa 2 | -------------------------------------------------------------------------------- /pyvips/vimage.pyi: -------------------------------------------------------------------------------- 1 | from _typeshed import Incomplete 2 | 3 | import pyvips 4 | 5 | class ImageType(type): 6 | def __getattr__(cls, name): ... 7 | 8 | class Image(pyvips.VipsObject): 9 | width: int 10 | height: int 11 | def __init__(self, pointer) -> None: ... 12 | @staticmethod 13 | def new_from_file(vips_filename, **kwargs) -> Image: ... 14 | @staticmethod 15 | def new_from_buffer(data, options, **kwargs) -> Image: ... 16 | @staticmethod 17 | def new_from_list(array, scale: float = ..., offset: float = ...) -> Image: ... 18 | @classmethod 19 | def new_from_array( 20 | cls, 21 | obj, 22 | scale: float = ..., 23 | offset: float = ..., 24 | interpretation: Incomplete | None = ..., 25 | ) -> Image: ... 26 | @staticmethod 27 | def new_from_memory(data, width, height, bands, format) -> Image: ... 28 | @staticmethod 29 | def new_from_source(source, options, **kwargs) -> Image: ... 30 | @staticmethod 31 | def new_temp_file(format) -> Image: ... 32 | def new_from_image(self, value) -> Image: ... 33 | def copy_memory(self): ... 34 | def write_to_file(self, vips_filename, **kwargs): ... 35 | def write_to_buffer(self, format_string, **kwargs): ... 36 | def write_to_target(self, target, format_string, **kwargs): ... 37 | def write_to_memory(self): ... 38 | def write(self, other) -> None: ... 39 | def invalidate(self) -> None: ... 40 | def set_progress(self, progress) -> None: ... 41 | def set_kill(self, kill) -> None: ... 42 | def get_typeof(self, name): ... 43 | def get(self, name): ... 44 | def get_fields(self): ... 45 | def set_type(self, gtype, name, value) -> None: ... 46 | def set(self, name, value) -> None: ... 47 | def remove(self, name): ... 48 | def tolist(self): ... 49 | def __array__(self, dtype: Incomplete | None = ...): ... 50 | def numpy(self, dtype: Incomplete | None = ...): ... 51 | def __getattr__(self, name): ... 52 | def get_value(self, name): ... 53 | def set_value(self, name, value) -> None: ... 54 | def get_scale(self): ... 55 | def get_offset(self): ... 56 | def __enter__(self): ... 57 | def __exit__(self, type, value, traceback) -> None: ... 58 | def __getitem__(self, arg): ... 59 | def __call__(self, x, y): ... 60 | def __add__(self, other): ... 61 | def __radd__(self, other): ... 62 | def __sub__(self, other): ... 63 | def __rsub__(self, other): ... 64 | def __mul__(self, other): ... 65 | def __rmul__(self, other): ... 66 | def __div__(self, other): ... 67 | def __rdiv__(self, other): ... 68 | def __truediv__(self, other): ... 69 | def __rtruediv__(self, other): ... 70 | def __floordiv__(self, other): ... 71 | def __rfloordiv__(self, other): ... 72 | def __mod__(self, other): ... 73 | def __pow__(self, other): ... 74 | def __rpow__(self, other): ... 75 | def __abs__(self): ... 76 | def __lshift__(self, other): ... 77 | def __rshift__(self, other): ... 78 | def __and__(self, other): ... 79 | def __rand__(self, other): ... 80 | def __or__(self, other): ... 81 | def __ror__(self, other): ... 82 | def __xor__(self, other): ... 83 | def __rxor__(self, other): ... 84 | def __neg__(self): ... 85 | def __pos__(self): ... 86 | def __invert__(self): ... 87 | def __gt__(self, other): ... 88 | def __ge__(self, other): ... 89 | def __lt__(self, other): ... 90 | def __le__(self, other): ... 91 | def __eq__(self, other): ... 92 | def __ne__(self, other): ... 93 | def floor(self): ... 94 | def ceil(self): ... 95 | def rint(self): ... 96 | def bandand(self): ... 97 | def bandor(self): ... 98 | def bandeor(self): ... 99 | def bandsplit(self): ... 100 | def bandjoin(self, other): ... 101 | def atan2(self, other): ... 102 | def get_n_pages(self): ... 103 | def get_page_height(self): ... 104 | def pagesplit(self): ... 105 | def pagejoin(self, other): ... 106 | def composite(self, other, mode, **kwargs): ... 107 | def bandrank(self, other, **kwargs): ... 108 | def maxpos(self): ... 109 | def minpos(self): ... 110 | def real(self): ... 111 | def imag(self): ... 112 | def polar(self): ... 113 | def rect(self): ... 114 | def conj(self): ... 115 | def sin(self): ... 116 | def cos(self): ... 117 | def tan(self): ... 118 | def asin(self): ... 119 | def acos(self): ... 120 | def atan(self): ... 121 | def sinh(self): ... 122 | def cosh(self): ... 123 | def tanh(self): ... 124 | def asinh(self): ... 125 | def acosh(self): ... 126 | def atanh(self): ... 127 | def log(self): ... 128 | def log10(self): ... 129 | def exp(self): ... 130 | def exp10(self): ... 131 | def erode(self, mask): ... 132 | def dilate(self, mask): ... 133 | def median(self, size): ... 134 | def fliphor(self): ... 135 | def flipver(self): ... 136 | def rot90(self): ... 137 | def rot180(self): ... 138 | def rot270(self): ... 139 | def hasalpha(self): ... 140 | def addalpha(self): ... 141 | def ifthenelse(self, in1, in2, **kwargs): ... 142 | def scaleimage(self, **kwargs): ... 143 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from tests.settings import STORAGES 5 | 6 | 7 | def pytest_sessionfinish(session, exitstatus): 8 | path = STORAGES["default"]["OPTIONS"]["location"] 9 | if os.path.exists(path): 10 | shutil.rmtree(path) 11 | -------------------------------------------------------------------------------- /tests/easy_images_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SmileyChris/django-easy-images/c01cb9a9822ec7c4a74c0f9835a687c396fbd7fd/tests/easy_images_tests/__init__.py -------------------------------------------------------------------------------- /tests/easy_images_tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Profile(models.Model): 5 | name = models.CharField(max_length=100) 6 | image = models.ImageField(upload_to="profile-images/") 7 | second_image = models.FileField(upload_to="profile-images/", null=True, blank=True) 8 | 9 | def __str__(self): 10 | return self.name 11 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | "default": { 3 | "ENGINE": "django.db.backends.sqlite3", 4 | "NAME": ":memory:", 5 | } 6 | } 7 | 8 | INSTALLED_APPS = [ 9 | "easy_images", 10 | "tests.easy_images_tests", 11 | ] 12 | 13 | STORAGES = { 14 | "default": { 15 | "BACKEND": "django.core.files.storage.FileSystemStorage", 16 | "OPTIONS": { 17 | "location": "/tmp/easy-images-tests/", 18 | }, 19 | } 20 | } 21 | USE_TZ = True 22 | SECRET_KEY = "test" 23 | 24 | TEMPLATES = [ 25 | { 26 | "BACKEND": "django.template.backends.django.DjangoTemplates", 27 | "APP_DIRS": True, # Allows finding templates in installed apps (like easy_images) 28 | "OPTIONS": { 29 | "context_processors": [ 30 | "django.template.context_processors.debug", 31 | "django.template.context_processors.request", 32 | "django.contrib.auth.context_processors.auth", 33 | "django.contrib.messages.context_processors.messages", 34 | ], 35 | # Ensure easy_images tags are loaded automatically if needed, 36 | # or rely on {% load easy_images %} in templates. APP_DIRS=True is key. 37 | }, 38 | } 39 | ] 40 | SECRET_KEY = "test" 41 | -------------------------------------------------------------------------------- /tests/test_command.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | from unittest import mock 3 | 4 | import pytest 5 | from django.core.files.storage import default_storage 6 | from django.core.files.uploadedfile import InMemoryUploadedFile, TemporaryUploadedFile 7 | from django.core.management import call_command 8 | from django.test import override_settings 9 | 10 | from easy_images.engine import vips_to_django 11 | from easy_images.models import EasyImage, ImageStatus, get_storage_name 12 | from pyvips import Image 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_empty(): 17 | test_output = StringIO() 18 | call_command("build_img_queue", stdout=test_output) 19 | assert test_output.getvalue() == ( 20 | """Building queued thumbnails... 21 | No thumbnails required building 22 | """ 23 | ) 24 | 25 | 26 | @pytest.mark.django_db 27 | def test_queue(): 28 | EasyImage.objects.create(args={}, name="1") 29 | EasyImage.objects.create(status=ImageStatus.BUILDING, args={}, name="3") 30 | EasyImage.objects.create(status=ImageStatus.BUILD_ERROR, args={}, name="4") 31 | EasyImage.objects.create(status=ImageStatus.SOURCE_ERROR, args={}, name="5") 32 | for name in "678": 33 | EasyImage.objects.create( 34 | image="test", 35 | status=ImageStatus.BUILT, 36 | width=800, 37 | height=600, 38 | args={}, 39 | name=name, 40 | ) 41 | EasyImage.objects.create(args={}, name="2") 42 | test_output = StringIO() 43 | with mock.patch("easy_images.models.EasyImage.build", return_value=True): 44 | call_command("build_img_queue", stdout=test_output) 45 | assert test_output.getvalue() == ( 46 | """Building queued thumbnails... 47 | Skipping 1 marked as already building... 48 | Skipping 1 with source errors... 49 | Skipping 1 with build errors... 50 | Built 2 thumbnails 51 | """ 52 | ) 53 | 54 | 55 | @pytest.mark.django_db 56 | def test_retry(): 57 | EasyImage.objects.create(args={}, name="1") 58 | EasyImage.objects.create(status=ImageStatus.BUILDING, args={}, name="2") 59 | EasyImage.objects.create( 60 | status=ImageStatus.BUILD_ERROR, args={}, name="3", error_count=1 61 | ) 62 | EasyImage.objects.create( 63 | status=ImageStatus.SOURCE_ERROR, args={}, name="4", error_count=2 64 | ) 65 | for name in "567": 66 | EasyImage.objects.create( 67 | image="test", 68 | status=ImageStatus.BUILT, 69 | width=800, 70 | height=600, 71 | args={}, 72 | name=name, 73 | ) 74 | test_output = StringIO() 75 | with mock.patch("easy_images.models.EasyImage.build", return_value=True): 76 | call_command("build_img_queue", stdout=test_output, retry=1) 77 | assert test_output.getvalue() == ( 78 | """Building queued thumbnails... 79 | Skipping 1 marked as already building... 80 | Retrying 0 with source errors (1 with more than 1 retries skipped)... 81 | Retrying 1 with build errors... 82 | Built 2 thumbnails 83 | """ 84 | ) 85 | 86 | 87 | @pytest.mark.django_db 88 | def test_force(): 89 | EasyImage.objects.create(args={}, name="1") 90 | EasyImage.objects.create(status=ImageStatus.BUILDING, args={}, name="2") 91 | EasyImage.objects.create(status=ImageStatus.BUILD_ERROR, args={}, name="3") 92 | EasyImage.objects.create(status=ImageStatus.SOURCE_ERROR, args={}, name="4") 93 | for name in "567": 94 | EasyImage.objects.create( 95 | image="test", 96 | status=ImageStatus.BUILT, 97 | width=800, 98 | height=600, 99 | args={}, 100 | name=name, 101 | ) 102 | test_output = StringIO() 103 | with mock.patch("easy_images.models.EasyImage.build", return_value=True): 104 | call_command("build_img_queue", stdout=test_output, force=True) 105 | assert test_output.getvalue() == ( 106 | """Building queued thumbnails... 107 | Built 4 thumbnails 108 | """ 109 | ) 110 | 111 | 112 | def _create_easyimage(): 113 | image = Image.black(1000, 1000) 114 | file = vips_to_django(image, "test.jpg") 115 | name = default_storage.save("test.jpg", file) 116 | img = EasyImage.objects.create( 117 | storage=get_storage_name(default_storage), 118 | name=name, 119 | args={"width": 200, "ratio": 1}, 120 | ) 121 | return img, file 122 | 123 | 124 | @pytest.mark.django_db 125 | def test_build(): 126 | img, file = _create_easyimage() 127 | assert isinstance(file, TemporaryUploadedFile) 128 | file.close() 129 | img.build() 130 | assert img.image 131 | assert (img.width, img.height) == (200, 200) 132 | 133 | 134 | @pytest.mark.django_db 135 | @override_settings(FILE_UPLOAD_TEMP_DIR="/") 136 | def test_build_via_memory(): 137 | img, file = _create_easyimage() 138 | assert isinstance(file, InMemoryUploadedFile) 139 | img.build() 140 | assert img.image 141 | assert (img.width, img.height) == (200, 200) 142 | -------------------------------------------------------------------------------- /tests/test_core.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, Mock, patch # Import Mock 2 | 3 | import pytest 4 | from django.db.models import FileField 5 | from django.db.models.fields.files import FieldFile 6 | 7 | from easy_images.core import ImageBatch, Img # Import ImageBatch 8 | from easy_images.models import EasyImage 9 | 10 | 11 | # Remove patch decorator - mock storage inside test 12 | @pytest.mark.django_db 13 | @patch("easy_images.models.get_storage_name", return_value="default") 14 | def test_as_html_fallback(mock_get_storage): 15 | """Test HTML generation before loading/building (uses fallback URL).""" 16 | # Create mock storage and assign URL 17 | mock_storage = Mock() 18 | mock_storage.url.return_value = "/test.jpg" 19 | 20 | generator = Img(width=100) 21 | source = FieldFile(instance=EasyImage(), field=FileField(), name="test.jpg") 22 | source.storage = mock_storage # Assign mock storage to instance 23 | # Expect src attribute because base_url falls back to source.url 24 | assert generator(source).as_html() == '' 25 | 26 | 27 | @pytest.mark.django_db # Add DB access mark 28 | @patch.object(ImageBatch, "get_image") # Mock get_image 29 | @patch.object(ImageBatch, "_ensure_loaded", return_value=None) # Mock _ensure_loaded 30 | def test_as_html_loaded(mock_ensure_loaded, mock_get_image): 31 | """Test HTML generation after images are theoretically loaded.""" 32 | # --- Setup Mocks --- 33 | # Mock EasyImage instances 34 | mock_base = MagicMock(spec=EasyImage) 35 | mock_base.image.url = "/img/base.jpg" 36 | mock_base.width = 100 37 | mock_base.height = 56 # Assuming 16:9 ratio for width 100 38 | 39 | mock_srcset1 = MagicMock(spec=EasyImage) 40 | mock_srcset1.image.url = "/img/srcset1.webp" 41 | mock_srcset1.width = 100 # Assuming 1x density 42 | mock_srcset1.height = 56 43 | 44 | mock_srcset2 = MagicMock(spec=EasyImage) 45 | mock_srcset2.image.url = "/img/srcset2.webp" 46 | mock_srcset2.width = 200 # Assuming 2x density 47 | mock_srcset2.height = 112 48 | 49 | # --- Test Logic --- 50 | generator = Img( 51 | width=100, format="webp", densities=[1, 2] 52 | ) # Use densities for simpler srcset 53 | source = FieldFile(instance=EasyImage(), field=FileField(), name="test.jpg") 54 | # No need to mock source.url here as get_image is mocked 55 | 56 | bound_img = generator(source) 57 | # Manually set the _requests data for the mock, as add() wasn't fully run 58 | # This is a simplification for the test. 59 | dummy_base_pk = "00000000-0000-0000-0000-000000000001" 60 | dummy_srcset1_pk = "00000000-0000-0000-0000-000000000002" 61 | dummy_srcset2_pk = "00000000-0000-0000-0000-000000000003" 62 | bound_img._parent_batch._requests[bound_img._request_id] = { 63 | "base_pk": dummy_base_pk, 64 | "srcset_pks": [dummy_srcset1_pk, dummy_srcset2_pk], 65 | "srcset_pk_options": { # Need this for srcset generation in as_html 66 | dummy_srcset1_pk: {"srcset_width": 100}, 67 | dummy_srcset2_pk: {"srcset_width": 200}, 68 | }, 69 | "alt": "", # Default alt 70 | "sizes_attr": "", # No sizes defined 71 | "source_name_fallback": "test.jpg", 72 | "storage_name": "default", # Assume default storage 73 | } 74 | 75 | # Configure the mock to return mocks based on these dummy PKs 76 | def get_image_side_effect(pk): 77 | if pk == dummy_base_pk: 78 | return mock_base 79 | if pk == dummy_srcset1_pk: 80 | return mock_srcset1 81 | if pk == dummy_srcset2_pk: 82 | return mock_srcset2 83 | return None 84 | 85 | mock_get_image.side_effect = get_image_side_effect 86 | 87 | html_output = bound_img.as_html() 88 | 89 | # --- Assertions --- 90 | mock_ensure_loaded.assert_called() # Ensure loading was triggered at least once 91 | assert mock_get_image.call_count >= 3 # Base + 2 srcset items 92 | 93 | # Check the generated HTML 94 | assert 'src="/img/base.jpg"' in html_output 95 | assert 'alt=""' in html_output 96 | assert 'srcset="' in html_output 97 | # Order in srcset might vary, check parts 98 | assert "/img/srcset1.webp 100w" in html_output 99 | assert "/img/srcset2.webp 200w" in html_output 100 | assert 'width="100"' in html_output 101 | assert 'height="56"' in html_output 102 | 103 | 104 | # Remove patch decorator - mock storage inside test 105 | @pytest.mark.django_db 106 | @patch("easy_images.models.get_storage_name", return_value="default") 107 | def test_sizes_fallback(mock_get_storage): 108 | """Test sizes attribute generation before loading.""" 109 | # Create mock storage and assign URL 110 | mock_storage = Mock() 111 | mock_storage.url.return_value = "/test.jpg" 112 | 113 | generator = Img(width=200, sizes={800: 100}) 114 | source = FieldFile(instance=EasyImage(), field=FileField(), name="test.jpg") 115 | source.storage = mock_storage # Assign mock storage to instance 116 | # Expect src (fallback) and sizes attributes 117 | assert ( 118 | generator(source).as_html() 119 | == '' 120 | ) 121 | 122 | 123 | @pytest.mark.django_db # Add DB access mark 124 | @patch.object(ImageBatch, "get_image") # Mock get_image 125 | @patch.object(ImageBatch, "_ensure_loaded", return_value=None) # Mock _ensure_loaded 126 | def test_sizes_loaded(mock_ensure_loaded, mock_get_image): 127 | """Test HTML generation with sizes after images are theoretically loaded.""" 128 | # --- Setup Mocks --- 129 | mock_base = MagicMock(spec=EasyImage) 130 | mock_base.image.url = "/img/base_200.jpg" 131 | mock_base.width = 200 132 | mock_base.height = 112 # 16:9 133 | 134 | mock_size100 = MagicMock(spec=EasyImage) 135 | mock_size100.image.url = "/img/size_100.webp" 136 | mock_size100.width = 100 137 | mock_size100.height = 56 138 | 139 | mock_size200 = MagicMock(spec=EasyImage) 140 | mock_size200.image.url = "/img/size_200.webp" 141 | mock_size200.width = 200 142 | mock_size200.height = 112 143 | 144 | mock_size400 = MagicMock(spec=EasyImage) # For 2x density of max width (200) 145 | mock_size400.image.url = "/img/size_400.webp" 146 | mock_size400.width = 400 147 | mock_size400.height = 225 148 | 149 | # --- Test Logic --- 150 | generator = Img(width=200, sizes={800: 100}) # Default densities=[2], format='webp' 151 | source = FieldFile(instance=EasyImage(), field=FileField(), name="test.jpg") 152 | # No need to mock source.url here 153 | 154 | bound_img = generator(source) 155 | # Manually set the _requests data for the mock 156 | dummy_base_pk = "10000000-0000-0000-0000-000000000001" 157 | dummy_size100_pk = "10000000-0000-0000-0000-000000000002" 158 | dummy_size200_pk = "10000000-0000-0000-0000-000000000003" # Default size 159 | dummy_size400_pk = "10000000-0000-0000-0000-000000000004" # High density 160 | bound_img._parent_batch._requests[bound_img._request_id] = { 161 | "base_pk": dummy_base_pk, 162 | "srcset_pks": [dummy_size100_pk, dummy_size200_pk, dummy_size400_pk], 163 | "srcset_pk_options": { 164 | dummy_size100_pk: {"srcset_width": 100}, 165 | dummy_size200_pk: {"srcset_width": 200}, 166 | dummy_size400_pk: {"srcset_width": 400, "width_multiplier": 2.0}, 167 | }, 168 | "alt": "", 169 | "sizes_attr": "(max-width: 800px) 100px, 200px", 170 | "source_name_fallback": "test.jpg", 171 | "storage_name": "default", 172 | } 173 | 174 | # Configure mock side effect 175 | def get_image_side_effect_sizes(pk): 176 | if pk == dummy_base_pk: 177 | return mock_base 178 | if pk == dummy_size100_pk: 179 | return mock_size100 180 | if pk == dummy_size200_pk: 181 | return mock_size200 182 | if pk == dummy_size400_pk: 183 | return mock_size400 184 | return None 185 | 186 | mock_get_image.side_effect = get_image_side_effect_sizes 187 | 188 | html_output = bound_img.as_html() 189 | 190 | # --- Assertions --- 191 | mock_ensure_loaded.assert_called() # Ensure loading was triggered at least once 192 | assert mock_get_image.call_count >= 4 # Base + 3 srcset images 193 | 194 | assert 'src="/img/base_200.jpg"' in html_output 195 | assert 'alt=""' in html_output 196 | assert 'sizes="(max-width: 800px) 100px, 200px"' in html_output 197 | assert 'srcset="' in html_output 198 | # Check srcset parts (order might vary) 199 | assert "/img/size_100.webp 100w" in html_output 200 | assert "/img/size_200.webp 200w" in html_output 201 | assert "/img/size_400.webp 400w" in html_output # High density based on max width 202 | assert 'width="200"' in html_output 203 | assert 'height="112"' in html_output 204 | -------------------------------------------------------------------------------- /tests/test_engine.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from pathlib import Path 3 | 4 | from django.core.files.uploadedfile import ( 5 | SimpleUploadedFile, 6 | ) 7 | 8 | from easy_images.engine import efficient_load, scale_image 9 | from easy_images.options import ParsedOptions 10 | from pyvips import Image 11 | 12 | 13 | def test_efficient_load(): 14 | """ 15 | Efficiently loaded image should be at least 3x the largest target size. 16 | """ 17 | # Create a test image 18 | image = Image.black(1000, 1000) 19 | with tempfile.TemporaryDirectory() as tmpdir: 20 | image_path = Path(tmpdir) / "test.jpg" 21 | image.write_to_file(image_path) 22 | # Test against a few target sizes 23 | e_image = efficient_load( 24 | image_path, 25 | [ 26 | ParsedOptions(width=100, ratio="video"), 27 | ParsedOptions(width=50, ratio="video"), 28 | ], 29 | ) 30 | assert (e_image.width, e_image.height) == (500, 500) 31 | e_image = efficient_load( 32 | image_path, 33 | [ 34 | ParsedOptions(width=30, ratio="video_vertical"), 35 | ParsedOptions(width=30, ratio="square"), 36 | ], 37 | ) 38 | assert (e_image.width, e_image.height) == (250, 250) 39 | # Test against a percentage target size 40 | e_image = efficient_load(image_path, [ParsedOptions(width=10, ratio="square")]) 41 | assert (e_image.width, e_image.height) == (125, 125) 42 | # Test against a large target size 43 | e_image = efficient_load( 44 | image_path, [ParsedOptions(width=5000, ratio="square")] 45 | ) 46 | assert (e_image.width, e_image.height) == (1000, 1000) 47 | 48 | 49 | def test_efficient_load_from_memory(): 50 | image = Image.black(1000, 1000) 51 | file = SimpleUploadedFile("test.jpg", image.write_to_buffer(".jpg[Q=90]")) 52 | e_image = efficient_load(file, [ParsedOptions(width=100, ratio="video")]) 53 | assert (e_image.width, e_image.height) == (500, 500) 54 | 55 | 56 | def test_scale(): 57 | source = Image.black(1000, 1000) 58 | scaled_cover = scale_image(source, (400, 500)) 59 | assert (scaled_cover.width, scaled_cover.height) == (500, 500) 60 | scaled = scale_image(source, (400, 500), contain=True) 61 | assert (scaled.width, scaled.height) == (400, 400) 62 | cropped = scale_image(source, (400, 500), crop=True) 63 | assert (cropped.width, cropped.height) == (400, 500) 64 | 65 | small_src = Image.black(100, 100) 66 | cropped_upscale = scale_image(small_src, (400, 500), crop=True) 67 | assert (cropped_upscale.width, cropped_upscale.height) == (400, 500) 68 | 69 | scaled_not_upscale = scale_image(small_src, (400, 500), contain=True) 70 | assert (scaled_not_upscale.width, scaled_not_upscale.height) == (100, 100) 71 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | import pytest 4 | from django.core.files.uploadedfile import SimpleUploadedFile 5 | 6 | from easy_images.core import Img 7 | from easy_images.models import ( 8 | EasyImage, 9 | ImageStatus, 10 | get_storage_name, 11 | pick_image_storage, 12 | ) 13 | from pyvips.vimage import Image 14 | from tests.easy_images_tests.models import Profile 15 | 16 | thumbnail = Img(width=200, format="jpg") 17 | 18 | 19 | @pytest.mark.django_db 20 | def test_build_bad_source(): 21 | storage = pick_image_storage() 22 | name = storage.save("bad_source.jpg", BytesIO(b"bad image data")) 23 | storage_name = get_storage_name(storage) 24 | image = EasyImage.objects.create( 25 | args={"width": 100}, name=name, storage=storage_name 26 | ) 27 | assert image.status == ImageStatus.QUEUED 28 | image.build() 29 | assert image.status == ImageStatus.SOURCE_ERROR 30 | assert not image.image 31 | assert image.error_count == 1 32 | 33 | 34 | @pytest.mark.django_db 35 | def test_build_no_source(): 36 | storage = pick_image_storage() 37 | storage_name = get_storage_name(storage) 38 | image = EasyImage.objects.create( 39 | args={"width": 100}, name="notafile.jpg", storage=storage_name 40 | ) 41 | assert image.status == ImageStatus.QUEUED 42 | image.build() 43 | assert image.status == ImageStatus.SOURCE_ERROR 44 | assert image.error_count == 1 45 | 46 | 47 | @pytest.mark.django_db 48 | def test_build_from_filefield(): 49 | image = Image.black(1000, 1000) 50 | file = SimpleUploadedFile("test.png", image.write_to_buffer(".png[Q=90]")) 51 | profile = Profile.objects.create(name="Test", image=file) 52 | 53 | thumb = thumbnail(profile.image) 54 | assert thumb.base_url() == "/profile-images/test.png" 55 | assert thumb.as_html() == '' 56 | 57 | 58 | @pytest.mark.django_db 59 | def test_build_from_filefield_with_build(): 60 | image = Image.black(1000, 1000) 61 | file = SimpleUploadedFile("test.png", image.write_to_buffer(".png[Q=90]")) 62 | profile = Profile.objects.create(name="Test", image=file) 63 | 64 | thumb = thumbnail(profile.image, build="src") 65 | assert thumb.base_url().endswith(".png") 66 | assert thumb.as_html() == f'' 67 | -------------------------------------------------------------------------------- /tests/test_options.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from easy_images.options import ParsedOptions 4 | 5 | 6 | def test_parsed_options_initialization(): 7 | options = ParsedOptions(width=100, quality=80) 8 | assert options.width == 100 9 | assert options.quality == 80 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "ratio, expected_size", 14 | [ 15 | ("video", (100, 56)), 16 | ("square", (100, 100)), 17 | ("golden_vertical", (100, 161)), 18 | ("3/4", (100, 133)), 19 | ], 20 | ) 21 | def test_size(ratio, expected_size): 22 | options = ParsedOptions(width=100, ratio=ratio) 23 | assert options.size == expected_size 24 | 25 | 26 | def test_size_with_multiplier(): 27 | options = ParsedOptions(width=100, ratio="video", width_multiplier=2) 28 | assert options.size == (200, 112) 29 | 30 | 31 | @pytest.mark.parametrize( 32 | "croption, expected_crop", 33 | [ 34 | (True, (0.5, 0.5)), 35 | (False, None), 36 | ("0.5,.5", (0.5, 0.5)), 37 | ("center", (0.5, 0.5)), 38 | ("t", (0.5, 0)), 39 | ("r", (1, 0.5)), 40 | ("bl", (0, 1)), 41 | ], 42 | ) 43 | def test_crop(croption, expected_crop): 44 | options = ParsedOptions(crop=croption) 45 | assert options.crop == expected_crop 46 | 47 | 48 | def test_hash(): 49 | assert ( 50 | ParsedOptions(quality=80).hash().hexdigest() 51 | == "cce6431a80fe3a84c7ea9f6c5293cbce4ed8848349bb0f2182eb6bb0d7a19f78" 52 | ) 53 | 54 | 55 | def test_str(): 56 | assert ( 57 | str(ParsedOptions(width=100, ratio="video")) 58 | == '{"crop": null, "mimetype": null, "quality": 80, "ratio": 1.7777777777777777, "width": 100, "window": null}' 59 | ) 60 | assert ( 61 | str(ParsedOptions(width=100, contain=True)) 62 | == '{"contain": true, "crop": null, "mimetype": null, "quality": 80, "ratio": null, "width": 100, "window": null}' 63 | ) 64 | -------------------------------------------------------------------------------- /tests/test_signals.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Iterator 3 | from unittest.mock import MagicMock 4 | 5 | import pytest 6 | from django.core.files.uploadedfile import SimpleUploadedFile 7 | from pytest import FixtureRequest # Import from public API 8 | 9 | import pyvips 10 | from easy_images import Img 11 | from easy_images.models import EasyImage 12 | from easy_images.signals import ( 13 | file_post_save, 14 | queued_img, 15 | ) 16 | from easy_images.types_ import ( 17 | BuildChoices, 18 | ImgOptions, 19 | ) 20 | from tests.easy_images_tests.models import Profile 21 | 22 | 23 | @pytest.fixture 24 | def profile_queue_img(request: FixtureRequest) -> Iterator[Img]: 25 | """ 26 | Fixture to set up and tear down the Img.queue signal connection for Profile model. 27 | Handles different build options via indirect parametrization if needed, 28 | or can be used directly for default setup. 29 | """ 30 | # Get build option if parametrized 31 | build_option: BuildChoices | None = getattr(request, "param", None) 32 | 33 | # Define standard Img options used across tests 34 | # test_queue and test_queued_img_signal use densities=[] 35 | # test_queue_with_build_src uses densities=[] 36 | # test_queue_with_build_srcset uses default densities 37 | img_options: ImgOptions = {"width": 100} 38 | if build_option is None or build_option == "src": 39 | # Ensure the key exists before assigning, although ImgOptions allows partials 40 | img_options["densities"] = [] 41 | 42 | # Unpack the TypedDict directly 43 | img = Img(**img_options) 44 | 45 | # Generate a unique ID for this specific handler connection 46 | handler_uid = f"test_handler_{uuid.uuid4().hex}" 47 | 48 | # Connect the signal handler using the unique ID 49 | # This requires Img.queue to accept dispatch_uid (added in previous step) 50 | img.queue(Profile, build=build_option, fields=None, dispatch_uid=handler_uid) 51 | 52 | yield img # Provide the configured Img instance to the test 53 | 54 | # Teardown: Disconnect the specific handler using its unique ID 55 | disconnected = file_post_save.disconnect(dispatch_uid=handler_uid, sender=Profile) 56 | assert disconnected, f"Failed to disconnect signal handler with UID {handler_uid}" 57 | 58 | 59 | @pytest.mark.django_db 60 | def test_queue(profile_queue_img): 61 | """Test that the queue mechanism triggers the signal without building.""" 62 | # img and queue setup is handled by the fixture 63 | 64 | handler = MagicMock() 65 | queued_img.connect(handler) 66 | 67 | Profile.objects.create( 68 | name="Test", image=SimpleUploadedFile(name="test.jpg", content=b"123") 69 | ) 70 | 71 | # Assert the signal was called, indicating queuing happened 72 | assert handler.called 73 | # Assert no objects were actually created in DB yet due to lazy loading 74 | assert EasyImage.objects.count() == 0 75 | 76 | 77 | @pytest.mark.parametrize("profile_queue_img", ["src"], indirect=True) 78 | @pytest.mark.django_db 79 | def test_queue_with_build_src(profile_queue_img): 80 | """Test queuing with immediate build of source (base) image.""" 81 | # img and queue setup is handled by the fixture 82 | content = pyvips.Image.black(200, 200).write_to_buffer(".jpg") 83 | Profile.objects.create( 84 | name="Test", image=SimpleUploadedFile(name="test.jpg", content=content) 85 | ) 86 | 87 | # Only the base image record should be created and built 88 | assert EasyImage.objects.count() == 1 89 | assert EasyImage.objects.filter(image="").count() == 0 # Base image should be built 90 | 91 | 92 | @pytest.mark.parametrize("profile_queue_img", ["srcset"], indirect=True) 93 | @pytest.mark.django_db 94 | def test_queue_with_build_srcset(profile_queue_img): 95 | """Test queuing with immediate build of srcset images only.""" 96 | # img (with default densities) and queue setup is handled by the fixture 97 | content = pyvips.Image.black(200, 200).write_to_buffer(".jpg") 98 | 99 | # Get PKs before creation 100 | pks_before = set(EasyImage.objects.values_list("pk", flat=True)) 101 | 102 | # Create profile, triggering signal and build="srcset" 103 | profile = Profile.objects.create( 104 | name="Test", image=SimpleUploadedFile(name="test.jpg", content=content) 105 | ) 106 | 107 | # Get PKs after creation 108 | pks_after = set(EasyImage.objects.values_list("pk", flat=True)) 109 | new_pks = pks_after - pks_before 110 | 111 | # Assert 3 new EasyImage objects were created (base, webp 1x, webp 2x) 112 | assert len(new_pks) == 3, f"Expected 3 new EasyImages, found {len(new_pks)}" 113 | 114 | # Find the base PK among the newly created ones 115 | bound_img = profile_queue_img(profile.image) # Get BoundImg to access _base_pk 116 | base_pk = bound_img._parent_batch._requests[bound_img._request_id]["base_pk"] 117 | assert base_pk in new_pks, "Base PK not found among newly created PKs" 118 | 119 | # Assert only the base image among the new ones is unbuilt 120 | unbuilt_new_images = EasyImage.objects.filter(pk__in=new_pks, image="") 121 | assert unbuilt_new_images.count() == 1, "Expected 1 unbuilt new image" 122 | unbuilt_first = unbuilt_new_images.first() 123 | assert unbuilt_first is not None, "QuerySet returned None unexpectedly" 124 | assert unbuilt_first.pk == base_pk, "The unbuilt image was not the base image" 125 | 126 | 127 | @pytest.mark.django_db 128 | def test_queued_img_signal(profile_queue_img): 129 | """Test the queued_img signal is dispatched correctly when needed.""" 130 | # img and queue setup is handled by the fixture 131 | 132 | handler = MagicMock() 133 | queued_img.connect(handler) 134 | 135 | Profile.objects.create( 136 | name="Test", image=SimpleUploadedFile(name="test.jpg", content=b"123") 137 | ) 138 | # .queue is triggered, which triggers the queued_img signal 139 | assert handler.called 140 | 141 | 142 | @pytest.mark.django_db 143 | def test_imagefield_signal_trigger(profile_queue_img): 144 | """Test that saving a model with an ImageField triggers the queue signal.""" 145 | # img and queue setup is handled by the fixture 146 | 147 | handler = MagicMock() 148 | queued_img.connect(handler) 149 | 150 | # Use a minimal valid image content 151 | content = pyvips.Image.black(10, 10).write_to_buffer(".jpg") 152 | Profile.objects.create( 153 | name="ImageField Test", 154 | image=SimpleUploadedFile(name="test_img.jpg", content=content), 155 | ) 156 | 157 | # Assert the signal was called, indicating queuing happened for ImageField 158 | assert handler.called 159 | # Assert no objects were actually created in DB yet due to lazy loading (default behavior) 160 | assert EasyImage.objects.count() == 0 161 | -------------------------------------------------------------------------------- /tests/test_templatetags.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock, patch 2 | 3 | import pytest 4 | from django.template import Context, Template, TemplateSyntaxError 5 | from django.utils.safestring import mark_safe 6 | 7 | 8 | # Mock the Img class and its methods to avoid actual image processing 9 | @patch("easy_images.templatetags.easy_images.Img") 10 | def test_basic_img_tag(MockImg): 11 | # Setup mock return value for as_html() 12 | mock_img_instance = MockImg.return_value 13 | mock_render_instance = mock_img_instance.return_value 14 | mock_render_instance.as_html.return_value = 'Test Alt' 15 | 16 | template_string = """ 17 | {% load easy_images %} 18 | {% img mock_file alt="Test Alt" width=100 %} 19 | """ 20 | template = Template(template_string) 21 | context = Context({"mock_file": "path/to/image.jpg"}) 22 | rendered = template.render(context) 23 | 24 | # Assert that Img was called correctly 25 | MockImg.assert_called_once_with(width=100, quality=80, contain=False, img_attrs={}) 26 | mock_img_instance.assert_called_once_with("path/to/image.jpg", alt="Test Alt") 27 | mock_render_instance.as_html.assert_called_once() 28 | 29 | # Assert the rendered output 30 | assert rendered.strip() == 'Test Alt' 31 | 32 | 33 | @patch("easy_images.templatetags.easy_images.Img") 34 | def test_img_tag_as_variable(MockImg): 35 | mock_img_instance = MockImg.return_value 36 | mock_render_instance = mock_img_instance.return_value 37 | # Mark the mocked HTML as safe to prevent auto-escaping 38 | mock_render_instance.as_html.return_value = mark_safe( 39 | 'As Var Test' 40 | ) 41 | 42 | template_string = """ 43 | {% load easy_images %} 44 | {% img mock_file alt="As Var Test" width=50 as my_image %} 45 |

Image: {{ my_image }}

46 | """ 47 | template = Template(template_string) 48 | context = Context({"mock_file": "path/to/image_var.jpg"}) 49 | rendered = template.render(context) 50 | 51 | MockImg.assert_called_once_with(width=50, quality=80, contain=False, img_attrs={}) 52 | mock_img_instance.assert_called_once_with( 53 | "path/to/image_var.jpg", alt="As Var Test" 54 | ) 55 | mock_render_instance.as_html.assert_called_once() 56 | 57 | assert ( 58 | '

Image: As Var Test

' 59 | in rendered.strip() 60 | ) 61 | assert "my_image" in context 62 | 63 | 64 | def test_img_tag_missing_alt(): 65 | template_string = """ 66 | {% load easy_images %} 67 | {% img mock_file width=100 %} 68 | """ 69 | with pytest.raises(TemplateSyntaxError, match="tag requires an alt attribute"): 70 | Template(template_string) 71 | 72 | 73 | def test_img_tag_missing_file(): 74 | template_string = """ 75 | {% load easy_images %} 76 | {% img %} 77 | """ 78 | with pytest.raises(TemplateSyntaxError, match="tag requires a field file"): 79 | Template(template_string) 80 | 81 | 82 | @patch("easy_images.templatetags.easy_images.Img") 83 | def test_img_tag_with_img_attrs(MockImg): 84 | mock_img_instance = MockImg.return_value 85 | mock_render_instance = mock_img_instance.return_value 86 | mock_render_instance.as_html.return_value = ( 87 | 'Attrs Test' 88 | ) 89 | 90 | template_string = """ 91 | {% load easy_images %} 92 | {# Use underscore as token_kwargs seems to handle that better #} 93 | {% img mock_file alt="Attrs Test" img_class="test-class" img_data_id=123 %} 94 | """ 95 | template = Template(template_string) 96 | context = Context({"mock_file": "path/to/image_attrs.jpg"}) 97 | rendered = template.render(context) 98 | 99 | # Check the actual call arguments directly 100 | MockImg.assert_called_once() # Check it was called once 101 | args, kwargs = MockImg.call_args 102 | assert args == () 103 | assert kwargs.get("quality") == 80 104 | assert kwargs.get("contain") is False 105 | expected_img_attrs = {"class": "test-class", "data-id": "123"} 106 | actual_img_attrs = kwargs.get("img_attrs") 107 | assert actual_img_attrs == expected_img_attrs, ( 108 | f"Expected img_attrs {expected_img_attrs}, got {actual_img_attrs}" 109 | ) 110 | mock_img_instance.assert_called_once_with( 111 | "path/to/image_attrs.jpg", alt="Attrs Test" 112 | ) 113 | mock_render_instance.as_html.assert_called_once() 114 | 115 | assert ( 116 | rendered.strip() 117 | == 'Attrs Test' 118 | ) 119 | 120 | 121 | @patch("easy_images.templatetags.easy_images.Img") 122 | def test_img_tag_with_densities(MockImg): 123 | mock_img_instance = MockImg.return_value 124 | mock_render_instance = mock_img_instance.return_value 125 | mock_render_instance.as_html.return_value = ( 126 | 'Densities Test' # Simplified 127 | ) 128 | 129 | template_string_str = """ 130 | {% load easy_images %} 131 | {% img mock_file alt="Densities Test" densities="1.5,2" %} 132 | """ 133 | template_str = Template(template_string_str) 134 | context = Context({"mock_file": "path/to/image_densities.jpg"}) 135 | rendered_str = template_str.render(context) 136 | 137 | MockImg.assert_called_once_with( 138 | densities=[1.5, 2.0], quality=80, contain=False, img_attrs={} 139 | ) 140 | mock_img_instance.assert_called_once_with( 141 | "path/to/image_densities.jpg", alt="Densities Test" 142 | ) 143 | mock_render_instance.as_html.assert_called_once() 144 | assert 'srcset="..."' in rendered_str # Basic check 145 | 146 | # Reset mocks for next call 147 | MockImg.reset_mock() 148 | mock_img_instance.reset_mock() 149 | mock_render_instance.reset_mock() 150 | mock_render_instance.as_html.return_value = ( 151 | 'Densities Test' # Simplified 152 | ) 153 | 154 | template_string_list = """ 155 | {% load easy_images %} 156 | {% img mock_file alt="Densities Test" densities=density_list %} 157 | """ 158 | template_list = Template(template_string_list) 159 | context_list = Context( 160 | {"mock_file": "path/to/image_densities.jpg", "density_list": [1.0, 3.0]} 161 | ) 162 | rendered_list = template_list.render(context_list) 163 | 164 | MockImg.assert_called_once_with( 165 | densities=[1.0, 3.0], quality=80, contain=False, img_attrs={} 166 | ) 167 | mock_img_instance.assert_called_once_with( 168 | "path/to/image_densities.jpg", alt="Densities Test" 169 | ) 170 | mock_render_instance.as_html.assert_called_once() 171 | assert 'srcset="..."' in rendered_list # Basic check 172 | 173 | 174 | @patch("easy_images.templatetags.easy_images.Img") 175 | def test_img_tag_with_sizes(MockImg): 176 | mock_img_instance = MockImg.return_value 177 | mock_render_instance = mock_img_instance.return_value 178 | mock_render_instance.as_html.return_value = 'Sizes Test' # Simplified 179 | 180 | template_string = """ 181 | {% load easy_images %} 182 | {% img mock_file alt="Sizes Test" size="600,300" size="large,500" %} 183 | """ 184 | template = Template(template_string) 185 | context = Context({"mock_file": "path/to/image_sizes.jpg"}) 186 | rendered = template.render(context) 187 | 188 | # Django template kwargs only keep the LAST value for repeated keys 189 | expected_sizes_last_only = {"large": 500} 190 | MockImg.assert_called_once_with( 191 | sizes=expected_sizes_last_only, quality=80, contain=False, img_attrs={} 192 | ) 193 | mock_img_instance.assert_called_once_with( 194 | "path/to/image_sizes.jpg", alt="Sizes Test" 195 | ) 196 | mock_render_instance.as_html.assert_called_once() 197 | assert 'sizes="..."' in rendered # Basic check 198 | 199 | 200 | @patch("easy_images.templatetags.easy_images.Img") 201 | def test_img_tag_with_format(MockImg): 202 | mock_img_instance = MockImg.return_value 203 | mock_render_instance = mock_img_instance.return_value 204 | mock_render_instance.as_html.return_value = ( 205 | 'Format Test' # Simplified 206 | ) 207 | 208 | template_string = """ 209 | {% load easy_images %} 210 | {% img mock_file alt="Format Test" format="webp" %} 211 | """ 212 | template = Template(template_string) 213 | context = Context({"mock_file": "path/to/image_format.jpg"}) 214 | rendered = template.render(context) 215 | 216 | MockImg.assert_called_once_with( 217 | format="webp", quality=80, contain=False, img_attrs={} 218 | ) 219 | mock_img_instance.assert_called_once_with( 220 | "path/to/image_format.jpg", alt="Format Test" 221 | ) 222 | mock_render_instance.as_html.assert_called_once() 223 | assert 'src="mock_format.webp"' in rendered # Basic check 224 | 225 | 226 | @patch("easy_images.templatetags.easy_images.Img") 227 | def test_img_tag_invalid_option(MockImg): 228 | template_string = """ 229 | {% load easy_images %} 230 | {% img mock_file alt="Invalid Test" invalid_option="foo" %} 231 | """ 232 | template = Template(template_string) 233 | context = Context({"mock_file": "path/to/image_invalid.jpg"}) 234 | # Update the expected error message based on the new logic 235 | # ParsedOptions now raises the error during init 236 | # Expect error from the tag validation, not ParsedOptions 237 | with pytest.raises( 238 | ValueError, match="Unknown options passed to 'img' tag: invalid_option" 239 | ): 240 | template.render(context) 241 | 242 | 243 | @patch("easy_images.templatetags.easy_images.Img") 244 | def test_img_tag_invalid_size_format(MockImg): 245 | template_string = """ 246 | {% load easy_images %} 247 | {% img mock_file alt="Invalid Size" size="invalid" %} 248 | """ 249 | template = Template(template_string) 250 | context = Context({"mock_file": "path/to/image_invalid_size.jpg"}) 251 | with pytest.raises(ValueError, match="size must be a string with a comma"): 252 | template.render(context) 253 | 254 | 255 | # Test case for passing an Img instance (less common usage) 256 | @patch("easy_images.templatetags.easy_images.Img", new_callable=MagicMock) 257 | def test_img_tag_with_img_instance(MockImgClass): 258 | # Mock the instance passed in the context 259 | mock_context_img_instance = MagicMock() 260 | mock_render_instance = mock_context_img_instance.return_value 261 | mock_render_instance.as_html.return_value = ( 262 | 'Instance Test' 263 | ) 264 | 265 | template_string = """ 266 | {% load easy_images %} 267 | {% img mock_file my_img_instance alt="Instance Test" %} 268 | """ 269 | template = Template(template_string) 270 | # Pass the mocked Img instance creator in the context 271 | context = Context( 272 | { 273 | "mock_file": "path/to/image_instance.jpg", 274 | "my_img_instance": mock_context_img_instance, 275 | } 276 | ) 277 | rendered = template.render(context) 278 | 279 | # Assert that the Img class itself wasn't called to create a new instance 280 | MockImgClass.assert_not_called() 281 | # Assert that the instance from the context was used 282 | # When using a pre-configured instance, img_attrs from the tag are NOT passed 283 | # to the instance's __call__ method (as per Img.__call__ signature). 284 | mock_context_img_instance.assert_called_once_with( 285 | "path/to/image_instance.jpg", alt="Instance Test" 286 | ) 287 | mock_render_instance.as_html.assert_called_once() 288 | 289 | assert rendered.strip() == 'Instance Test' 290 | --------------------------------------------------------------------------------