thumbmoves 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PixelCue contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: thumbmoves
3
+ Version: 0.1.0
4
+ Summary: ThumbMoves: cross-platform Python access to native operating-system thumbnail caches.
5
+ Author: Kieran Simkin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kieransimkin/PixelCue/tree/main/packages/thumbmoves
8
+ Project-URL: Repository, https://github.com/kieransimkin/PixelCue
9
+ Project-URL: Issues, https://github.com/kieransimkin/PixelCue/issues
10
+ Project-URL: Releases, https://github.com/kieransimkin/PixelCue/releases
11
+ Keywords: thumbnail,cache,windows,freedesktop,quicklook
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Desktop Environment :: File Managers
18
+ Classifier: Topic :: Multimedia :: Graphics
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: Pillow>=10.0
23
+ Requires-Dist: comtypes>=1.4; platform_system == "Windows"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # ThumbMoves
29
+
30
+ Part of the DanceFlow ecosystem, **ThumbMoves** is a small cross-platform Python library for retrieving **existing thumbnails from the operating system's thumbnail cache** without opening the original media file.
31
+
32
+ ```python
33
+ from thumbmoves import get_cached_thumbnail
34
+
35
+ result = get_cached_thumbnail("/photos/example.jpg", (320, 240))
36
+ if result:
37
+ print(result.backend, result.size)
38
+ open("thumb.jpg", "wb").write(result.data)
39
+ ```
40
+
41
+ Or if you only want bytes:
42
+
43
+ ```python
44
+ from thumbmoves import get_cached_thumbnail_bytes
45
+
46
+ jpeg = get_cached_thumbnail_bytes("/photos/example.jpg")
47
+ ```
48
+
49
+ ## Platform behavior
50
+
51
+ | Platform | Backend | Strict cache-only? | Behavior |
52
+ |---|---|---:|---|
53
+ | Windows | Shell `IShellItemImageFactory` | Yes | Requests `SIIGBF_INCACHEONLY | SIIGBF_THUMBNAILONLY` |
54
+ | Linux / Unix desktops | Freedesktop thumbnail spec | Yes | Reads `$XDG_CACHE_HOME/thumbnails` / `~/.cache/thumbnails` by canonical file-URI MD5 |
55
+ | macOS | Quick Look capability | No public equivalent | Returns a miss rather than trigger thumbnail generation |
56
+
57
+ The API is intentionally conservative: a cache miss is `None`. It never falls back to decoding the source file. Applications can implement their own fallback after the cache lookup.
58
+
59
+ ## Installation
60
+
61
+ Install from PyPI after the first production release:
62
+
63
+ ```bash
64
+ python -m pip install thumbmoves
65
+ ```
66
+
67
+ The same tested wheel and source distribution are attached to the matching GitHub release with `SHA256SUMS.txt`. Test releases are published separately on TestPyPI.
68
+
69
+ Or install a checkout for local development:
70
+
71
+ ```bash
72
+ python -m pip install -e .[dev]
73
+ pytest
74
+ ```
75
+
76
+ Windows installs `comtypes` automatically through a platform-scoped dependency. Other platforms do not install it.
77
+
78
+ Release tags use the `thumbmoves-vX.Y.Z` form because ThumbMoves currently lives in the PixelCue repository.
79
+
80
+ ## CLI
81
+
82
+ ```bash
83
+ thumbmoves ~/Pictures/photo.jpg --size 320x240 -o thumb.jpg
84
+ ```
85
+
86
+ Exit code `0` means cache hit; `1` means cache miss.
87
+
88
+ ## Design goals
89
+
90
+ - no dependency on a GUI toolkit;
91
+ - no opening the source media file;
92
+ - no implicit thumbnail generation in the cache-only API;
93
+ - one stable API across platforms;
94
+ - Pillow images and encoded bytes both available;
95
+ - OS-specific code isolated in backend modules.
96
+
97
+ ## License
98
+
99
+ MIT.
@@ -0,0 +1,72 @@
1
+ # ThumbMoves
2
+
3
+ Part of the DanceFlow ecosystem, **ThumbMoves** is a small cross-platform Python library for retrieving **existing thumbnails from the operating system's thumbnail cache** without opening the original media file.
4
+
5
+ ```python
6
+ from thumbmoves import get_cached_thumbnail
7
+
8
+ result = get_cached_thumbnail("/photos/example.jpg", (320, 240))
9
+ if result:
10
+ print(result.backend, result.size)
11
+ open("thumb.jpg", "wb").write(result.data)
12
+ ```
13
+
14
+ Or if you only want bytes:
15
+
16
+ ```python
17
+ from thumbmoves import get_cached_thumbnail_bytes
18
+
19
+ jpeg = get_cached_thumbnail_bytes("/photos/example.jpg")
20
+ ```
21
+
22
+ ## Platform behavior
23
+
24
+ | Platform | Backend | Strict cache-only? | Behavior |
25
+ |---|---|---:|---|
26
+ | Windows | Shell `IShellItemImageFactory` | Yes | Requests `SIIGBF_INCACHEONLY | SIIGBF_THUMBNAILONLY` |
27
+ | Linux / Unix desktops | Freedesktop thumbnail spec | Yes | Reads `$XDG_CACHE_HOME/thumbnails` / `~/.cache/thumbnails` by canonical file-URI MD5 |
28
+ | macOS | Quick Look capability | No public equivalent | Returns a miss rather than trigger thumbnail generation |
29
+
30
+ The API is intentionally conservative: a cache miss is `None`. It never falls back to decoding the source file. Applications can implement their own fallback after the cache lookup.
31
+
32
+ ## Installation
33
+
34
+ Install from PyPI after the first production release:
35
+
36
+ ```bash
37
+ python -m pip install thumbmoves
38
+ ```
39
+
40
+ The same tested wheel and source distribution are attached to the matching GitHub release with `SHA256SUMS.txt`. Test releases are published separately on TestPyPI.
41
+
42
+ Or install a checkout for local development:
43
+
44
+ ```bash
45
+ python -m pip install -e .[dev]
46
+ pytest
47
+ ```
48
+
49
+ Windows installs `comtypes` automatically through a platform-scoped dependency. Other platforms do not install it.
50
+
51
+ Release tags use the `thumbmoves-vX.Y.Z` form because ThumbMoves currently lives in the PixelCue repository.
52
+
53
+ ## CLI
54
+
55
+ ```bash
56
+ thumbmoves ~/Pictures/photo.jpg --size 320x240 -o thumb.jpg
57
+ ```
58
+
59
+ Exit code `0` means cache hit; `1` means cache miss.
60
+
61
+ ## Design goals
62
+
63
+ - no dependency on a GUI toolkit;
64
+ - no opening the source media file;
65
+ - no implicit thumbnail generation in the cache-only API;
66
+ - one stable API across platforms;
67
+ - Pillow images and encoded bytes both available;
68
+ - OS-specific code isolated in backend modules.
69
+
70
+ ## License
71
+
72
+ MIT.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "thumbmoves"
7
+ version = "0.1.0"
8
+ description = "ThumbMoves: cross-platform Python access to native operating-system thumbnail caches."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Kieran Simkin"}]
14
+ keywords = ["thumbnail", "cache", "windows", "freedesktop", "quicklook"]
15
+ dependencies = [
16
+ "Pillow>=10.0",
17
+ "comtypes>=1.4; platform_system == 'Windows'",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 3 - Alpha",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Operating System :: POSIX :: Linux",
23
+ "Operating System :: MacOS",
24
+ "Programming Language :: Python :: 3",
25
+ "Topic :: Desktop Environment :: File Managers",
26
+ "Topic :: Multimedia :: Graphics",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["pytest>=8"]
31
+
32
+ [project.scripts]
33
+ thumbmoves = "thumbmoves.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/kieransimkin/PixelCue/tree/main/packages/thumbmoves"
37
+ Repository = "https://github.com/kieransimkin/PixelCue"
38
+ Issues = "https://github.com/kieransimkin/PixelCue/issues"
39
+ Releases = "https://github.com/kieransimkin/PixelCue/releases"
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,18 @@
1
+ from .api import (
2
+ BackendInfo,
3
+ ThumbnailResult,
4
+ backend_info,
5
+ get_cached_thumbnail,
6
+ get_cached_thumbnail_bytes,
7
+ get_cached_thumbnail_image,
8
+ )
9
+
10
+ __all__ = [
11
+ "BackendInfo",
12
+ "ThumbnailResult",
13
+ "backend_info",
14
+ "get_cached_thumbnail",
15
+ "get_cached_thumbnail_bytes",
16
+ "get_cached_thumbnail_image",
17
+ ]
18
+ __version__ = "0.1.0"
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from PIL import Image
7
+
8
+ from .common import DEFAULT_MAX_SIZE, encode_image, fit_image, normalize_size
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class BackendInfo:
13
+ name: str
14
+ platform: str
15
+ cache_only_supported: bool
16
+ notes: str = ""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ThumbnailResult:
21
+ data: bytes
22
+ image: Image.Image
23
+ backend: str
24
+ path: Path
25
+ output_format: str
26
+ cache_only: bool = True
27
+
28
+ @property
29
+ def size(self) -> tuple[int, int]:
30
+ return self.image.size
31
+
32
+
33
+ def backend_info(platform: str | None = None) -> BackendInfo:
34
+ platform = platform or sys.platform
35
+ if platform == "win32":
36
+ return BackendInfo("windows-shell", platform, True,
37
+ "IShellItemImageFactory with INCACHEONLY + THUMBNAILONLY")
38
+ if platform == "darwin":
39
+ return BackendInfo("macos-quicklook", platform, False,
40
+ "Quick Look has no public strict cache-only query")
41
+ return BackendInfo("freedesktop-xdg", platform, True,
42
+ "Freedesktop thumbnail-spec cache lookup")
43
+
44
+
45
+ def get_cached_thumbnail_image(
46
+ path: str | Path,
47
+ max_size: tuple[int, int] = DEFAULT_MAX_SIZE,
48
+ ) -> Image.Image | None:
49
+ """Return an existing OS-managed thumbnail as a Pillow image.
50
+
51
+ This function never opens the original media file and never intentionally
52
+ asks the OS to generate a thumbnail. On macOS, where no public strict
53
+ cache-only Quick Look API exists, it returns ``None``.
54
+ """
55
+ p = Path(path)
56
+ max_size = normalize_size(max_size)
57
+
58
+ if sys.platform == "win32":
59
+ from .backends.windows import load_cached_thumbnail
60
+ image = load_cached_thumbnail(p, max(max_size))
61
+ elif sys.platform == "darwin":
62
+ from .backends.macos import load_cached_thumbnail
63
+ image = load_cached_thumbnail(p)
64
+ else:
65
+ from .backends.freedesktop import load_cached_thumbnail
66
+ image = load_cached_thumbnail(p)
67
+
68
+ return fit_image(image, max_size) if image is not None else None
69
+
70
+
71
+ def get_cached_thumbnail(
72
+ path: str | Path,
73
+ max_size: tuple[int, int] = DEFAULT_MAX_SIZE,
74
+ *,
75
+ output_format: str = "JPEG",
76
+ quality: int = 82,
77
+ ) -> ThumbnailResult | None:
78
+ image = get_cached_thumbnail_image(path, max_size=max_size)
79
+ if image is None:
80
+ return None
81
+ fmt = str(output_format).upper()
82
+ return ThumbnailResult(
83
+ data=encode_image(image, output_format=fmt, quality=quality),
84
+ image=image,
85
+ backend=backend_info().name,
86
+ path=Path(path),
87
+ output_format=fmt,
88
+ cache_only=True,
89
+ )
90
+
91
+
92
+ def get_cached_thumbnail_bytes(
93
+ path: str | Path,
94
+ max_size: tuple[int, int] = DEFAULT_MAX_SIZE,
95
+ *,
96
+ output_format: str = "JPEG",
97
+ quality: int = 82,
98
+ ) -> bytes | None:
99
+ result = get_cached_thumbnail(
100
+ path, max_size=max_size, output_format=output_format, quality=quality
101
+ )
102
+ return result.data if result is not None else None
File without changes
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import os
5
+ from pathlib import Path
6
+ from PIL import Image
7
+
8
+
9
+ def load_cached_thumbnail(path: Path) -> Image.Image | None:
10
+ """Load an existing Freedesktop/XDG thumbnail without generating one."""
11
+ try:
12
+ resolved = path.expanduser().resolve(strict=True)
13
+ uri = resolved.as_uri()
14
+ digest = hashlib.md5(uri.encode("utf-8")).hexdigest() + ".png"
15
+ cache_home = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
16
+
17
+ for bucket in ("xx-large", "x-large", "large", "normal"):
18
+ candidate = cache_home / "thumbnails" / bucket / digest
19
+ if not candidate.is_file():
20
+ continue
21
+ try:
22
+ with Image.open(candidate) as cached:
23
+ info = dict(cached.info)
24
+ thumb_uri = info.get("Thumb::URI")
25
+ thumb_mtime = info.get("Thumb::MTime")
26
+ if thumb_uri and str(thumb_uri) != uri:
27
+ continue
28
+ if thumb_mtime is not None:
29
+ try:
30
+ if int(thumb_mtime) != int(resolved.stat().st_mtime):
31
+ continue
32
+ except (TypeError, ValueError, OSError):
33
+ continue
34
+ cached.load()
35
+ return cached.copy()
36
+ except Exception:
37
+ continue
38
+ except Exception:
39
+ return None
40
+ return None
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from PIL import Image
5
+
6
+
7
+ def load_cached_thumbnail(path: Path) -> Image.Image | None:
8
+ """macOS has no public strict cache-only Quick Look query.
9
+
10
+ The cross-platform cache-only API therefore returns a clean miss instead of
11
+ causing Quick Look to generate a thumbnail. A future backend can add a
12
+ documented cache-only mechanism if Apple exposes one.
13
+ """
14
+ return None
@@ -0,0 +1,136 @@
1
+ \
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+ from PIL import Image
7
+
8
+
9
+ def load_cached_thumbnail(path: Path, requested_size: int) -> Image.Image | None:
10
+ """Retrieve an existing Windows Shell thumbnail without generating one."""
11
+ if sys.platform != "win32":
12
+ return None
13
+
14
+ try:
15
+ import ctypes
16
+ from ctypes import POINTER, Structure, byref, cast, c_void_p, c_wchar_p
17
+ from ctypes.wintypes import BYTE, DWORD, HANDLE, HBITMAP, LONG, UINT, WORD
18
+ from comtypes import COMMETHOD, GUID, HRESULT, IUnknown, CoInitialize, CoUninitialize
19
+ except Exception:
20
+ return None
21
+
22
+ class SIZE(Structure):
23
+ _fields_ = [("cx", LONG), ("cy", LONG)]
24
+
25
+ class BITMAP(Structure):
26
+ _fields_ = [
27
+ ("bmType", LONG), ("bmWidth", LONG), ("bmHeight", LONG),
28
+ ("bmWidthBytes", LONG), ("bmPlanes", WORD), ("bmBitsPixel", WORD),
29
+ ("bmBits", c_void_p),
30
+ ]
31
+
32
+ class BITMAPINFOHEADER(Structure):
33
+ _fields_ = [
34
+ ("biSize", DWORD), ("biWidth", LONG), ("biHeight", LONG),
35
+ ("biPlanes", WORD), ("biBitCount", WORD), ("biCompression", DWORD),
36
+ ("biSizeImage", DWORD), ("biXPelsPerMeter", LONG),
37
+ ("biYPelsPerMeter", LONG), ("biClrUsed", DWORD),
38
+ ("biClrImportant", DWORD),
39
+ ]
40
+
41
+ class RGBQUAD(Structure):
42
+ _fields_ = [
43
+ ("rgbBlue", BYTE), ("rgbGreen", BYTE), ("rgbRed", BYTE),
44
+ ("rgbReserved", BYTE),
45
+ ]
46
+
47
+ class BITMAPINFO(Structure):
48
+ _fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", RGBQUAD * 1)]
49
+
50
+ class IShellItemImageFactory(IUnknown):
51
+ _case_insensitive_ = True
52
+ _iid_ = GUID("{bcc18b79-ba16-442f-80c4-8a59c30c463b}")
53
+ _idlflags_ = []
54
+
55
+ IShellItemImageFactory._methods_ = [
56
+ COMMETHOD([], HRESULT, "GetImage", (["in"], SIZE, "size"),
57
+ (["in"], UINT, "flags"), (["out"], POINTER(HBITMAP), "phbm"))
58
+ ]
59
+
60
+ shell32 = ctypes.windll.shell32
61
+ gdi32 = ctypes.windll.gdi32
62
+ user32 = ctypes.windll.user32
63
+
64
+ shell32.SHCreateItemFromParsingName.argtypes = [c_wchar_p, c_void_p, POINTER(GUID), POINTER(c_void_p)]
65
+ shell32.SHCreateItemFromParsingName.restype = HRESULT
66
+ gdi32.GetObjectW.argtypes = [HANDLE, ctypes.c_int, c_void_p]
67
+ gdi32.GetObjectW.restype = ctypes.c_int
68
+ gdi32.GetDIBits.argtypes = [HANDLE, HANDLE, UINT, UINT, c_void_p, POINTER(BITMAPINFO), UINT]
69
+ gdi32.GetDIBits.restype = ctypes.c_int
70
+ gdi32.DeleteObject.argtypes = [HANDLE]
71
+ gdi32.DeleteObject.restype = ctypes.c_int
72
+ user32.GetDC.argtypes = [HANDLE]
73
+ user32.GetDC.restype = HANDLE
74
+ user32.ReleaseDC.argtypes = [HANDLE, HANDLE]
75
+ user32.ReleaseDC.restype = ctypes.c_int
76
+
77
+ SIIGBF_THUMBNAILONLY = 0x00000008
78
+ SIIGBF_INCACHEONLY = 0x00000010
79
+ flags = SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY
80
+ DIB_RGB_COLORS = 0
81
+ BI_RGB = 0
82
+
83
+ initialized = False
84
+ hbitmap = None
85
+ dc = None
86
+ factory_ptr = c_void_p()
87
+ try:
88
+ CoInitialize()
89
+ initialized = True
90
+ hr = shell32.SHCreateItemFromParsingName(
91
+ str(path).replace("/", "\\"), None,
92
+ byref(IShellItemImageFactory._iid_), byref(factory_ptr),
93
+ )
94
+ if hr < 0 or not factory_ptr.value:
95
+ return None
96
+
97
+ factory = cast(factory_ptr, POINTER(IShellItemImageFactory))
98
+ hbitmap = factory.GetImage(SIZE(requested_size, requested_size), flags)
99
+ if not hbitmap:
100
+ return None
101
+
102
+ bitmap = BITMAP()
103
+ if not gdi32.GetObjectW(hbitmap, ctypes.sizeof(bitmap), byref(bitmap)):
104
+ return None
105
+ width, height = int(bitmap.bmWidth), abs(int(bitmap.bmHeight))
106
+ if width <= 0 or height <= 0:
107
+ return None
108
+
109
+ bmi = BITMAPINFO()
110
+ bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
111
+ bmi.bmiHeader.biWidth = width
112
+ bmi.bmiHeader.biHeight = -height
113
+ bmi.bmiHeader.biPlanes = 1
114
+ bmi.bmiHeader.biBitCount = 32
115
+ bmi.bmiHeader.biCompression = BI_RGB
116
+
117
+ raw = ctypes.create_string_buffer(width * height * 4)
118
+ dc = user32.GetDC(None)
119
+ if not dc:
120
+ return None
121
+ lines = gdi32.GetDIBits(dc, hbitmap, 0, height, raw, byref(bmi), DIB_RGB_COLORS)
122
+ if lines != height:
123
+ return None
124
+ return Image.frombuffer("RGBA", (width, height), raw.raw, "raw", "BGRA", 0, 1).copy()
125
+ except Exception:
126
+ return None
127
+ finally:
128
+ if dc:
129
+ try: user32.ReleaseDC(None, dc)
130
+ except Exception: pass
131
+ if hbitmap:
132
+ try: gdi32.DeleteObject(hbitmap)
133
+ except Exception: pass
134
+ if initialized:
135
+ try: CoUninitialize()
136
+ except Exception: pass
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from .api import backend_info, get_cached_thumbnail
7
+
8
+
9
+ def _size(value: str) -> tuple[int, int]:
10
+ try:
11
+ width, height = value.lower().split("x", 1)
12
+ return int(width), int(height)
13
+ except Exception as exc:
14
+ raise argparse.ArgumentTypeError("size must look like 320x240") from exc
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = argparse.ArgumentParser(description="Read an existing OS thumbnail cache entry")
19
+ parser.add_argument("path", type=Path)
20
+ parser.add_argument("-o", "--output", type=Path)
21
+ parser.add_argument("--size", type=_size, default=(320, 240))
22
+ parser.add_argument("--format", default="JPEG")
23
+ args = parser.parse_args(argv)
24
+
25
+ info = backend_info()
26
+ result = get_cached_thumbnail(args.path, args.size, output_format=args.format)
27
+ if result is None:
28
+ print(f"MISS backend={info.name} path={args.path}")
29
+ return 1
30
+
31
+ output = args.output or Path.cwd() / (args.path.stem + ".thumb." + args.format.lower())
32
+ output.write_bytes(result.data)
33
+ print(f"HIT backend={result.backend} size={result.size[0]}x{result.size[1]} output={output}")
34
+ return 0
35
+
36
+
37
+ if __name__ == "__main__":
38
+ raise SystemExit(main())
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from PIL import Image
5
+
6
+ DEFAULT_MAX_SIZE = (320, 240)
7
+
8
+
9
+ def normalize_size(max_size: tuple[int, int]) -> tuple[int, int]:
10
+ width, height = int(max_size[0]), int(max_size[1])
11
+ if width <= 0 or height <= 0:
12
+ raise ValueError("max_size dimensions must be positive")
13
+ return width, height
14
+
15
+
16
+ def fit_image(image: Image.Image, max_size: tuple[int, int]) -> Image.Image:
17
+ max_size = normalize_size(max_size)
18
+ out = image.convert("RGB").copy()
19
+ out.thumbnail(max_size)
20
+ return out
21
+
22
+
23
+ def encode_image(
24
+ image: Image.Image,
25
+ *,
26
+ output_format: str = "JPEG",
27
+ quality: int = 82,
28
+ ) -> bytes:
29
+ fmt = str(output_format).upper()
30
+ out = io.BytesIO()
31
+ kwargs = {}
32
+ if fmt in {"JPEG", "JPG", "WEBP"}:
33
+ kwargs["quality"] = int(quality)
34
+ if fmt in {"JPEG", "JPG"}:
35
+ kwargs["optimize"] = True
36
+ image.save(out, format=fmt, **kwargs)
37
+ return out.getvalue()
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: thumbmoves
3
+ Version: 0.1.0
4
+ Summary: ThumbMoves: cross-platform Python access to native operating-system thumbnail caches.
5
+ Author: Kieran Simkin
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kieransimkin/PixelCue/tree/main/packages/thumbmoves
8
+ Project-URL: Repository, https://github.com/kieransimkin/PixelCue
9
+ Project-URL: Issues, https://github.com/kieransimkin/PixelCue/issues
10
+ Project-URL: Releases, https://github.com/kieransimkin/PixelCue/releases
11
+ Keywords: thumbnail,cache,windows,freedesktop,quicklook
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Desktop Environment :: File Managers
18
+ Classifier: Topic :: Multimedia :: Graphics
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: Pillow>=10.0
23
+ Requires-Dist: comtypes>=1.4; platform_system == "Windows"
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # ThumbMoves
29
+
30
+ Part of the DanceFlow ecosystem, **ThumbMoves** is a small cross-platform Python library for retrieving **existing thumbnails from the operating system's thumbnail cache** without opening the original media file.
31
+
32
+ ```python
33
+ from thumbmoves import get_cached_thumbnail
34
+
35
+ result = get_cached_thumbnail("/photos/example.jpg", (320, 240))
36
+ if result:
37
+ print(result.backend, result.size)
38
+ open("thumb.jpg", "wb").write(result.data)
39
+ ```
40
+
41
+ Or if you only want bytes:
42
+
43
+ ```python
44
+ from thumbmoves import get_cached_thumbnail_bytes
45
+
46
+ jpeg = get_cached_thumbnail_bytes("/photos/example.jpg")
47
+ ```
48
+
49
+ ## Platform behavior
50
+
51
+ | Platform | Backend | Strict cache-only? | Behavior |
52
+ |---|---|---:|---|
53
+ | Windows | Shell `IShellItemImageFactory` | Yes | Requests `SIIGBF_INCACHEONLY | SIIGBF_THUMBNAILONLY` |
54
+ | Linux / Unix desktops | Freedesktop thumbnail spec | Yes | Reads `$XDG_CACHE_HOME/thumbnails` / `~/.cache/thumbnails` by canonical file-URI MD5 |
55
+ | macOS | Quick Look capability | No public equivalent | Returns a miss rather than trigger thumbnail generation |
56
+
57
+ The API is intentionally conservative: a cache miss is `None`. It never falls back to decoding the source file. Applications can implement their own fallback after the cache lookup.
58
+
59
+ ## Installation
60
+
61
+ Install from PyPI after the first production release:
62
+
63
+ ```bash
64
+ python -m pip install thumbmoves
65
+ ```
66
+
67
+ The same tested wheel and source distribution are attached to the matching GitHub release with `SHA256SUMS.txt`. Test releases are published separately on TestPyPI.
68
+
69
+ Or install a checkout for local development:
70
+
71
+ ```bash
72
+ python -m pip install -e .[dev]
73
+ pytest
74
+ ```
75
+
76
+ Windows installs `comtypes` automatically through a platform-scoped dependency. Other platforms do not install it.
77
+
78
+ Release tags use the `thumbmoves-vX.Y.Z` form because ThumbMoves currently lives in the PixelCue repository.
79
+
80
+ ## CLI
81
+
82
+ ```bash
83
+ thumbmoves ~/Pictures/photo.jpg --size 320x240 -o thumb.jpg
84
+ ```
85
+
86
+ Exit code `0` means cache hit; `1` means cache miss.
87
+
88
+ ## Design goals
89
+
90
+ - no dependency on a GUI toolkit;
91
+ - no opening the source media file;
92
+ - no implicit thumbnail generation in the cache-only API;
93
+ - one stable API across platforms;
94
+ - Pillow images and encoded bytes both available;
95
+ - OS-specific code isolated in backend modules.
96
+
97
+ ## License
98
+
99
+ MIT.
@@ -0,0 +1,21 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/thumbmoves/__init__.py
5
+ src/thumbmoves/api.py
6
+ src/thumbmoves/cli.py
7
+ src/thumbmoves/common.py
8
+ src/thumbmoves.egg-info/PKG-INFO
9
+ src/thumbmoves.egg-info/SOURCES.txt
10
+ src/thumbmoves.egg-info/dependency_links.txt
11
+ src/thumbmoves.egg-info/entry_points.txt
12
+ src/thumbmoves.egg-info/requires.txt
13
+ src/thumbmoves.egg-info/top_level.txt
14
+ src/thumbmoves/backends/__init__.py
15
+ src/thumbmoves/backends/freedesktop.py
16
+ src/thumbmoves/backends/macos.py
17
+ src/thumbmoves/backends/windows.py
18
+ tests/test_api.py
19
+ tests/test_freedesktop.py
20
+ tests/test_source_contracts.py
21
+ tests/test_thumbmoves_identity.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ thumbmoves = thumbmoves.cli:main
@@ -0,0 +1,7 @@
1
+ Pillow>=10.0
2
+
3
+ [:platform_system == "Windows"]
4
+ comtypes>=1.4
5
+
6
+ [dev]
7
+ pytest>=8
@@ -0,0 +1 @@
1
+ thumbmoves
@@ -0,0 +1,20 @@
1
+ from thumbmoves import backend_info
2
+ from thumbmoves.common import normalize_size
3
+
4
+
5
+ def test_backend_info_is_cross_platform_api():
6
+ assert backend_info("win32").name == "windows-shell"
7
+ assert backend_info("win32").cache_only_supported is True
8
+ assert backend_info("darwin").name == "macos-quicklook"
9
+ assert backend_info("darwin").cache_only_supported is False
10
+ assert backend_info("linux").name == "freedesktop-xdg"
11
+
12
+
13
+ def test_size_validation():
14
+ assert normalize_size((320, 240)) == (320, 240)
15
+ try:
16
+ normalize_size((0, 240))
17
+ except ValueError:
18
+ pass
19
+ else:
20
+ raise AssertionError("expected ValueError")
@@ -0,0 +1,27 @@
1
+ import hashlib
2
+ from pathlib import Path
3
+ from PIL import Image, PngImagePlugin
4
+
5
+ from thumbmoves.api import get_cached_thumbnail
6
+
7
+
8
+ def test_freedesktop_cache_lookup(tmp_path, monkeypatch):
9
+ source = tmp_path / "photo.jpg"
10
+ source.write_bytes(b"not-opened-by-library")
11
+ uri = source.resolve().as_uri()
12
+ digest = hashlib.md5(uri.encode("utf-8")).hexdigest() + ".png"
13
+
14
+ cache = tmp_path / "cache" / "thumbnails" / "large"
15
+ cache.mkdir(parents=True)
16
+ info = PngImagePlugin.PngInfo()
17
+ info.add_text("Thumb::URI", uri)
18
+ info.add_text("Thumb::MTime", str(int(source.stat().st_mtime)))
19
+ Image.new("RGB", (640, 480), "white").save(cache / digest, pnginfo=info)
20
+
21
+ monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
22
+ monkeypatch.setattr("sys.platform", "linux")
23
+ result = get_cached_thumbnail(source, (160, 120))
24
+ assert result is not None
25
+ assert result.backend == "freedesktop-xdg"
26
+ assert result.size == (160, 120)
27
+ assert result.data.startswith(b"\xff\xd8")
@@ -0,0 +1,22 @@
1
+ from pathlib import Path
2
+
3
+ ROOT = Path(__file__).parents[1]
4
+
5
+
6
+ def test_windows_backend_uses_strict_cache_flags():
7
+ source = (ROOT / "src/thumbmoves/backends/windows.py").read_text()
8
+ assert "SIIGBF_INCACHEONLY = 0x00000010" in source
9
+ assert "SIIGBF_THUMBNAILONLY = 0x00000008" in source
10
+ assert "SIIGBF_THUMBNAILONLY | SIIGBF_INCACHEONLY" in source
11
+ assert "DeleteObject" in source
12
+
13
+
14
+ def test_freedesktop_backend_uses_uri_md5():
15
+ source = (ROOT / "src/thumbmoves/backends/freedesktop.py").read_text()
16
+ assert 'hashlib.md5(uri.encode("utf-8")).hexdigest() + ".png"' in source
17
+ assert '("xx-large", "x-large", "large", "normal")' in source
18
+
19
+
20
+ def test_library_has_no_pixelcue_dependency():
21
+ for path in (ROOT / "src/thumbmoves").rglob("*.py"):
22
+ assert "pixelcue" not in path.read_text().casefold()
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+ import thumbmoves
3
+
4
+ ROOT = Path(__file__).parents[1]
5
+
6
+
7
+ def test_public_package_name_and_version():
8
+ assert thumbmoves.__version__ == "0.1.0"
9
+
10
+
11
+ def test_project_identity_is_thumbmoves():
12
+ pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
13
+ assert 'name = "thumbmoves"' in pyproject
14
+ assert 'thumbmoves = "thumbmoves.cli:main"' in pyproject
15
+
16
+
17
+ def test_no_old_import_namespace_remains():
18
+ assert (ROOT / "src/thumbmoves").is_dir()
19
+ for path in (ROOT / "src/thumbmoves").rglob("*.py"):
20
+ assert "os_thumbnail_cache" not in path.read_text(encoding="utf-8")