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.
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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
@@ -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,8 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class OpposableThumbsConfig(AppConfig):
5
+ name = "opposable_thumbs.django"
6
+ # the default label would be "django"
7
+ label = "opposable_thumbs"
8
+ verbose_name = "opposable-thumbs"
@@ -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
@@ -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}"