opposable-thumbs 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. opposable_thumbs-0.1.0/.gitignore +7 -0
  2. opposable_thumbs-0.1.0/AUTHORS +1 -0
  3. opposable_thumbs-0.1.0/CHANGELOG.md +31 -0
  4. opposable_thumbs-0.1.0/DEVELOP.md +286 -0
  5. opposable_thumbs-0.1.0/LICENCE +27 -0
  6. opposable_thumbs-0.1.0/PKG-INFO +148 -0
  7. opposable_thumbs-0.1.0/README.md +116 -0
  8. opposable_thumbs-0.1.0/docs/caching.md +77 -0
  9. opposable_thumbs-0.1.0/docs/configuration.md +150 -0
  10. opposable_thumbs-0.1.0/docs/django.md +76 -0
  11. opposable_thumbs-0.1.0/docs/flask.md +66 -0
  12. opposable_thumbs-0.1.0/docs/operations.md +120 -0
  13. opposable_thumbs-0.1.0/docs/other-frameworks.md +73 -0
  14. opposable_thumbs-0.1.0/docs/python.md +138 -0
  15. opposable_thumbs-0.1.0/pyproject.toml +85 -0
  16. opposable_thumbs-0.1.0/src/opposable_thumbs/__init__.py +25 -0
  17. opposable_thumbs-0.1.0/src/opposable_thumbs/__main__.py +3 -0
  18. opposable_thumbs-0.1.0/src/opposable_thumbs/cache.py +139 -0
  19. opposable_thumbs-0.1.0/src/opposable_thumbs/cli.py +50 -0
  20. opposable_thumbs-0.1.0/src/opposable_thumbs/config.py +104 -0
  21. opposable_thumbs-0.1.0/src/opposable_thumbs/django/__init__.py +13 -0
  22. opposable_thumbs-0.1.0/src/opposable_thumbs/django/apps.py +8 -0
  23. opposable_thumbs-0.1.0/src/opposable_thumbs/django/conf.py +34 -0
  24. opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/__init__.py +0 -0
  25. opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/commands/__init__.py +0 -0
  26. opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/commands/clean_opposable_cache.py +32 -0
  27. opposable_thumbs-0.1.0/src/opposable_thumbs/django/templatetags/__init__.py +0 -0
  28. opposable_thumbs-0.1.0/src/opposable_thumbs/django/templatetags/opposable_thumbs.py +16 -0
  29. opposable_thumbs-0.1.0/src/opposable_thumbs/django/urls.py +8 -0
  30. opposable_thumbs-0.1.0/src/opposable_thumbs/django/views.py +40 -0
  31. opposable_thumbs-0.1.0/src/opposable_thumbs/errors.py +15 -0
  32. opposable_thumbs-0.1.0/src/opposable_thumbs/flask.py +93 -0
  33. opposable_thumbs-0.1.0/src/opposable_thumbs/imaging.py +85 -0
  34. opposable_thumbs-0.1.0/src/opposable_thumbs/operations.py +452 -0
  35. opposable_thumbs-0.1.0/src/opposable_thumbs/py.typed +0 -0
  36. opposable_thumbs-0.1.0/src/opposable_thumbs/query.py +141 -0
  37. opposable_thumbs-0.1.0/src/opposable_thumbs/result.py +43 -0
  38. opposable_thumbs-0.1.0/src/opposable_thumbs/sources.py +109 -0
  39. opposable_thumbs-0.1.0/src/opposable_thumbs/thumbnail.py +452 -0
  40. opposable_thumbs-0.1.0/tests/conftest.py +82 -0
  41. opposable_thumbs-0.1.0/tests/helpers.py +34 -0
  42. opposable_thumbs-0.1.0/tests/test_cache.py +318 -0
  43. opposable_thumbs-0.1.0/tests/test_cli.py +58 -0
  44. opposable_thumbs-0.1.0/tests/test_decoding.py +138 -0
  45. opposable_thumbs-0.1.0/tests/test_django.py +149 -0
  46. opposable_thumbs-0.1.0/tests/test_docs.py +225 -0
  47. opposable_thumbs-0.1.0/tests/test_flask.py +116 -0
  48. opposable_thumbs-0.1.0/tests/test_formats.py +139 -0
  49. opposable_thumbs-0.1.0/tests/test_http.py +63 -0
  50. opposable_thumbs-0.1.0/tests/test_operations.py +258 -0
  51. opposable_thumbs-0.1.0/tests/test_params.py +125 -0
  52. opposable_thumbs-0.1.0/tests/test_playground.py +64 -0
  53. opposable_thumbs-0.1.0/tests/test_python_api.py +205 -0
  54. opposable_thumbs-0.1.0/tests/test_signing.py +123 -0
  55. opposable_thumbs-0.1.0/tests/test_sources.py +199 -0
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .DS_Store
@@ -0,0 +1 @@
1
+ Jon Combe <joncombe@gmail.com>
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are listed here. It follows
4
+ [semantic versioning](https://semver.org): while the version is 0.x, a minor
5
+ release may change the API.
6
+
7
+ ## 0.1.0
8
+
9
+ The first release.
10
+
11
+ - Make thumbnails from Python with `make_thumbnail()` and `save_thumbnail()`,
12
+ or serve them from any web app with `handle_request()`, which turns a query
13
+ string into an HTTP response.
14
+ - Build thumbnail URLs with `thumbnail_query()`. URLs are signed with
15
+ `secret_key` by default, so nobody else can make new ones.
16
+ - Django and Flask integrations, installed with `opposable-thumbs[django]` or
17
+ `opposable-thumbs[flask]`: a view or route, a `thumbnail_url` template tag
18
+ or global, settings, and a command to clean the caches.
19
+ - 29 operations, including `fit`, `pad`, `cropratio`, `rotate`, `circle`,
20
+ `rounded`, `unsharp` and colour adjustments.
21
+ - Sources are files in named folders (`roots`) or allowed URLs, in allowed
22
+ formats only, with limits on download size, image size and work per
23
+ request.
24
+ - Optional caches for downloaded sources and finished thumbnails, and an
25
+ `opposable-thumbs clean-cache` command to remove expired files and keep
26
+ each cache under a maximum size.
27
+ - `ETag`, `Cache-Control` and `304 Not Modified` responses for browsers and
28
+ CDNs.
29
+ - JPEGs are decoded at a reduced size when the operations allow it, for
30
+ faster thumbnails that use less memory.
31
+ - EXIF orientation is applied, so phone photos come out upright.
@@ -0,0 +1,286 @@
1
+ # Developing opposable-thumbs
2
+
3
+ How to work on this project. For how to use the package, see
4
+ [README.md](README.md) and [docs/](docs/).
5
+
6
+ ## Setup
7
+
8
+ The project uses [uv](https://docs.astral.sh/uv/). Install it, then from the
9
+ repository folder:
10
+
11
+ ```
12
+ uv sync
13
+ ```
14
+
15
+ This creates `.venv` with the package (editable) and the development tools,
16
+ including Django and Flask for testing the integrations.
17
+ Prefix commands with `uv run` to use it, or activate `.venv` yourself.
18
+
19
+ ## Tests
20
+
21
+ ```
22
+ uv run pytest
23
+ ```
24
+
25
+ The tests never use the network: downloads go through a fake
26
+ `requests.get` (the `fake_fetch` fixture in `tests/conftest.py`). Test images
27
+ are generated in a temporary folder for each test (the `images` fixture).
28
+
29
+ GitHub Actions runs the tests on every supported Python version, plus once
30
+ with the oldest versions of Pillow and requests that `pyproject.toml` allows.
31
+ To run those yourself:
32
+
33
+ ```
34
+ uv run --python 3.11 pytest
35
+ uv run --isolated --resolution lowest-direct --python 3.11 pytest
36
+ ```
37
+
38
+ The oldest-versions run is worth doing after any change to how Pillow is
39
+ used: older versions behave differently in places (e.g. `posterize`).
40
+
41
+ ## Linting and formatting
42
+
43
+ ```
44
+ uv run ruff check .
45
+ uv run ruff format .
46
+ ```
47
+
48
+ GitHub Actions fails if either would change anything.
49
+
50
+ ## Documentation
51
+
52
+ The README is the front page, on GitHub and PyPI: what the package is, quick
53
+ starts, and links to the rest. Everything else is in `docs/`, one page per
54
+ topic.
55
+
56
+ - PyPI shows only the README, where relative links don't work, so its links
57
+ to other files are full GitHub URLs. Links between pages in `docs/` are
58
+ relative.
59
+ - `tests/test_docs.py` checks that every link, including `#section` anchors,
60
+ goes somewhere that exists, and that the README has no relative links.
61
+ Renaming a heading breaks links to it, and the tests say which.
62
+ - It also checks the docs against the code: the settings table and its
63
+ defaults, the operations table (and the playground's list), the output
64
+ formats, the Python API's arguments and attributes, the error statuses,
65
+ and the cleanup commands' options. Change one without the other and a
66
+ test says what's out of date.
67
+
68
+ ## The playground
69
+
70
+ `playground/` is a page for trying every operation in a browser:
71
+
72
+ ```
73
+ uv run playground/serve.py
74
+ ```
75
+
76
+ Then open http://127.0.0.1:8000. Give a port number as an argument to use
77
+ another port.
78
+
79
+ - `serve.py` is a small WSGI app on the standard library's `wsgiref` server.
80
+ Its `/thumb` route is the whole integration, so it doubles as the example of
81
+ serving thumbnails from a web app. `/source` serves the original image, so
82
+ the page can show its real file size. `wsgiref` handles one request at a
83
+ time, so it isn't a deployment example.
84
+ - The playground sets `signed_urls=False`, because the page builds thumbnail
85
+ URLs in the browser as you click, and a browser can't keep a secret. A real
86
+ site leaves signing on.
87
+ - `index.html` is deliberately plain: a table of operations, the format and
88
+ quality options, the resulting URL, and the result and source images, each
89
+ with its size, type and file size. It shows error messages as text.
90
+ - When you add an operation, add it to the `OPERATIONS` list in `index.html`.
91
+ - `tests/test_playground.py` starts the server and requests a page and a
92
+ thumbnail, so the playground can't silently break.
93
+
94
+ The playground isn't in the package or the sdist.
95
+
96
+ The sample image is
97
+ [a photo](https://unsplash.com/photos/person-standing-inside-the-brown-boat-parked-on-the-seashore-fzEOvcazEtI)
98
+ by [Frankie Spontelli](https://unsplash.com/@fr33water) on Unsplash, at
99
+ 1920 × 1152.
100
+
101
+ ## Code layout
102
+
103
+ Everything is in `src/opposable_thumbs/`:
104
+
105
+ | Module | |
106
+ |---|---|
107
+ | `__init__.py` | The public API. Anything not exported here is internal. |
108
+ | `thumbnail.py` | `make_thumbnail()`, `save_thumbnail()` and `handle_request()`, and the `_Job` class that makes one thumbnail. |
109
+ | `query.py` | The query string format: parsing it for `handle_request()`, building it in `thumbnail_query()`, and signing and checking it. |
110
+ | `config.py` | `Config`, a frozen dataclass of every setting. |
111
+ | `result.py` | `Thumbnail`, what `make_thumbnail()` returns, and `Result`, what `handle_request()` returns. |
112
+ | `errors.py` | `ConfigError` and `ThumbnailError`. |
113
+ | `operations.py` | Every operation, registered by name with `@operation`. |
114
+ | `imaging.py` | Output formats and image modes: what format to save in, converting modes so every operation works, EXIF orientation, flattening transparency for JPEG. |
115
+ | `sources.py` | Checking `file` against `roots` and `url` against `allowed_urls`, and downloading. |
116
+ | `cache.py` | Reading and writing the cache folders, and `clean_cache()`. |
117
+ | `cli.py` | The `opposable-thumbs` command. |
118
+ | `django/` | The Django integration: an app with a view, `urls.py`, a template tag and a management command. `conf.py` builds the `Config` from settings. |
119
+ | `flask.py` | The Flask integration: `init_app()`, which adds a route, a template global and a CLI command. |
120
+
121
+ There are two ways in, and they meet in `_Job`. `make_thumbnail()` and
122
+ `save_thumbnail()` take Python arguments; `handle_request()` turns a query
123
+ string into the same arguments with `parse_query()`. Operations are kept as
124
+ lists of strings either way (`normalise_operations()` converts Python
125
+ values), so both give the same image and share output cache entries.
126
+
127
+ A thumbnail is made in this order (see `_Job.check` and `_Job.make`):
128
+
129
+ 1. Check every argument. All the checks run, so every problem is reported,
130
+ not just the first.
131
+ 2. Return the thumbnail from the output cache, if it has a fresh copy. This
132
+ comes after the checks, so a source that is no longer allowed isn't served
133
+ from the cache.
134
+ 3. Open the source, checking its format and pixel count before it is decoded.
135
+ 4. Decode it, at a reduced size if it is a JPEG and the operations allow it
136
+ (see `reduction_limit()`), then apply the EXIF orientation and convert it
137
+ to RGB, RGBA or L.
138
+ 5. Run the operations, crops first.
139
+ 6. Save in the output format, write it to the output cache, and return it.
140
+
141
+ `handle_request()` then turns the image or the errors into a `Result`, with
142
+ the ETag, `Cache-Control` and any 304.
143
+
144
+ A few rules the code follows:
145
+
146
+ - **The core imports no web framework.** It must stay usable from any Python
147
+ code. Each integration imports only its own framework, and only when it is
148
+ imported itself. A test checks that `import opposable_thumbs` loads neither
149
+ Django nor Flask.
150
+ - **Integrations stay thin.** They turn the framework's request into a call
151
+ to `handle_request()`, its `Result` into the framework's response, and the
152
+ framework's settings into a `Config`. Anything more belongs in the core, so
153
+ every framework gets it.
154
+ - **Nothing is allowed by default.** Sources must be listed explicitly, URLs
155
+ must be signed unless `signed_urls=False` is set, and every setting that
156
+ limits work has a default.
157
+ - **Python callers get exceptions, web requests get responses.**
158
+ `make_thumbnail()` and `save_thumbnail()` raise `ThumbnailError` for bad
159
+ arguments. `handle_request()` never raises for a bad request: it returns an
160
+ error `Result`. Only a bug raises: a cache that can't be written to is
161
+ logged as a warning, as it only makes things slower.
162
+ - **Close what you open.** Pillow keeps some image files open until they are
163
+ closed, so `_Job.open_source` closes the source once it has a copy. The
164
+ tests fail on any warning, including a file left open.
165
+ - **The query string format lives here, not in framework packages.** They
166
+ pass the query string to `handle_request()` untouched, so a URL behaves the
167
+ same in every framework.
168
+ - **`Config` can't change after it is made**, so one instance can be shared by
169
+ every request and thread.
170
+
171
+ ## Adding a framework integration
172
+
173
+ Look at `flask.py`, the simpler of the two. An integration:
174
+
175
+ 1. Imports its framework in a `try`, raising an `ImportError` that says which
176
+ extra to install, e.g. `pip install opposable-thumbs[flask]`.
177
+ 2. Builds a `Config` from the framework's settings, using a key made from
178
+ the framework's own secret (if it has one) as the default `secret_key`.
179
+ 3. Serves thumbnails with `handle_request()`, passing the raw query string
180
+ and the `If-None-Match` header, and copying the `Result`'s status, headers
181
+ and body to the response. A 304 has no Content-Type, so make sure the
182
+ framework doesn't add one.
183
+ 4. Offers a `thumbnail_url()` for templates and code, using
184
+ `thumbnail_query()`.
185
+ 5. Wraps `clean_cache()` in the framework's command line, if it has one.
186
+
187
+ Then add the framework to `[project.optional-dependencies]` and the `dev`
188
+ group in `pyproject.toml`, add tests that go through the framework's test
189
+ client (see `tests/test_flask.py`), add it to the framework import test in
190
+ `tests/test_python_api.py`, and add a page to `docs/` and a quick start to
191
+ the README.
192
+
193
+ ## Adding an operation
194
+
195
+ 1. Write a function in `operations.py` and register it:
196
+
197
+ ```python
198
+ @operation("sepia")
199
+ def sepia(image, args, config):
200
+ ...
201
+ return new_image
202
+ ```
203
+
204
+ It gets the image (always RGB, RGBA or L), the arguments from `p` as a
205
+ list of strings, and the `Config`. It returns the new image.
206
+
207
+ - Raise `ValueError`, `IndexError` or `ArithmeticError` for invalid
208
+ arguments; they are reported as "Invalid sepia arguments". Converting
209
+ with `int()` and `float()` and indexing `args` does this for you.
210
+ - Raise `OperationError("...")` for a more specific message, e.g. a limit.
211
+ - If it changes the size, call `check_output_size()` first.
212
+ - If it uses a Pillow function that refuses or mishandles an alpha channel,
213
+ wrap it in `apply_without_alpha()`.
214
+ - Use `@operation("name", crop=True)` only for crops, which run first.
215
+ - If it gives the same result on a smaller image (e.g. a colour change),
216
+ add it to `SCALE_FREE_OPERATIONS`, so JPEGs can still be decoded at a
217
+ reduced size when it comes before a resize. If it sets the output size,
218
+ add it to `reduction_limit()`. Otherwise do nothing: unknown operations
219
+ turn reduced decoding off, which is always safe.
220
+
221
+ 2. Add it to `VALID` in `tests/test_operations.py`, with example arguments
222
+ and the size it should produce. A test fails until every operation is
223
+ listed there, and the operation is then tested on every image mode and with
224
+ transparency. Add bad arguments to `test_invalid_arguments`.
225
+ 3. Add it to the operations table in `docs/operations.md`.
226
+ 4. Add it to the `OPERATIONS` list in `playground/index.html`.
227
+
228
+ ## Releasing
229
+
230
+ Releases are published to PyPI by GitHub Actions when a tag like `v0.2.0` is
231
+ pushed. `.github/workflows/release.yml` runs the tests, checks the tag
232
+ matches the version in `pyproject.toml`, builds, and publishes.
233
+
234
+ ### One-time setup
235
+
236
+ Publishing uses PyPI
237
+ [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no API
238
+ token is stored anywhere.
239
+
240
+ 1. On PyPI, add a trusted publisher. Before the first release, add it as a
241
+ "pending publisher" from your account's Publishing page, which also
242
+ reserves the name:
243
+ - PyPI project name: `opposable-thumbs`
244
+ - Owner: `joncombe`
245
+ - Repository name: `opposable-thumbs`
246
+ - Workflow name: `release.yml`
247
+ - Environment name: `pypi`
248
+ 2. On GitHub, create an environment called `pypi` (Settings → Environments).
249
+ Optionally add yourself as a required reviewer, so each release waits for
250
+ your approval before it is published.
251
+
252
+ ### Each release
253
+
254
+ 1. Set the new version, following [semantic versioning](https://semver.org):
255
+
256
+ ```
257
+ uv version --bump patch # or minor, or major, or e.g. uv version 0.2.0
258
+ ```
259
+
260
+ While the version is `0.x`, a minor release may change the API.
261
+ 2. Add the version and its changes to the top of `CHANGELOG.md`.
262
+ 3. Commit, then tag and push:
263
+
264
+ ```
265
+ git tag v0.2.0
266
+ git push origin main v0.2.0
267
+ ```
268
+
269
+ 4. Watch the Release workflow in GitHub Actions. If a step fails, nothing is
270
+ published: fix it, delete the tag (`git tag -d v0.2.0` and
271
+ `git push origin :v0.2.0`), and tag again.
272
+
273
+ ## Security notes
274
+
275
+ Two limits are knowingly out of scope, because both need a hostile host
276
+ listed in `allowed_urls`, and those hosts are trusted:
277
+
278
+ - **DNS can point an allowed hostname at an internal address.** Nothing stops
279
+ an allowed domain resolving to e.g. `127.0.0.1`. Only list domains you
280
+ control.
281
+ - **There is no limit on the total download time.** `fetch_timeout` applies to
282
+ each wait for data, so a host that sends a byte every few seconds can hold a
283
+ worker much longer.
284
+
285
+ Keeping animation (every frame of a GIF or WebP) is also out of scope: it
286
+ multiplies the work by the frame count and isn't needed for thumbnails.
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2018, Jon Combe
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ * Neither the name of Jon Combe, "Naremit" nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.5
2
+ Name: opposable-thumbs
3
+ Version: 0.1.0
4
+ Summary: Thumbnails and image manipulation driven by URL query strings, for any Python project.
5
+ Project-URL: Homepage, https://github.com/joncombe/opposable-thumbs
6
+ Project-URL: Issues, https://github.com/joncombe/opposable-thumbs/issues
7
+ Author-email: Jon Combe <pypi@joncombe.net>
8
+ License-Expression: BSD-3-Clause
9
+ License-File: LICENCE
10
+ Keywords: crop,django,flask,image,pillow,resize,thumbnail
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: Django
13
+ Classifier: Framework :: Flask
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
22
+ Classifier: Topic :: Multimedia :: Graphics
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: pillow>=10.3
26
+ Requires-Dist: requests>=2.32.4
27
+ Provides-Extra: django
28
+ Requires-Dist: django>=5.2; extra == 'django'
29
+ Provides-Extra: flask
30
+ Requires-Dist: flask>=3.0; extra == 'flask'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # opposable-thumbs
34
+
35
+ Make thumbnails and simple image edits from Python, or serve them from your
36
+ web app with URLs like
37
+ `/thumb?file=media/photo.jpg&p=fit,300,300&format=webp`.
38
+
39
+ It is designed to plug into a project you already have: install it, add a
40
+ few lines, and your app serves its own thumbnails, with no separate image
41
+ server, reverse proxy or other infrastructure to run. Sources must be allowed
42
+ explicitly, URLs are signed, and every request is limited in size and work,
43
+ so it is safe to expose to the public.
44
+
45
+ It isn't trying to compete with dedicated image servers such as
46
+ [Thumbor](https://github.com/thumbor/thumbor) or
47
+ [imgproxy](https://github.com/imgproxy/imgproxy). They are faster and do
48
+ more, e.g. face-aware cropping, cloud storage and animated images. If you
49
+ serve a lot of images, or speed matters most, use one of those instead.
50
+
51
+ Requires Python 3.11+. Uses [Pillow](https://python-pillow.org) for images
52
+ and [requests](https://requests.readthedocs.io) to fetch remote ones.
53
+
54
+ ## Install
55
+
56
+ ```
57
+ pip install opposable-thumbs
58
+ ```
59
+
60
+ For the Django or Flask integration, install `opposable-thumbs[django]` or
61
+ `opposable-thumbs[flask]` instead, which also requires the framework.
62
+
63
+ ## Quick start
64
+
65
+ ### Django
66
+
67
+ ```python
68
+ # settings.py
69
+ INSTALLED_APPS = [..., "opposable_thumbs.django"]
70
+ OPPOSABLE_THUMBS = {"roots": {"media": MEDIA_ROOT}}
71
+
72
+ # urls.py
73
+ urlpatterns = [..., path("thumb/", include("opposable_thumbs.django.urls"))]
74
+ ```
75
+
76
+ ```html
77
+ {% load opposable_thumbs %}
78
+ <img src="{% thumbnail_url "media/photo.jpg" "fit,300,300|unsharp" format="webp" %}">
79
+ ```
80
+
81
+ More in [Using with Django](https://github.com/joncombe/opposable-thumbs/blob/main/docs/django.md).
82
+
83
+ ### Flask
84
+
85
+ ```python
86
+ from opposable_thumbs.flask import init_app
87
+
88
+ app.config["OPPOSABLE_THUMBS"] = {"roots": {"media": "/srv/media"}}
89
+ init_app(app)
90
+ ```
91
+
92
+ ```html
93
+ <img src="{{ thumbnail_url('media/photo.jpg', operations='fit,300,300|unsharp') }}">
94
+ ```
95
+
96
+ More in [Using with Flask](https://github.com/joncombe/opposable-thumbs/blob/main/docs/flask.md).
97
+
98
+ ### Python
99
+
100
+ ```python
101
+ from opposable_thumbs import Config, make_thumbnail
102
+
103
+ config = Config(roots={"media": "/srv/media"})
104
+ thumbnail = make_thumbnail(
105
+ config, file="media/photo.jpg", operations="fit,300,300", format="webp"
106
+ )
107
+ thumbnail.data # the image, as bytes
108
+ ```
109
+
110
+ More in [The Python API](https://github.com/joncombe/opposable-thumbs/blob/main/docs/python.md),
111
+ and for other web frameworks, see
112
+ [Using with another web framework](https://github.com/joncombe/opposable-thumbs/blob/main/docs/other-frameworks.md).
113
+
114
+ ## Features
115
+
116
+ - 29 operations, including `fit`, `pad`, `cropratio`, `rotate`, `circle`,
117
+ `rounded`, `unsharp` and colour adjustments, with JPEG, PNG, WebP, GIF,
118
+ BMP and TIFF output.
119
+ - Signed URLs, so nobody can make up their own.
120
+ - Sources from named folders or allowed URLs only, with limits on download
121
+ size, image size and work per request.
122
+ - Optional caches for downloaded sources and finished thumbnails, plus
123
+ `ETag`, `Cache-Control` and `304 Not Modified` for browsers and CDNs.
124
+ - Faster, lower-memory decoding of large JPEGs, and EXIF orientation applied
125
+ so phone photos come out upright.
126
+
127
+ ## Documentation
128
+
129
+ - [Using with Django](https://github.com/joncombe/opposable-thumbs/blob/main/docs/django.md)
130
+ - [Using with Flask](https://github.com/joncombe/opposable-thumbs/blob/main/docs/flask.md)
131
+ - [Using with another web framework](https://github.com/joncombe/opposable-thumbs/blob/main/docs/other-frameworks.md)
132
+ - [The Python API](https://github.com/joncombe/opposable-thumbs/blob/main/docs/python.md):
133
+ functions, results and errors
134
+ - [Operations and output](https://github.com/joncombe/opposable-thumbs/blob/main/docs/operations.md):
135
+ every operation, query parameters, output formats, speed
136
+ - [Configuration](https://github.com/joncombe/opposable-thumbs/blob/main/docs/configuration.md):
137
+ settings, allowed sources, signed URLs, limits
138
+ - [Caching](https://github.com/joncombe/opposable-thumbs/blob/main/docs/caching.md):
139
+ the caches, cleaning them, and browser and CDN caching
140
+
141
+ The repository also has a playground for trying every operation in a
142
+ browser, and a guide to working on the project: see
143
+ [DEVELOP.md](https://github.com/joncombe/opposable-thumbs/blob/main/DEVELOP.md).
144
+
145
+ ## Licence
146
+
147
+ BSD 3-Clause. See
148
+ [LICENCE](https://github.com/joncombe/opposable-thumbs/blob/main/LICENCE).
@@ -0,0 +1,116 @@
1
+ # opposable-thumbs
2
+
3
+ Make thumbnails and simple image edits from Python, or serve them from your
4
+ web app with URLs like
5
+ `/thumb?file=media/photo.jpg&p=fit,300,300&format=webp`.
6
+
7
+ It is designed to plug into a project you already have: install it, add a
8
+ few lines, and your app serves its own thumbnails, with no separate image
9
+ server, reverse proxy or other infrastructure to run. Sources must be allowed
10
+ explicitly, URLs are signed, and every request is limited in size and work,
11
+ so it is safe to expose to the public.
12
+
13
+ It isn't trying to compete with dedicated image servers such as
14
+ [Thumbor](https://github.com/thumbor/thumbor) or
15
+ [imgproxy](https://github.com/imgproxy/imgproxy). They are faster and do
16
+ more, e.g. face-aware cropping, cloud storage and animated images. If you
17
+ serve a lot of images, or speed matters most, use one of those instead.
18
+
19
+ Requires Python 3.11+. Uses [Pillow](https://python-pillow.org) for images
20
+ and [requests](https://requests.readthedocs.io) to fetch remote ones.
21
+
22
+ ## Install
23
+
24
+ ```
25
+ pip install opposable-thumbs
26
+ ```
27
+
28
+ For the Django or Flask integration, install `opposable-thumbs[django]` or
29
+ `opposable-thumbs[flask]` instead, which also requires the framework.
30
+
31
+ ## Quick start
32
+
33
+ ### Django
34
+
35
+ ```python
36
+ # settings.py
37
+ INSTALLED_APPS = [..., "opposable_thumbs.django"]
38
+ OPPOSABLE_THUMBS = {"roots": {"media": MEDIA_ROOT}}
39
+
40
+ # urls.py
41
+ urlpatterns = [..., path("thumb/", include("opposable_thumbs.django.urls"))]
42
+ ```
43
+
44
+ ```html
45
+ {% load opposable_thumbs %}
46
+ <img src="{% thumbnail_url "media/photo.jpg" "fit,300,300|unsharp" format="webp" %}">
47
+ ```
48
+
49
+ More in [Using with Django](https://github.com/joncombe/opposable-thumbs/blob/main/docs/django.md).
50
+
51
+ ### Flask
52
+
53
+ ```python
54
+ from opposable_thumbs.flask import init_app
55
+
56
+ app.config["OPPOSABLE_THUMBS"] = {"roots": {"media": "/srv/media"}}
57
+ init_app(app)
58
+ ```
59
+
60
+ ```html
61
+ <img src="{{ thumbnail_url('media/photo.jpg', operations='fit,300,300|unsharp') }}">
62
+ ```
63
+
64
+ More in [Using with Flask](https://github.com/joncombe/opposable-thumbs/blob/main/docs/flask.md).
65
+
66
+ ### Python
67
+
68
+ ```python
69
+ from opposable_thumbs import Config, make_thumbnail
70
+
71
+ config = Config(roots={"media": "/srv/media"})
72
+ thumbnail = make_thumbnail(
73
+ config, file="media/photo.jpg", operations="fit,300,300", format="webp"
74
+ )
75
+ thumbnail.data # the image, as bytes
76
+ ```
77
+
78
+ More in [The Python API](https://github.com/joncombe/opposable-thumbs/blob/main/docs/python.md),
79
+ and for other web frameworks, see
80
+ [Using with another web framework](https://github.com/joncombe/opposable-thumbs/blob/main/docs/other-frameworks.md).
81
+
82
+ ## Features
83
+
84
+ - 29 operations, including `fit`, `pad`, `cropratio`, `rotate`, `circle`,
85
+ `rounded`, `unsharp` and colour adjustments, with JPEG, PNG, WebP, GIF,
86
+ BMP and TIFF output.
87
+ - Signed URLs, so nobody can make up their own.
88
+ - Sources from named folders or allowed URLs only, with limits on download
89
+ size, image size and work per request.
90
+ - Optional caches for downloaded sources and finished thumbnails, plus
91
+ `ETag`, `Cache-Control` and `304 Not Modified` for browsers and CDNs.
92
+ - Faster, lower-memory decoding of large JPEGs, and EXIF orientation applied
93
+ so phone photos come out upright.
94
+
95
+ ## Documentation
96
+
97
+ - [Using with Django](https://github.com/joncombe/opposable-thumbs/blob/main/docs/django.md)
98
+ - [Using with Flask](https://github.com/joncombe/opposable-thumbs/blob/main/docs/flask.md)
99
+ - [Using with another web framework](https://github.com/joncombe/opposable-thumbs/blob/main/docs/other-frameworks.md)
100
+ - [The Python API](https://github.com/joncombe/opposable-thumbs/blob/main/docs/python.md):
101
+ functions, results and errors
102
+ - [Operations and output](https://github.com/joncombe/opposable-thumbs/blob/main/docs/operations.md):
103
+ every operation, query parameters, output formats, speed
104
+ - [Configuration](https://github.com/joncombe/opposable-thumbs/blob/main/docs/configuration.md):
105
+ settings, allowed sources, signed URLs, limits
106
+ - [Caching](https://github.com/joncombe/opposable-thumbs/blob/main/docs/caching.md):
107
+ the caches, cleaning them, and browser and CDN caching
108
+
109
+ The repository also has a playground for trying every operation in a
110
+ browser, and a guide to working on the project: see
111
+ [DEVELOP.md](https://github.com/joncombe/opposable-thumbs/blob/main/DEVELOP.md).
112
+
113
+ ## Licence
114
+
115
+ BSD 3-Clause. See
116
+ [LICENCE](https://github.com/joncombe/opposable-thumbs/blob/main/LICENCE).