python-libphash 2.0.1.0__cp314-cp314t-macosx_11_0_arm64.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.
- libphash/__init__.py +216 -0
- libphash/__main__.py +7 -0
- libphash/_algorithms.py +88 -0
- libphash/_batch.py +366 -0
- libphash/_cli.py +518 -0
- libphash/_config.py +210 -0
- libphash/_context.py +388 -0
- libphash/_digest.py +293 -0
- libphash/_errors.py +109 -0
- libphash/_native.cpython-314t-darwin.so +0 -0
- libphash/_native.pyi +944 -0
- libphash/_sources.py +278 -0
- libphash/_version.py +3 -0
- libphash/exceptions.py +157 -0
- libphash/py.typed +0 -0
- python_libphash-2.0.1.0.dist-info/METADATA +138 -0
- python_libphash-2.0.1.0.dist-info/RECORD +29 -0
- python_libphash-2.0.1.0.dist-info/WHEEL +6 -0
- python_libphash-2.0.1.0.dist-info/entry_points.txt +2 -0
- python_libphash-2.0.1.0.dist-info/licenses/LICENSE +21 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/THIRD-PARTY-NOTICES.md +139 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/LICENSE.md +147 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/README.ijg +260 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libpng/LICENSE +134 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/COPYING +30 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/PATENTS +23 -0
- python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/zlib-ng/LICENSE.md +19 -0
- python_libphash-2.0.1.0.dist-info/sboms/python-libphash.cdx.json +161 -0
- python_libphash-2.0.1.0.dist-info/top_level.txt +1 -0
libphash/__init__.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Perceptual image hashing: Python binding for the libphash C library.
|
|
2
|
+
|
|
3
|
+
>>> import libphash as ph
|
|
4
|
+
>>> h = ph.hash("cat.jpg") # 64-bit pHash, an int
|
|
5
|
+
>>> ph.hamming_distance(h, ph.hash("cat_small.jpg"))
|
|
6
|
+
>>> d = ph.hash("cat.jpg", "radial") # a Digest
|
|
7
|
+
>>> d.similarity(ph.hash("cat_rotated.jpg", "radial"))
|
|
8
|
+
|
|
9
|
+
:class:`ImageContext` keeps an image and its configuration for several hashes and
|
|
10
|
+
reuses its buffers across images; :func:`hash_files` hashes many files on a thread pool.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Literal, overload
|
|
16
|
+
|
|
17
|
+
from ._algorithms import Algorithm, AlgorithmLike, DigestKind, to_algorithm
|
|
18
|
+
from ._batch import BatchResult, ProgressCallback, hash_buffers, hash_files
|
|
19
|
+
from ._config import (
|
|
20
|
+
AlphaMode,
|
|
21
|
+
Config,
|
|
22
|
+
DecodeScale,
|
|
23
|
+
WHashMode,
|
|
24
|
+
apply_config,
|
|
25
|
+
new_context,
|
|
26
|
+
)
|
|
27
|
+
from ._context import (
|
|
28
|
+
DigestAlgorithm,
|
|
29
|
+
DigestName,
|
|
30
|
+
ImageContext,
|
|
31
|
+
Uint64Algorithm,
|
|
32
|
+
Uint64Name,
|
|
33
|
+
)
|
|
34
|
+
from ._digest import (
|
|
35
|
+
PH_DIGEST_MAX_BYTES,
|
|
36
|
+
RADIAL_PCC_THRESHOLD,
|
|
37
|
+
Digest,
|
|
38
|
+
hamming_distance,
|
|
39
|
+
hash_from_hex,
|
|
40
|
+
hash_to_hex,
|
|
41
|
+
similarity,
|
|
42
|
+
)
|
|
43
|
+
from ._errors import PH_SUCCESS, make_error
|
|
44
|
+
from ._native import ffi, lib
|
|
45
|
+
from ._sources import (
|
|
46
|
+
ChannelOrder,
|
|
47
|
+
PILImage,
|
|
48
|
+
PixelArray,
|
|
49
|
+
Source,
|
|
50
|
+
StrPath,
|
|
51
|
+
SupportsArrayInterface,
|
|
52
|
+
SupportsRead,
|
|
53
|
+
)
|
|
54
|
+
from ._version import __version__ as __version__
|
|
55
|
+
from .exceptions import (
|
|
56
|
+
AllocationError,
|
|
57
|
+
BatchCancelledError,
|
|
58
|
+
CorruptDataError,
|
|
59
|
+
DecodeError,
|
|
60
|
+
DecoderUnavailableError,
|
|
61
|
+
DigestKindError,
|
|
62
|
+
EmptyImageError,
|
|
63
|
+
ImageIOError,
|
|
64
|
+
ImageTooLargeError,
|
|
65
|
+
InvalidArgumentError,
|
|
66
|
+
NoStructureError,
|
|
67
|
+
PhashError,
|
|
68
|
+
RequiresColorError,
|
|
69
|
+
UnsupportedFormatError,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from collections.abc import Callable
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"PH_DIGEST_MAX_BYTES",
|
|
77
|
+
"RADIAL_PCC_THRESHOLD",
|
|
78
|
+
"Algorithm",
|
|
79
|
+
"AlgorithmLike",
|
|
80
|
+
"AllocationError",
|
|
81
|
+
"AlphaMode",
|
|
82
|
+
"BatchCancelledError",
|
|
83
|
+
"BatchResult",
|
|
84
|
+
"ChannelOrder",
|
|
85
|
+
"Config",
|
|
86
|
+
"CorruptDataError",
|
|
87
|
+
"DecodeError",
|
|
88
|
+
"DecodeScale",
|
|
89
|
+
"DecoderUnavailableError",
|
|
90
|
+
"Digest",
|
|
91
|
+
"DigestAlgorithm",
|
|
92
|
+
"DigestKind",
|
|
93
|
+
"DigestKindError",
|
|
94
|
+
"DigestName",
|
|
95
|
+
"EmptyImageError",
|
|
96
|
+
"ImageContext",
|
|
97
|
+
"ImageIOError",
|
|
98
|
+
"ImageTooLargeError",
|
|
99
|
+
"InvalidArgumentError",
|
|
100
|
+
"NoStructureError",
|
|
101
|
+
"PILImage",
|
|
102
|
+
"PhashError",
|
|
103
|
+
"PixelArray",
|
|
104
|
+
"ProgressCallback",
|
|
105
|
+
"RequiresColorError",
|
|
106
|
+
"Source",
|
|
107
|
+
"StrPath",
|
|
108
|
+
"SupportsArrayInterface",
|
|
109
|
+
"SupportsRead",
|
|
110
|
+
"Uint64Algorithm",
|
|
111
|
+
"Uint64Name",
|
|
112
|
+
"UnsupportedFormatError",
|
|
113
|
+
"WHashMode",
|
|
114
|
+
"build_info",
|
|
115
|
+
"can_decode",
|
|
116
|
+
"digest_info",
|
|
117
|
+
"hamming_distance",
|
|
118
|
+
"hash",
|
|
119
|
+
"hash_buffers",
|
|
120
|
+
"hash_files",
|
|
121
|
+
"hash_from_hex",
|
|
122
|
+
"hash_to_hex",
|
|
123
|
+
"libphash_version",
|
|
124
|
+
"similarity",
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@overload
|
|
129
|
+
def hash(
|
|
130
|
+
source: Source,
|
|
131
|
+
algorithm: Uint64Algorithm | Uint64Name = ...,
|
|
132
|
+
*,
|
|
133
|
+
config: Config | None = ...,
|
|
134
|
+
) -> int: ...
|
|
135
|
+
@overload
|
|
136
|
+
def hash(
|
|
137
|
+
source: Source,
|
|
138
|
+
algorithm: DigestAlgorithm | DigestName,
|
|
139
|
+
*,
|
|
140
|
+
config: Config | None = ...,
|
|
141
|
+
) -> Digest: ...
|
|
142
|
+
@overload
|
|
143
|
+
def hash(
|
|
144
|
+
source: Source, algorithm: AlgorithmLike, *, config: Config | None = ...
|
|
145
|
+
) -> int | Digest: ...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def hash(
|
|
149
|
+
source: Source,
|
|
150
|
+
algorithm: AlgorithmLike = Algorithm.PHASH,
|
|
151
|
+
*,
|
|
152
|
+
config: Config | None = None,
|
|
153
|
+
) -> int | Digest:
|
|
154
|
+
"""Hash one image.
|
|
155
|
+
|
|
156
|
+
``source`` is a path, encoded bytes, a binary file object, an array of pixels or a
|
|
157
|
+
Pillow image. Returns an ``int`` for aHash, dHash, pHash and wHash, a
|
|
158
|
+
:class:`Digest` for the others.
|
|
159
|
+
"""
|
|
160
|
+
with ImageContext(config) as ctx:
|
|
161
|
+
ctx.load(source)
|
|
162
|
+
return ctx.hash(algorithm)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def libphash_version() -> str:
|
|
166
|
+
"""Return the version of the bundled libphash (``"2.0.1"``).
|
|
167
|
+
|
|
168
|
+
It is the first three numbers of ``__version__``.
|
|
169
|
+
"""
|
|
170
|
+
return str(ffi.string(lib.ph_version()).decode())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_info() -> str:
|
|
174
|
+
"""Return how the bundled libphash was built.
|
|
175
|
+
|
|
176
|
+
The string lists the version, decoders, threads and SIMD:
|
|
177
|
+
``"version=2.0.1 jpeg=libjpeg-turbo png=libpng ..."``.
|
|
178
|
+
"""
|
|
179
|
+
return str(ffi.string(lib.ph_get_build_info()).decode())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def can_decode(fmt: Literal["jpeg", "png", "webp"]) -> bool:
|
|
183
|
+
"""Return whether this build has a native decoder for the format.
|
|
184
|
+
|
|
185
|
+
JPEG and PNG decode without one too (through stb_image); WebP does not.
|
|
186
|
+
"""
|
|
187
|
+
checks: dict[str, Callable[[], int]] = {
|
|
188
|
+
"jpeg": lib.ph_can_use_jpeg,
|
|
189
|
+
"png": lib.ph_can_use_png,
|
|
190
|
+
"webp": lib.ph_can_use_webp,
|
|
191
|
+
}
|
|
192
|
+
if fmt not in checks:
|
|
193
|
+
raise ValueError(f"fmt must be 'jpeg', 'png' or 'webp', got {fmt!r}")
|
|
194
|
+
return bool(checks[fmt]())
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def digest_info(
|
|
198
|
+
algorithm: AlgorithmLike, config: Config | None = None
|
|
199
|
+
) -> tuple[int, DigestKind]:
|
|
200
|
+
"""Return the size in bytes and the kind of the hashes ``algorithm`` produces.
|
|
201
|
+
|
|
202
|
+
No image is needed: the answer depends only on ``config``, which makes it fit for
|
|
203
|
+
sizing a database column, say. A 64-bit hash is 8 ``BITS`` bytes.
|
|
204
|
+
"""
|
|
205
|
+
algo = to_algorithm(algorithm)
|
|
206
|
+
ctx = new_context()
|
|
207
|
+
try:
|
|
208
|
+
apply_config(config if config is not None else Config(), ctx)
|
|
209
|
+
size = ffi.new("size_t *")
|
|
210
|
+
kind = ffi.new("ph_digest_kind_t *")
|
|
211
|
+
code = lib.ph_digest_info(ctx, int(algo), size, kind)
|
|
212
|
+
if code != PH_SUCCESS:
|
|
213
|
+
raise make_error(code, context=f"digest_info({algo})")
|
|
214
|
+
return int(size[0]), DigestKind(int(kind[0]))
|
|
215
|
+
finally:
|
|
216
|
+
lib.ph_free(ctx)
|
libphash/__main__.py
ADDED
libphash/_algorithms.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import IntEnum
|
|
4
|
+
from typing import Union
|
|
5
|
+
|
|
6
|
+
from ._errors import PH_SUCCESS
|
|
7
|
+
from ._native import ffi, lib
|
|
8
|
+
from ._sources import encode_text
|
|
9
|
+
from .exceptions import InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Algorithm(IntEnum):
|
|
13
|
+
"""A hash algorithm; the values are libphash's ``ph_algorithm_t``.
|
|
14
|
+
|
|
15
|
+
Wherever an algorithm is expected, its libphash name (``"phash"``,
|
|
16
|
+
``"color_moments"``) is accepted too; ``str(algorithm)`` is that name.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# Literal values, so that type checkers see the members (libphash never changes an
|
|
20
|
+
# existing value; the tests compare them with the C enum).
|
|
21
|
+
|
|
22
|
+
AHASH = 0
|
|
23
|
+
DHASH = 1
|
|
24
|
+
PHASH = 2
|
|
25
|
+
WHASH = 3
|
|
26
|
+
BMH = 4
|
|
27
|
+
MHASH = 5
|
|
28
|
+
RADIAL = 6
|
|
29
|
+
COLOR_HASH = 7
|
|
30
|
+
COLOR_MOMENTS = 8
|
|
31
|
+
|
|
32
|
+
def __str__(self) -> str:
|
|
33
|
+
return str(ffi.string(lib.ph_algorithm_name(self.value)).decode())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DigestKind(IntEnum):
|
|
37
|
+
"""What a digest's bytes mean, and so which comparisons it supports.
|
|
38
|
+
|
|
39
|
+
The values are those of ``ph_digest_kind_t``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
UNSPECIFIED = 0
|
|
43
|
+
BITS = 1
|
|
44
|
+
COEFFICIENTS = 2
|
|
45
|
+
VECTOR = 3
|
|
46
|
+
HISTOGRAM = 4
|
|
47
|
+
VECTOR16 = 5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
AlgorithmLike = Union["Algorithm", str]
|
|
51
|
+
|
|
52
|
+
#: The algorithms that produce a 64-bit integer; the others produce a Digest.
|
|
53
|
+
UINT64_ALGORITHMS = frozenset(
|
|
54
|
+
Algorithm[name] for name in ("AHASH", "DHASH", "PHASH", "WHASH")
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_algorithm(value: object) -> Algorithm:
|
|
59
|
+
if isinstance(value, Algorithm):
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
out = ffi.new("ph_algorithm_t *")
|
|
63
|
+
if lib.ph_algorithm_from_name(encode_text(value), out) != PH_SUCCESS:
|
|
64
|
+
names = ", ".join(str(a) for a in Algorithm)
|
|
65
|
+
raise InvalidArgumentError(
|
|
66
|
+
f"unknown algorithm {value!r}; expected one of {names}"
|
|
67
|
+
)
|
|
68
|
+
return Algorithm(int(out[0]))
|
|
69
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
70
|
+
try:
|
|
71
|
+
return Algorithm(value)
|
|
72
|
+
except ValueError:
|
|
73
|
+
pass
|
|
74
|
+
raise InvalidArgumentError(f"expected an Algorithm or its name, got {value!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def uint64_flags(algorithms: list[Algorithm]) -> int:
|
|
78
|
+
"""Return the ph_hash_flags_t bits of uint64 algorithms: bit ``1 << value``."""
|
|
79
|
+
flags = 0
|
|
80
|
+
for algo in algorithms:
|
|
81
|
+
if algo not in UINT64_ALGORITHMS:
|
|
82
|
+
names = ", ".join(str(a) for a in sorted(UINT64_ALGORITHMS))
|
|
83
|
+
raise InvalidArgumentError(
|
|
84
|
+
f"{algo} produces a digest; only {names} "
|
|
85
|
+
"can be computed together or in a batch"
|
|
86
|
+
)
|
|
87
|
+
flags |= 1 << int(algo)
|
|
88
|
+
return flags
|
libphash/_batch.py
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import signal
|
|
5
|
+
import socket
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import (
|
|
10
|
+
TYPE_CHECKING,
|
|
11
|
+
Generic,
|
|
12
|
+
TypeVar,
|
|
13
|
+
cast,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from ._algorithms import Algorithm, AlgorithmLike, to_algorithm, uint64_flags
|
|
17
|
+
from ._config import Config, apply_config, new_context
|
|
18
|
+
from ._errors import PH_ERR_CANCELLED, PH_SUCCESS, check, make_error
|
|
19
|
+
from ._native import ffi, lib
|
|
20
|
+
from ._sources import READ_FILES_IN_PYTHON, StrPath, encode_path, read_file
|
|
21
|
+
from .exceptions import ImageIOError, InvalidArgumentError, PhashError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from typing_extensions import Self
|
|
25
|
+
|
|
26
|
+
from ._native import ph_batch_buffer_item_t, ph_batch_item_t, void_ptr
|
|
27
|
+
|
|
28
|
+
#: Called as ``progress(done, total)`` after each finished item; returning ``False``
|
|
29
|
+
#: stops the batch (items not yet started get BatchCancelledError).
|
|
30
|
+
ProgressCallback = Callable[[int, int], bool | None]
|
|
31
|
+
|
|
32
|
+
_S = TypeVar("_S")
|
|
33
|
+
_P = TypeVar("_P", bound=StrPath)
|
|
34
|
+
_Algorithms = AlgorithmLike | Iterable[AlgorithmLike]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class BatchResult(Generic[_S]):
|
|
39
|
+
"""The outcome for one item of a batch.
|
|
40
|
+
|
|
41
|
+
:func:`hash_files` gives ``BatchResult[path type]``, :func:`hash_buffers` gives
|
|
42
|
+
``BatchResult[int]`` (the buffer's index).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
#: The path or buffer index the item was given as.
|
|
46
|
+
source: _S
|
|
47
|
+
#: The hashes by algorithm, or None if the item failed.
|
|
48
|
+
hashes: dict[Algorithm, int] | None
|
|
49
|
+
#: Why the item failed, or None.
|
|
50
|
+
error: PhashError | None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def ok(self) -> bool:
|
|
54
|
+
return self.error is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hash_files(
|
|
58
|
+
paths: Iterable[_P],
|
|
59
|
+
algorithms: _Algorithms = (Algorithm.PHASH,),
|
|
60
|
+
*,
|
|
61
|
+
config: Config | None = None,
|
|
62
|
+
threads: int = 0,
|
|
63
|
+
progress: ProgressCallback | None = None,
|
|
64
|
+
) -> list[BatchResult[_P]]:
|
|
65
|
+
"""Hash many image files on libphash's pool of threads.
|
|
66
|
+
|
|
67
|
+
Only the 64-bit algorithms (aHash, dHash, pHash, wHash) can be batched. A failed
|
|
68
|
+
file does not stop the batch: its result carries the error. ``threads=0`` uses one
|
|
69
|
+
thread per usable CPU, ``1`` runs on the calling thread. Each worker holds one
|
|
70
|
+
decoded image at a time, so peak memory grows with the thread count (bound it with
|
|
71
|
+
``threads`` or ``Config.max_pixels``). Ctrl+C stops the batch and raises
|
|
72
|
+
KeyboardInterrupt.
|
|
73
|
+
"""
|
|
74
|
+
paths = list(paths)
|
|
75
|
+
if READ_FILES_IN_PYTHON:
|
|
76
|
+
return _files_via_buffers(paths, algorithms, config, threads, progress)
|
|
77
|
+
algos, flags = _algorithms(algorithms)
|
|
78
|
+
encoded = [ffi.new("char[]", encode_path(p)) for p in paths]
|
|
79
|
+
items = ffi.new("ph_batch_item_t[]", len(paths))
|
|
80
|
+
for item, path in zip(items, encoded, strict=True):
|
|
81
|
+
item.path = path
|
|
82
|
+
with _Run(len(paths), config, threads, progress) as run:
|
|
83
|
+
code = lib.ph_hash_files_ex(items, len(paths), flags, run.options)
|
|
84
|
+
run.finish(code)
|
|
85
|
+
return [_result(p, items[i], algos, path=p) for i, p in enumerate(paths)]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def hash_buffers(
|
|
89
|
+
buffers: Iterable[bytes | bytearray | memoryview],
|
|
90
|
+
algorithms: _Algorithms = (Algorithm.PHASH,),
|
|
91
|
+
*,
|
|
92
|
+
config: Config | None = None,
|
|
93
|
+
threads: int = 0,
|
|
94
|
+
progress: ProgressCallback | None = None,
|
|
95
|
+
) -> list[BatchResult[int]]:
|
|
96
|
+
"""Hash many encoded images held in memory; see :func:`hash_files`.
|
|
97
|
+
|
|
98
|
+
``BatchResult.source`` is the buffer's index. The buffers must not change while the
|
|
99
|
+
call runs.
|
|
100
|
+
"""
|
|
101
|
+
algos, flags = _algorithms(algorithms)
|
|
102
|
+
views = [ffi.from_buffer("const uint8_t[]", b) for b in buffers]
|
|
103
|
+
items = ffi.new("ph_batch_buffer_item_t[]", len(views))
|
|
104
|
+
for item, view in zip(items, views, strict=True):
|
|
105
|
+
item.buffer = view
|
|
106
|
+
item.length = len(view)
|
|
107
|
+
with _Run(len(views), config, threads, progress) as run:
|
|
108
|
+
code = lib.ph_hash_buffers_ex(items, len(views), flags, run.options)
|
|
109
|
+
run.finish(code)
|
|
110
|
+
return [_result(i, items[i], algos) for i in range(len(views))]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _files_via_buffers(
|
|
114
|
+
paths: list[_P],
|
|
115
|
+
algorithms: _Algorithms,
|
|
116
|
+
config: Config | None,
|
|
117
|
+
threads: int,
|
|
118
|
+
progress: ProgressCallback | None,
|
|
119
|
+
) -> list[BatchResult[_P]]:
|
|
120
|
+
"""hash_files() where the binding reads the files (see READ_FILES_IN_PYTHON).
|
|
121
|
+
|
|
122
|
+
Files are read a chunk at a time, so that memory holds a few encoded files per
|
|
123
|
+
worker and not the whole batch.
|
|
124
|
+
"""
|
|
125
|
+
algos, _ = _algorithms(algorithms)
|
|
126
|
+
workers = threads if threads > 0 else (os.cpu_count() or 1)
|
|
127
|
+
chunk = max(64, 4 * workers)
|
|
128
|
+
total = len(paths)
|
|
129
|
+
results: list[BatchResult[_P]] = []
|
|
130
|
+
reporter = _ChunkProgress(progress, total)
|
|
131
|
+
for start in range(0, total, chunk):
|
|
132
|
+
part = paths[start : start + chunk]
|
|
133
|
+
if reporter.stopped:
|
|
134
|
+
results += [_cancelled(p) for p in part]
|
|
135
|
+
continue
|
|
136
|
+
data: list[bytes] = []
|
|
137
|
+
failed: dict[int, PhashError] = {}
|
|
138
|
+
for i, p in enumerate(part):
|
|
139
|
+
try:
|
|
140
|
+
data.append(read_file(p))
|
|
141
|
+
except ImageIOError as e: # noqa: PERF203 -- each file fails on its own
|
|
142
|
+
failed[i] = e
|
|
143
|
+
data.append(b"\0") # placeholder; its result is replaced below
|
|
144
|
+
part_results = hash_buffers(
|
|
145
|
+
data,
|
|
146
|
+
algos,
|
|
147
|
+
config=config,
|
|
148
|
+
threads=threads,
|
|
149
|
+
progress=reporter.at(start),
|
|
150
|
+
)
|
|
151
|
+
for i, (p, r) in enumerate(zip(part, part_results, strict=True)):
|
|
152
|
+
if i in failed:
|
|
153
|
+
results.append(BatchResult(p, None, failed[i]))
|
|
154
|
+
else:
|
|
155
|
+
results.append(BatchResult(p, r.hashes, r.error))
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class _ChunkProgress:
|
|
160
|
+
"""The caller's progress over the chunks of one batch.
|
|
161
|
+
|
|
162
|
+
A chunk reports its items as items of the whole batch. Once progress returns False,
|
|
163
|
+
the batch stops: the chunk cancels what it has not started, and no later chunk runs.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(self, progress: ProgressCallback | None, total: int) -> None:
|
|
167
|
+
self.progress = progress
|
|
168
|
+
self.total = total
|
|
169
|
+
self.stopped = False
|
|
170
|
+
|
|
171
|
+
def at(self, offset: int) -> ProgressCallback | None:
|
|
172
|
+
progress = self.progress
|
|
173
|
+
if progress is None:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
def report(done: int, _total: int) -> bool:
|
|
177
|
+
if progress(offset + done, self.total) is False:
|
|
178
|
+
self.stopped = True
|
|
179
|
+
return not self.stopped
|
|
180
|
+
|
|
181
|
+
return report
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _cancelled(source: _S) -> BatchResult[_S]:
|
|
185
|
+
return BatchResult(source, None, make_error(PH_ERR_CANCELLED))
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _algorithms(algorithms: _Algorithms) -> tuple[list[Algorithm], int]:
|
|
189
|
+
if isinstance(algorithms, (str, Algorithm)):
|
|
190
|
+
algos = [to_algorithm(algorithms)]
|
|
191
|
+
else:
|
|
192
|
+
algos = sorted({to_algorithm(a) for a in algorithms})
|
|
193
|
+
if not algos:
|
|
194
|
+
raise InvalidArgumentError("no algorithm given")
|
|
195
|
+
return algos, uint64_flags(algos)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _result(
|
|
199
|
+
source: _S,
|
|
200
|
+
item: ph_batch_item_t | ph_batch_buffer_item_t,
|
|
201
|
+
algos: list[Algorithm],
|
|
202
|
+
path: StrPath | None = None,
|
|
203
|
+
) -> BatchResult[_S]:
|
|
204
|
+
status = int(item.status)
|
|
205
|
+
if status != PH_SUCCESS:
|
|
206
|
+
return BatchResult(
|
|
207
|
+
source, None, make_error(status, context=str(source), path=path)
|
|
208
|
+
)
|
|
209
|
+
# Slots follow ascending flag bits, which is ascending Algorithm value.
|
|
210
|
+
return BatchResult(
|
|
211
|
+
source, {a: int(item.hashes[i]) for i, a in enumerate(algos)}, None
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _Run:
|
|
216
|
+
"""Options, callbacks and Ctrl+C handling around one batch call."""
|
|
217
|
+
|
|
218
|
+
def __init__(
|
|
219
|
+
self,
|
|
220
|
+
total: int,
|
|
221
|
+
config: Config | None,
|
|
222
|
+
threads: int,
|
|
223
|
+
progress: ProgressCallback | None,
|
|
224
|
+
) -> None:
|
|
225
|
+
self.total = total
|
|
226
|
+
self.config = config if config is not None else Config()
|
|
227
|
+
self.threads = _check_threads(threads)
|
|
228
|
+
self.progress = progress
|
|
229
|
+
self.lock = threading.Lock()
|
|
230
|
+
self.done = 0
|
|
231
|
+
self.stop = False
|
|
232
|
+
self.error: BaseException | None = None
|
|
233
|
+
self.wakeup: socket.socket | None = None
|
|
234
|
+
self.options = ffi.new("ph_batch_options_t *")
|
|
235
|
+
check(lib.ph_batch_options_init(self.options))
|
|
236
|
+
self.handle = ffi.new_handle(self)
|
|
237
|
+
self.options.threads = self.threads
|
|
238
|
+
self.options.should_continue = lib._libphash_continue
|
|
239
|
+
self.options.user_data = self.handle
|
|
240
|
+
if self.progress is not None:
|
|
241
|
+
self.options.on_progress = lib._libphash_progress
|
|
242
|
+
|
|
243
|
+
def __enter__(self) -> Self:
|
|
244
|
+
template = new_context()
|
|
245
|
+
try:
|
|
246
|
+
apply_config(self.config, template)
|
|
247
|
+
except BaseException:
|
|
248
|
+
lib.ph_free(template)
|
|
249
|
+
raise
|
|
250
|
+
self.options.config = template
|
|
251
|
+
_escaped.error = None
|
|
252
|
+
self._watch_sigint()
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
256
|
+
self._unwatch_sigint()
|
|
257
|
+
lib.ph_free(self.options.config)
|
|
258
|
+
self.options.config = ffi.NULL
|
|
259
|
+
|
|
260
|
+
def finish(self, code: int) -> None:
|
|
261
|
+
"""Check the batch's return code, re-raising a callback's exception."""
|
|
262
|
+
escaped, _escaped.error = _escaped.error, None
|
|
263
|
+
if escaped is not None:
|
|
264
|
+
raise escaped
|
|
265
|
+
if self.error is not None:
|
|
266
|
+
raise self.error
|
|
267
|
+
if code not in (PH_SUCCESS, PH_ERR_CANCELLED):
|
|
268
|
+
raise make_error(code, context="batch")
|
|
269
|
+
|
|
270
|
+
# Ctrl+C: while the batch runs, the main thread is inside C and Python's SIGINT
|
|
271
|
+
# handler cannot run. Its C-level handler still writes the signal number to the
|
|
272
|
+
# wakeup fd, which should_continue polls; KeyboardInterrupt follows as usual once
|
|
273
|
+
# the call returns.
|
|
274
|
+
def _watch_sigint(self) -> None:
|
|
275
|
+
if threading.current_thread() is not threading.main_thread():
|
|
276
|
+
return
|
|
277
|
+
reader, writer = socket.socketpair()
|
|
278
|
+
reader.setblocking(False)
|
|
279
|
+
writer.setblocking(False)
|
|
280
|
+
try:
|
|
281
|
+
previous = signal.set_wakeup_fd(writer.fileno(), warn_on_full_buffer=False)
|
|
282
|
+
except (ValueError, OSError):
|
|
283
|
+
reader.close()
|
|
284
|
+
writer.close()
|
|
285
|
+
return
|
|
286
|
+
if previous != -1:
|
|
287
|
+
# Someone (an event loop) owns the wakeup fd; leave it to them.
|
|
288
|
+
signal.set_wakeup_fd(previous)
|
|
289
|
+
reader.close()
|
|
290
|
+
writer.close()
|
|
291
|
+
return
|
|
292
|
+
self.wakeup = reader
|
|
293
|
+
self._writer = writer
|
|
294
|
+
|
|
295
|
+
def _unwatch_sigint(self) -> None:
|
|
296
|
+
if self.wakeup is not None:
|
|
297
|
+
signal.set_wakeup_fd(-1)
|
|
298
|
+
self.wakeup.close()
|
|
299
|
+
self._writer.close()
|
|
300
|
+
self.wakeup = None
|
|
301
|
+
|
|
302
|
+
def should_continue(self) -> bool:
|
|
303
|
+
with self.lock:
|
|
304
|
+
if self.stop:
|
|
305
|
+
return False
|
|
306
|
+
if self.wakeup is not None:
|
|
307
|
+
try:
|
|
308
|
+
received = self.wakeup.recv(64)
|
|
309
|
+
except (BlockingIOError, InterruptedError):
|
|
310
|
+
received = b""
|
|
311
|
+
if int(signal.SIGINT) in received:
|
|
312
|
+
self.stop = True
|
|
313
|
+
return False
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
def on_progress(self) -> None:
|
|
317
|
+
# The callback is registered only with a progress function.
|
|
318
|
+
progress = cast("ProgressCallback", self.progress)
|
|
319
|
+
with self.lock:
|
|
320
|
+
self.done += 1
|
|
321
|
+
done = self.done
|
|
322
|
+
if self.stop:
|
|
323
|
+
return
|
|
324
|
+
try:
|
|
325
|
+
if progress(done, self.total) is False:
|
|
326
|
+
self.stop = True
|
|
327
|
+
except BaseException as e: # noqa: BLE001 -- re-raised from finish()
|
|
328
|
+
self.error = e
|
|
329
|
+
self.stop = True
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _check_threads(threads: object) -> int:
|
|
333
|
+
if isinstance(threads, bool) or not isinstance(threads, int) or threads < 0:
|
|
334
|
+
raise InvalidArgumentError(f"threads must be an int >= 0, got {threads!r}")
|
|
335
|
+
return threads
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _Escaped(threading.local):
|
|
339
|
+
error: BaseException | None = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# An exception that leaves a callback never reaches C: cffi passes it to
|
|
343
|
+
# _keep_escaped and returns 0 to C, which stops the batch. Such an exception can
|
|
344
|
+
# arrive as the callback is entered, before any line of it runs: KeyboardInterrupt,
|
|
345
|
+
# when the calling thread runs the callbacks (threads=1). It is kept per thread and
|
|
346
|
+
# re-raised by finish() on the calling thread; on a worker thread it only stops the
|
|
347
|
+
# batch, since the calling thread cannot see it.
|
|
348
|
+
_escaped = _Escaped()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _keep_escaped(
|
|
352
|
+
_type: type[BaseException] | None, value: BaseException | None, _tb: object
|
|
353
|
+
) -> None:
|
|
354
|
+
_escaped.error = value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@ffi.def_extern(name="_libphash_should_continue", onerror=_keep_escaped)
|
|
358
|
+
def should_continue_callback(user_data: void_ptr) -> int:
|
|
359
|
+
run = cast("_Run", ffi.from_handle(user_data))
|
|
360
|
+
return 1 if run.should_continue() else 0
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@ffi.def_extern(name="_libphash_on_progress", onerror=_keep_escaped)
|
|
364
|
+
def on_progress_callback(_done: int, _total: int, user_data: void_ptr) -> None:
|
|
365
|
+
run = cast("_Run", ffi.from_handle(user_data))
|
|
366
|
+
run.on_progress()
|