├── .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 |
11 | ```
12 |
13 | But after the images are built, the HTML will be:
14 |
15 | ```html
16 |
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 | MacOs |
65 |
66 |
67 | brew install vips
68 |
69 | |
70 |
71 |
72 |
73 | Ubuntu |
74 |
75 |
76 | sudo apt-get install --no-install-recommends libvips
77 |
78 | |
79 |
80 |
81 |
82 | Arch |
83 |
84 |
85 | sudo pacman -S libvips
86 |
87 | |
88 |
89 |
90 |
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 |
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 = '
'
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() == '
'
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 | '
'
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: 
'
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 | '
'
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 | == '
'
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 | '
' # 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 | '
' # 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 = '
' # 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 | '
' # 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 | '
'
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() == '
'
290 |
--------------------------------------------------------------------------------