opposable-thumbs 0.1.0__py3-none-any.whl
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/__init__.py +25 -0
- opposable_thumbs/__main__.py +3 -0
- opposable_thumbs/cache.py +139 -0
- opposable_thumbs/cli.py +50 -0
- opposable_thumbs/config.py +104 -0
- opposable_thumbs/django/__init__.py +13 -0
- opposable_thumbs/django/apps.py +8 -0
- opposable_thumbs/django/conf.py +34 -0
- opposable_thumbs/django/management/__init__.py +0 -0
- opposable_thumbs/django/management/commands/__init__.py +0 -0
- opposable_thumbs/django/management/commands/clean_opposable_cache.py +32 -0
- opposable_thumbs/django/templatetags/__init__.py +0 -0
- opposable_thumbs/django/templatetags/opposable_thumbs.py +16 -0
- opposable_thumbs/django/urls.py +8 -0
- opposable_thumbs/django/views.py +40 -0
- opposable_thumbs/errors.py +15 -0
- opposable_thumbs/flask.py +93 -0
- opposable_thumbs/imaging.py +85 -0
- opposable_thumbs/operations.py +452 -0
- opposable_thumbs/py.typed +0 -0
- opposable_thumbs/query.py +141 -0
- opposable_thumbs/result.py +43 -0
- opposable_thumbs/sources.py +109 -0
- opposable_thumbs/thumbnail.py +452 -0
- opposable_thumbs-0.1.0.dist-info/METADATA +148 -0
- opposable_thumbs-0.1.0.dist-info/RECORD +29 -0
- opposable_thumbs-0.1.0.dist-info/WHEEL +4 -0
- opposable_thumbs-0.1.0.dist-info/entry_points.txt +2 -0
- opposable_thumbs-0.1.0.dist-info/licenses/LICENCE +27 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Thumbnails and image manipulation, from Python or from URL query strings."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from .cache import clean_cache
|
|
6
|
+
from .config import Config
|
|
7
|
+
from .errors import ConfigError, ThumbnailError
|
|
8
|
+
from .query import thumbnail_query
|
|
9
|
+
from .result import Result, Thumbnail
|
|
10
|
+
from .thumbnail import handle_request, make_thumbnail, save_thumbnail
|
|
11
|
+
|
|
12
|
+
__version__ = version("opposable-thumbs")
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Config",
|
|
16
|
+
"ConfigError",
|
|
17
|
+
"Result",
|
|
18
|
+
"Thumbnail",
|
|
19
|
+
"ThumbnailError",
|
|
20
|
+
"clean_cache",
|
|
21
|
+
"handle_request",
|
|
22
|
+
"make_thumbnail",
|
|
23
|
+
"save_thumbnail",
|
|
24
|
+
"thumbnail_query",
|
|
25
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
# only files named like cache entries are deleted, in case a cache folder
|
|
8
|
+
# shares a directory with other files: "<md5>", or "<md5>.<uuid>.tmp" left by
|
|
9
|
+
# an interrupted write
|
|
10
|
+
CACHE_FILENAME = re.compile(r"^[0-9a-f]{32}(\.[0-9a-f]{32}\.tmp)?$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cache_key(*parts):
|
|
14
|
+
# a file name for whatever identifies a cache entry
|
|
15
|
+
key = repr(parts).encode("utf-8")
|
|
16
|
+
return hashlib.md5(key, usedforsecurity=False).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_fresh(path, max_age):
|
|
20
|
+
# cached files older than max_age seconds are treated as missing, so they
|
|
21
|
+
# are replaced. a max_age of None keeps them forever
|
|
22
|
+
try:
|
|
23
|
+
age = time.time() - os.path.getmtime(path)
|
|
24
|
+
except OSError:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
return max_age is None or age < max_age
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_cache_file(path, data):
|
|
31
|
+
# write to a temp file and rename it into place, so a concurrent request
|
|
32
|
+
# never reads a partly written file. the folder is created if missing
|
|
33
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
34
|
+
temp_path = f"{path}.{uuid.uuid4().hex}.tmp"
|
|
35
|
+
try:
|
|
36
|
+
with open(temp_path, "xb") as f:
|
|
37
|
+
f.write(data)
|
|
38
|
+
os.replace(temp_path, path)
|
|
39
|
+
except Exception:
|
|
40
|
+
try:
|
|
41
|
+
os.remove(temp_path)
|
|
42
|
+
except OSError:
|
|
43
|
+
pass
|
|
44
|
+
raise
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# multipliers for the units a size can be given in, e.g. "5GB"
|
|
48
|
+
SIZE_UNITS = {
|
|
49
|
+
"": 1,
|
|
50
|
+
"b": 1,
|
|
51
|
+
"k": 1000,
|
|
52
|
+
"kb": 1000,
|
|
53
|
+
"m": 1000**2,
|
|
54
|
+
"mb": 1000**2,
|
|
55
|
+
"g": 1000**3,
|
|
56
|
+
"gb": 1000**3,
|
|
57
|
+
"t": 1000**4,
|
|
58
|
+
"tb": 1000**4,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_size(size):
|
|
63
|
+
"""A size in bytes, from a number or a string like "500MB" or "5GB"
|
|
64
|
+
(in powers of 1000). None stays None. Raises ValueError if it isn't one.
|
|
65
|
+
"""
|
|
66
|
+
if size is None:
|
|
67
|
+
return None
|
|
68
|
+
if isinstance(size, int | float) and not isinstance(size, bool):
|
|
69
|
+
value = size
|
|
70
|
+
else:
|
|
71
|
+
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*", str(size))
|
|
72
|
+
if not match or match.group(2).lower() not in SIZE_UNITS:
|
|
73
|
+
raise ValueError(f"Invalid size: {size!r}, e.g. use 500MB or 5GB")
|
|
74
|
+
value = float(match.group(1)) * SIZE_UNITS[match.group(2).lower()]
|
|
75
|
+
if value < 0:
|
|
76
|
+
raise ValueError(f"Invalid size: {size!r}, it can't be negative")
|
|
77
|
+
return int(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clean_cache(config, all=False, max_size=None):
|
|
81
|
+
"""Delete files from both cache folders, and return how many.
|
|
82
|
+
|
|
83
|
+
Expired files are deleted, or every file with all=True. Then, while a
|
|
84
|
+
cache folder is bigger than max_size bytes (by default
|
|
85
|
+
config.cache_max_size), its oldest files are deleted. max_size can be a
|
|
86
|
+
number of bytes or a string like "5GB". Run it on a schedule, as files
|
|
87
|
+
that are never requested again otherwise stay on disk.
|
|
88
|
+
"""
|
|
89
|
+
max_size = config.cache_max_size if max_size is None else parse_size(max_size)
|
|
90
|
+
return sum(
|
|
91
|
+
_clean_folder(folder, config.cache_max_age, all, max_size)
|
|
92
|
+
for folder in (config.input_cache_dir, config.output_cache_dir)
|
|
93
|
+
if folder and os.path.isdir(folder)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _clean_folder(folder, max_age, all, max_size):
|
|
98
|
+
now = time.time()
|
|
99
|
+
deleted = 0
|
|
100
|
+
kept = []
|
|
101
|
+
for entry in os.scandir(folder):
|
|
102
|
+
if not entry.is_file(follow_symlinks=False):
|
|
103
|
+
continue
|
|
104
|
+
if not CACHE_FILENAME.match(entry.name):
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
stat = entry.stat()
|
|
108
|
+
except FileNotFoundError:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
if all or (max_age is not None and now - stat.st_mtime >= max_age):
|
|
112
|
+
deleted += _remove(entry.path)
|
|
113
|
+
else:
|
|
114
|
+
kept.append((stat.st_mtime, stat.st_size, entry.name, entry.path))
|
|
115
|
+
|
|
116
|
+
if max_size is not None:
|
|
117
|
+
total = sum(size for _, size, _, _ in kept)
|
|
118
|
+
# oldest first. files still being written (.tmp) count towards the
|
|
119
|
+
# size, but are left alone
|
|
120
|
+
for _, size, name, path in sorted(kept):
|
|
121
|
+
if total <= max_size:
|
|
122
|
+
break
|
|
123
|
+
if name.endswith(".tmp"):
|
|
124
|
+
continue
|
|
125
|
+
if _remove(path):
|
|
126
|
+
deleted += 1
|
|
127
|
+
total -= size
|
|
128
|
+
|
|
129
|
+
return deleted
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _remove(path):
|
|
133
|
+
# 1 if the file was deleted, 0 if it was already gone (e.g. deleted by
|
|
134
|
+
# another process)
|
|
135
|
+
try:
|
|
136
|
+
os.remove(path)
|
|
137
|
+
return 1
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
return 0
|
opposable_thumbs/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from .cache import clean_cache
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .errors import ConfigError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None):
|
|
9
|
+
parser = argparse.ArgumentParser(prog="opposable-thumbs")
|
|
10
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
11
|
+
|
|
12
|
+
clean = commands.add_parser(
|
|
13
|
+
"clean-cache",
|
|
14
|
+
help="delete expired files from the cache folders",
|
|
15
|
+
description=(
|
|
16
|
+
"Delete cached files older than --max-age seconds, then the oldest "
|
|
17
|
+
"files in any folder bigger than --max-size. Run it on a schedule "
|
|
18
|
+
"to stop the caches growing."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
clean.add_argument("--input-cache-dir", help="the input_cache_dir folder")
|
|
22
|
+
clean.add_argument("--output-cache-dir", help="the output_cache_dir folder")
|
|
23
|
+
clean.add_argument(
|
|
24
|
+
"--max-age",
|
|
25
|
+
type=float,
|
|
26
|
+
default=Config.cache_max_age,
|
|
27
|
+
help="the cache_max_age setting, in seconds (default: %(default)s)",
|
|
28
|
+
)
|
|
29
|
+
clean.add_argument(
|
|
30
|
+
"--max-size",
|
|
31
|
+
help="the cache_max_size setting: the size to keep each folder under, "
|
|
32
|
+
"e.g. 5GB (default: no limit)",
|
|
33
|
+
)
|
|
34
|
+
clean.add_argument("--all", action="store_true", help="delete every cached file")
|
|
35
|
+
|
|
36
|
+
args = parser.parse_args(argv)
|
|
37
|
+
if not args.input_cache_dir and not args.output_cache_dir:
|
|
38
|
+
parser.error("give --input-cache-dir, --output-cache-dir or both")
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
config = Config(
|
|
42
|
+
input_cache_dir=args.input_cache_dir,
|
|
43
|
+
output_cache_dir=args.output_cache_dir,
|
|
44
|
+
cache_max_age=args.max_age,
|
|
45
|
+
cache_max_size=args.max_size,
|
|
46
|
+
)
|
|
47
|
+
except ConfigError as e:
|
|
48
|
+
parser.error(str(e))
|
|
49
|
+
deleted = clean_cache(config, all=args.all)
|
|
50
|
+
print(f"Deleted {deleted} cached file(s)")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Mapping, Sequence
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from .cache import parse_size
|
|
9
|
+
from .errors import ConfigError
|
|
10
|
+
|
|
11
|
+
StrPath = str | os.PathLike
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Config:
|
|
16
|
+
"""Settings for making thumbnails. Every setting has a safe default.
|
|
17
|
+
|
|
18
|
+
Nothing can be opened until `roots` or `allowed_urls` lists a source, and
|
|
19
|
+
URLs are signed, so serving them needs a `secret_key`.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# sources. file= is "<root name>/<path inside that root's folder>", e.g.
|
|
23
|
+
# roots={"media": "/srv/media"} and file=media/photos/a.jpg. a url is only
|
|
24
|
+
# fetched if it matches allowed_urls
|
|
25
|
+
roots: Mapping[str, StrPath] = field(default_factory=dict)
|
|
26
|
+
allowed_urls: Sequence[str] = ()
|
|
27
|
+
|
|
28
|
+
# thumbnail_query() signs URLs with secret_key, and handle_request()
|
|
29
|
+
# rejects any that aren't signed with it, so nobody can make up new ones.
|
|
30
|
+
# without signed_urls, any URL is accepted. not shown in repr(), so it
|
|
31
|
+
# doesn't end up in logs
|
|
32
|
+
secret_key: str | bytes | None = field(default=None, repr=False)
|
|
33
|
+
signed_urls: bool = True
|
|
34
|
+
# source images in any other format are rejected, to limit the Pillow
|
|
35
|
+
# parsers that untrusted input can reach
|
|
36
|
+
allowed_formats: Sequence[str] = ("JPEG", "PNG", "GIF", "WEBP", "BMP", "TIFF")
|
|
37
|
+
|
|
38
|
+
# caches, each turned on by setting its folder
|
|
39
|
+
input_cache_dir: StrPath | None = None
|
|
40
|
+
output_cache_dir: StrPath | None = None
|
|
41
|
+
# seconds before a cached file is replaced. None keeps them forever
|
|
42
|
+
cache_max_age: float | None = 7 * 24 * 60 * 60
|
|
43
|
+
# the size clean_cache() keeps each cache folder under, in bytes or as a
|
|
44
|
+
# string like "5GB". None has no limit
|
|
45
|
+
cache_max_size: int | str | None = None
|
|
46
|
+
|
|
47
|
+
# sent with every image. None sends no Cache-Control header
|
|
48
|
+
cache_control: str | None = "max-age=86400"
|
|
49
|
+
# rotate images upright using their EXIF orientation, as browsers do
|
|
50
|
+
apply_exif_orientation: bool = True
|
|
51
|
+
|
|
52
|
+
# limits on a single request
|
|
53
|
+
max_download_bytes: int = 20 * 1024 * 1024
|
|
54
|
+
fetch_timeout: float = 10
|
|
55
|
+
max_source_pixels: int = 50_000_000
|
|
56
|
+
max_output_size: int = 4000
|
|
57
|
+
max_operations: int = 10
|
|
58
|
+
max_blur_radius: int = 100
|
|
59
|
+
|
|
60
|
+
def __post_init__(self):
|
|
61
|
+
# store lists as tuples and paths as strings, so a Config can't change
|
|
62
|
+
# after it is made and can be shared between threads
|
|
63
|
+
set_ = object.__setattr__
|
|
64
|
+
set_(self, "roots", self._roots(self.roots))
|
|
65
|
+
set_(self, "allowed_urls", self._tuple("allowed_urls", self.allowed_urls))
|
|
66
|
+
if self.secret_key is not None and (
|
|
67
|
+
not isinstance(self.secret_key, str | bytes) or not self.secret_key
|
|
68
|
+
):
|
|
69
|
+
raise ConfigError("secret_key must be a non-empty string")
|
|
70
|
+
set_(self, "allowed_formats", self._formats(self.allowed_formats))
|
|
71
|
+
try:
|
|
72
|
+
set_(self, "cache_max_size", parse_size(self.cache_max_size))
|
|
73
|
+
except ValueError as e:
|
|
74
|
+
raise ConfigError(f"cache_max_size: {e}") from e
|
|
75
|
+
for name in ("input_cache_dir", "output_cache_dir"):
|
|
76
|
+
value = getattr(self, name)
|
|
77
|
+
set_(self, name, os.fspath(value) if value else None)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _roots(roots):
|
|
81
|
+
if not isinstance(roots, Mapping):
|
|
82
|
+
raise ConfigError("roots must map names to folders")
|
|
83
|
+
for name in roots:
|
|
84
|
+
# the name is the first part of file=, up to the first /
|
|
85
|
+
if not isinstance(name, str) or not name or "/" in name:
|
|
86
|
+
raise ConfigError(f"Invalid roots name: {name!r}")
|
|
87
|
+
return MappingProxyType({n: os.fspath(f) for n, f in roots.items()})
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _tuple(name, value):
|
|
91
|
+
# a single string would otherwise become a tuple of its characters
|
|
92
|
+
if isinstance(value, str | bytes | os.PathLike):
|
|
93
|
+
raise ConfigError(f"{name} must be a list, not a single value")
|
|
94
|
+
return tuple(value)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _formats(cls, formats):
|
|
98
|
+
# an unknown name would make Image.open raise, so fail here instead
|
|
99
|
+
formats = tuple(f.upper() for f in cls._tuple("allowed_formats", formats))
|
|
100
|
+
Image.init()
|
|
101
|
+
unknown = [f for f in formats if f not in Image.OPEN]
|
|
102
|
+
if unknown:
|
|
103
|
+
raise ConfigError(f"Unknown allowed_formats: {', '.join(unknown)}")
|
|
104
|
+
return formats
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Django integration: pip install opposable-thumbs[django].
|
|
2
|
+
|
|
3
|
+
Add "opposable_thumbs.django" to INSTALLED_APPS, set OPPOSABLE_THUMBS in your
|
|
4
|
+
settings and include "opposable_thumbs.django.urls" in your URLconf. See the
|
|
5
|
+
README for the details.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import django # noqa: F401
|
|
10
|
+
except ImportError as e:
|
|
11
|
+
raise ImportError(
|
|
12
|
+
"opposable_thumbs.django needs Django: pip install opposable-thumbs[django]"
|
|
13
|
+
) from e
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from functools import cache
|
|
2
|
+
|
|
3
|
+
from django.conf import settings
|
|
4
|
+
from django.core.exceptions import ImproperlyConfigured
|
|
5
|
+
from django.core.signals import setting_changed
|
|
6
|
+
from django.dispatch import receiver
|
|
7
|
+
from django.utils.crypto import salted_hmac
|
|
8
|
+
|
|
9
|
+
from opposable_thumbs import Config, ConfigError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@cache
|
|
13
|
+
def get_config():
|
|
14
|
+
"""The Config from settings.OPPOSABLE_THUMBS, a dict of Config settings.
|
|
15
|
+
|
|
16
|
+
Unless it sets secret_key, URLs are signed with a key made from
|
|
17
|
+
settings.SECRET_KEY (not SECRET_KEY itself), so they need no setting.
|
|
18
|
+
"""
|
|
19
|
+
options = dict(getattr(settings, "OPPOSABLE_THUMBS", {}))
|
|
20
|
+
options.setdefault(
|
|
21
|
+
"secret_key",
|
|
22
|
+
salted_hmac("opposable_thumbs", "signing", algorithm="sha256").hexdigest(),
|
|
23
|
+
)
|
|
24
|
+
try:
|
|
25
|
+
return Config(**options)
|
|
26
|
+
except (TypeError, ConfigError) as e:
|
|
27
|
+
raise ImproperlyConfigured(f"OPPOSABLE_THUMBS: {e}") from e
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@receiver(setting_changed)
|
|
31
|
+
def _settings_changed(setting, **kwargs):
|
|
32
|
+
# e.g. override_settings in tests
|
|
33
|
+
if setting in ("OPPOSABLE_THUMBS", "SECRET_KEY"):
|
|
34
|
+
get_config.cache_clear()
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
2
|
+
|
|
3
|
+
from opposable_thumbs import clean_cache
|
|
4
|
+
|
|
5
|
+
from ...conf import get_config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Command(BaseCommand):
|
|
9
|
+
help = (
|
|
10
|
+
"Delete cached files older than cache_max_age from the cache folders "
|
|
11
|
+
"in OPPOSABLE_THUMBS, then the oldest files in any folder bigger than "
|
|
12
|
+
"cache_max_size. Run it on a schedule to stop the caches growing."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
def add_arguments(self, parser):
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--all", action="store_true", help="Delete every cached file."
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--max-size",
|
|
21
|
+
help="The size to keep each folder under, e.g. 5GB, instead of "
|
|
22
|
+
"cache_max_size.",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def handle(self, *args, **options):
|
|
26
|
+
try:
|
|
27
|
+
deleted = clean_cache(
|
|
28
|
+
get_config(), all=options["all"], max_size=options["max_size"]
|
|
29
|
+
)
|
|
30
|
+
except ValueError as e:
|
|
31
|
+
raise CommandError(e) from e
|
|
32
|
+
self.stdout.write(f"Deleted {deleted} cached file(s)")
|
|
File without changes
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django import template
|
|
2
|
+
|
|
3
|
+
from ..views import thumbnail_url as _thumbnail_url
|
|
4
|
+
|
|
5
|
+
register = template.Library()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@register.simple_tag
|
|
9
|
+
def thumbnail_url(file=None, p="", *, url=None, format=None, quality=None):
|
|
10
|
+
"""The signed URL of a thumbnail, e.g.
|
|
11
|
+
|
|
12
|
+
{% thumbnail_url "media/a.jpg" "fit,300,300|grayscale" format="webp" %}
|
|
13
|
+
|
|
14
|
+
The operations are written as in the p= query parameter.
|
|
15
|
+
"""
|
|
16
|
+
return _thumbnail_url(file, url=url, operations=p, format=format, quality=quality)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from django.urls import path
|
|
2
|
+
|
|
3
|
+
from . import views
|
|
4
|
+
|
|
5
|
+
# include() this at the path thumbnails should be served from, e.g.
|
|
6
|
+
# path("thumb/", include("opposable_thumbs.django.urls"))
|
|
7
|
+
app_name = "opposable_thumbs"
|
|
8
|
+
urlpatterns = [path("", views.thumbnail, name="thumbnail")]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from django.http import HttpResponse
|
|
2
|
+
from django.urls import reverse
|
|
3
|
+
from django.views.decorators.http import require_safe
|
|
4
|
+
|
|
5
|
+
from opposable_thumbs import handle_request, thumbnail_query
|
|
6
|
+
|
|
7
|
+
from .conf import get_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@require_safe
|
|
11
|
+
def thumbnail(request):
|
|
12
|
+
result = handle_request(
|
|
13
|
+
get_config(),
|
|
14
|
+
request.META.get("QUERY_STRING", ""),
|
|
15
|
+
if_none_match=request.headers.get("If-None-Match"),
|
|
16
|
+
)
|
|
17
|
+
response = HttpResponse(result.body, status=result.status)
|
|
18
|
+
for name, value in result.headers.items():
|
|
19
|
+
response[name] = value
|
|
20
|
+
if "Content-Type" not in result.headers:
|
|
21
|
+
# a 304 has none, and Django would otherwise add text/html
|
|
22
|
+
del response["Content-Type"]
|
|
23
|
+
return response
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def thumbnail_url(file=None, *, url=None, operations=(), format=None, quality=None):
|
|
27
|
+
"""The URL of a thumbnail, signed, for use in views and templates.
|
|
28
|
+
|
|
29
|
+
Takes the same arguments as make_thumbnail(), e.g.
|
|
30
|
+
thumbnail_url("media/a.jpg", operations=[("fit", 300, 300)]).
|
|
31
|
+
"""
|
|
32
|
+
query = thumbnail_query(
|
|
33
|
+
get_config(),
|
|
34
|
+
file=file,
|
|
35
|
+
url=url,
|
|
36
|
+
operations=operations,
|
|
37
|
+
format=format,
|
|
38
|
+
quality=quality,
|
|
39
|
+
)
|
|
40
|
+
return f"{reverse('opposable_thumbs:thumbnail')}?{query}"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class ConfigError(ValueError):
|
|
2
|
+
"""A Config setting is invalid."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ThumbnailError(Exception):
|
|
6
|
+
"""save_thumbnail() couldn't make the thumbnail.
|
|
7
|
+
|
|
8
|
+
`errors` lists every problem found, and `status` is the HTTP status code
|
|
9
|
+
make_thumbnail() would have returned for them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
def __init__(self, errors, status):
|
|
13
|
+
super().__init__("\n".join(errors))
|
|
14
|
+
self.errors = tuple(errors)
|
|
15
|
+
self.status = status
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Flask integration: pip install opposable-thumbs[flask].
|
|
2
|
+
|
|
3
|
+
from opposable_thumbs.flask import init_app
|
|
4
|
+
|
|
5
|
+
app.config["OPPOSABLE_THUMBS"] = {"roots": {"media": "/srv/media"}}
|
|
6
|
+
init_app(app)
|
|
7
|
+
|
|
8
|
+
See the README for the details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import click
|
|
16
|
+
from flask import Response, current_app, request, url_for
|
|
17
|
+
except ImportError as e:
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"opposable_thumbs.flask needs Flask: pip install opposable-thumbs[flask]"
|
|
20
|
+
) from e
|
|
21
|
+
|
|
22
|
+
from opposable_thumbs import Config, clean_cache, handle_request, thumbnail_query
|
|
23
|
+
|
|
24
|
+
EXTENSION = "opposable_thumbs"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_app(app, url="/thumb", config=None):
|
|
28
|
+
"""Serve thumbnails at `url`, and add thumbnail_url() to templates.
|
|
29
|
+
|
|
30
|
+
The Config is `config` if given, otherwise made from
|
|
31
|
+
app.config["OPPOSABLE_THUMBS"], a dict of Config settings. Unless that
|
|
32
|
+
sets secret_key, URLs are signed with a key made from app.secret_key (not
|
|
33
|
+
app.secret_key itself).
|
|
34
|
+
"""
|
|
35
|
+
if config is None:
|
|
36
|
+
options = dict(app.config.get("OPPOSABLE_THUMBS", {}))
|
|
37
|
+
if "secret_key" not in options and app.secret_key:
|
|
38
|
+
secret = app.secret_key
|
|
39
|
+
if isinstance(secret, str):
|
|
40
|
+
secret = secret.encode("utf-8")
|
|
41
|
+
options["secret_key"] = hmac.new(
|
|
42
|
+
secret, b"opposable_thumbs.signing", hashlib.sha256
|
|
43
|
+
).hexdigest()
|
|
44
|
+
config = Config(**options)
|
|
45
|
+
|
|
46
|
+
app.extensions[EXTENSION] = config
|
|
47
|
+
app.add_url_rule(url, endpoint=EXTENSION, view_func=_thumbnail)
|
|
48
|
+
app.add_template_global(thumbnail_url)
|
|
49
|
+
|
|
50
|
+
@app.cli.command("clean-thumbnail-cache")
|
|
51
|
+
@click.option("--all", "all_", is_flag=True, help="Delete every cached file.")
|
|
52
|
+
@click.option(
|
|
53
|
+
"--max-size",
|
|
54
|
+
help="The size to keep each folder under, e.g. 5GB, instead of cache_max_size.",
|
|
55
|
+
)
|
|
56
|
+
def clean_thumbnail_cache(all_, max_size):
|
|
57
|
+
"""Delete expired files from the thumbnail cache folders, then the
|
|
58
|
+
oldest files in any folder bigger than cache_max_size."""
|
|
59
|
+
try:
|
|
60
|
+
deleted = clean_cache(config, all=all_, max_size=max_size)
|
|
61
|
+
except ValueError as e:
|
|
62
|
+
raise click.BadParameter(str(e), param_hint="--max-size") from e
|
|
63
|
+
click.echo(f"Deleted {deleted} cached file(s)")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _thumbnail():
|
|
67
|
+
result = handle_request(
|
|
68
|
+
current_app.extensions[EXTENSION],
|
|
69
|
+
request.query_string.decode("utf-8", errors="replace"),
|
|
70
|
+
if_none_match=request.headers.get("If-None-Match"),
|
|
71
|
+
)
|
|
72
|
+
response = Response(result.body, status=result.status, headers=result.headers)
|
|
73
|
+
if "Content-Type" not in result.headers:
|
|
74
|
+
# a 304 has none, and Flask would otherwise add text/html
|
|
75
|
+
del response.headers["Content-Type"]
|
|
76
|
+
return response
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def thumbnail_url(file=None, *, url=None, operations=(), format=None, quality=None):
|
|
80
|
+
"""The URL of a thumbnail, signed, for use in views and templates.
|
|
81
|
+
|
|
82
|
+
Takes the same arguments as make_thumbnail(), e.g. in a template
|
|
83
|
+
{{ thumbnail_url("media/a.jpg", operations="fit,300,300|grayscale") }}.
|
|
84
|
+
"""
|
|
85
|
+
query = thumbnail_query(
|
|
86
|
+
current_app.extensions[EXTENSION],
|
|
87
|
+
file=file,
|
|
88
|
+
url=url,
|
|
89
|
+
operations=operations,
|
|
90
|
+
format=format,
|
|
91
|
+
quality=quality,
|
|
92
|
+
)
|
|
93
|
+
return f"{url_for(EXTENSION)}?{query}"
|