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.
- opposable_thumbs-0.1.0/.gitignore +7 -0
- opposable_thumbs-0.1.0/AUTHORS +1 -0
- opposable_thumbs-0.1.0/CHANGELOG.md +31 -0
- opposable_thumbs-0.1.0/DEVELOP.md +286 -0
- opposable_thumbs-0.1.0/LICENCE +27 -0
- opposable_thumbs-0.1.0/PKG-INFO +148 -0
- opposable_thumbs-0.1.0/README.md +116 -0
- opposable_thumbs-0.1.0/docs/caching.md +77 -0
- opposable_thumbs-0.1.0/docs/configuration.md +150 -0
- opposable_thumbs-0.1.0/docs/django.md +76 -0
- opposable_thumbs-0.1.0/docs/flask.md +66 -0
- opposable_thumbs-0.1.0/docs/operations.md +120 -0
- opposable_thumbs-0.1.0/docs/other-frameworks.md +73 -0
- opposable_thumbs-0.1.0/docs/python.md +138 -0
- opposable_thumbs-0.1.0/pyproject.toml +85 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/__init__.py +25 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/__main__.py +3 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/cache.py +139 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/cli.py +50 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/config.py +104 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/__init__.py +13 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/apps.py +8 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/conf.py +34 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/__init__.py +0 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/commands/__init__.py +0 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/management/commands/clean_opposable_cache.py +32 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/templatetags/__init__.py +0 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/templatetags/opposable_thumbs.py +16 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/urls.py +8 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/django/views.py +40 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/errors.py +15 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/flask.py +93 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/imaging.py +85 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/operations.py +452 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/py.typed +0 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/query.py +141 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/result.py +43 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/sources.py +109 -0
- opposable_thumbs-0.1.0/src/opposable_thumbs/thumbnail.py +452 -0
- opposable_thumbs-0.1.0/tests/conftest.py +82 -0
- opposable_thumbs-0.1.0/tests/helpers.py +34 -0
- opposable_thumbs-0.1.0/tests/test_cache.py +318 -0
- opposable_thumbs-0.1.0/tests/test_cli.py +58 -0
- opposable_thumbs-0.1.0/tests/test_decoding.py +138 -0
- opposable_thumbs-0.1.0/tests/test_django.py +149 -0
- opposable_thumbs-0.1.0/tests/test_docs.py +225 -0
- opposable_thumbs-0.1.0/tests/test_flask.py +116 -0
- opposable_thumbs-0.1.0/tests/test_formats.py +139 -0
- opposable_thumbs-0.1.0/tests/test_http.py +63 -0
- opposable_thumbs-0.1.0/tests/test_operations.py +258 -0
- opposable_thumbs-0.1.0/tests/test_params.py +125 -0
- opposable_thumbs-0.1.0/tests/test_playground.py +64 -0
- opposable_thumbs-0.1.0/tests/test_python_api.py +205 -0
- opposable_thumbs-0.1.0/tests/test_signing.py +123 -0
- opposable_thumbs-0.1.0/tests/test_sources.py +199 -0
|
@@ -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).
|