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.
- thumbmoves-0.1.0/LICENSE +21 -0
- thumbmoves-0.1.0/PKG-INFO +99 -0
- thumbmoves-0.1.0/README.md +72 -0
- thumbmoves-0.1.0/pyproject.toml +42 -0
- thumbmoves-0.1.0/setup.cfg +4 -0
- thumbmoves-0.1.0/src/thumbmoves/__init__.py +18 -0
- thumbmoves-0.1.0/src/thumbmoves/api.py +102 -0
- thumbmoves-0.1.0/src/thumbmoves/backends/__init__.py +0 -0
- thumbmoves-0.1.0/src/thumbmoves/backends/freedesktop.py +40 -0
- thumbmoves-0.1.0/src/thumbmoves/backends/macos.py +14 -0
- thumbmoves-0.1.0/src/thumbmoves/backends/windows.py +136 -0
- thumbmoves-0.1.0/src/thumbmoves/cli.py +38 -0
- thumbmoves-0.1.0/src/thumbmoves/common.py +37 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/PKG-INFO +99 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/SOURCES.txt +21 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/dependency_links.txt +1 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/entry_points.txt +2 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/requires.txt +7 -0
- thumbmoves-0.1.0/src/thumbmoves.egg-info/top_level.txt +1 -0
- thumbmoves-0.1.0/tests/test_api.py +20 -0
- thumbmoves-0.1.0/tests/test_freedesktop.py +27 -0
- thumbmoves-0.1.0/tests/test_source_contracts.py +22 -0
- thumbmoves-0.1.0/tests/test_thumbmoves_identity.py +20 -0
thumbmoves-0.1.0/LICENSE
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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")
|