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.
Files changed (29) hide show
  1. libphash/__init__.py +216 -0
  2. libphash/__main__.py +7 -0
  3. libphash/_algorithms.py +88 -0
  4. libphash/_batch.py +366 -0
  5. libphash/_cli.py +518 -0
  6. libphash/_config.py +210 -0
  7. libphash/_context.py +388 -0
  8. libphash/_digest.py +293 -0
  9. libphash/_errors.py +109 -0
  10. libphash/_native.cpython-314t-darwin.so +0 -0
  11. libphash/_native.pyi +944 -0
  12. libphash/_sources.py +278 -0
  13. libphash/_version.py +3 -0
  14. libphash/exceptions.py +157 -0
  15. libphash/py.typed +0 -0
  16. python_libphash-2.0.1.0.dist-info/METADATA +138 -0
  17. python_libphash-2.0.1.0.dist-info/RECORD +29 -0
  18. python_libphash-2.0.1.0.dist-info/WHEEL +6 -0
  19. python_libphash-2.0.1.0.dist-info/entry_points.txt +2 -0
  20. python_libphash-2.0.1.0.dist-info/licenses/LICENSE +21 -0
  21. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/THIRD-PARTY-NOTICES.md +139 -0
  22. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/LICENSE.md +147 -0
  23. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libjpeg-turbo/README.ijg +260 -0
  24. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libpng/LICENSE +134 -0
  25. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/COPYING +30 -0
  26. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/libwebp/PATENTS +23 -0
  27. python_libphash-2.0.1.0.dist-info/licenses/native/libphash/vendor/zlib-ng/LICENSE.md +19 -0
  28. python_libphash-2.0.1.0.dist-info/sboms/python-libphash.cdx.json +161 -0
  29. 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
@@ -0,0 +1,7 @@
1
+ """Run the command line: ``python -m libphash --help``."""
2
+
3
+ import sys
4
+
5
+ from ._cli import main
6
+
7
+ sys.exit(main(prog="python -m libphash"))
@@ -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()