python-libphash 2.0.1.0__cp310-abi3-win_amd64.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.pyd +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 +5 -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/_digest.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, NoReturn, TypeVar, final
|
|
4
|
+
|
|
5
|
+
from ._algorithms import DigestKind
|
|
6
|
+
from ._errors import PH_SUCCESS, make_error
|
|
7
|
+
from ._native import ffi, lib
|
|
8
|
+
from ._sources import encode_text
|
|
9
|
+
from .exceptions import DigestKindError, InvalidArgumentError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
|
|
14
|
+
from ._native import double_ptr, ph_digest_t_ptr
|
|
15
|
+
|
|
16
|
+
_N = TypeVar("_N", int, float)
|
|
17
|
+
|
|
18
|
+
PH_DIGEST_MAX_BYTES: int = int(lib.PH_DIGEST_MAX_BYTES)
|
|
19
|
+
_UINT64_MAX = (1 << 64) - 1
|
|
20
|
+
|
|
21
|
+
# Comparison -> the kinds it accepts (UNSPECIFIED is accepted by all, as in C).
|
|
22
|
+
_METRIC_KINDS = {
|
|
23
|
+
"hamming_distance": (DigestKind.BITS,),
|
|
24
|
+
"l2_distance": (DigestKind.VECTOR, DigestKind.VECTOR16),
|
|
25
|
+
"histogram_intersection": (DigestKind.HISTOGRAM,),
|
|
26
|
+
"radial_similarity": (DigestKind.COEFFICIENTS,),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@final
|
|
31
|
+
class Digest:
|
|
32
|
+
"""A multi-byte hash: the bytes and the kind that says how they may be compared.
|
|
33
|
+
|
|
34
|
+
BMH and mHash give bit vectors, Radial quantized coefficients, ColorHash a
|
|
35
|
+
histogram, ColorMoments a vector of signed 16-bit features. Each comparison method
|
|
36
|
+
accepts only the kinds it is meaningful for and raises :class:`DigestKindError` for
|
|
37
|
+
the others; :meth:`similarity` picks the comparison that belongs to the kind.
|
|
38
|
+
|
|
39
|
+
Digests are immutable, hashable, comparable for equality and picklable. ``hex()``
|
|
40
|
+
and ``from_hex()`` give a text form that keeps the kind.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
__slots__ = ("_data", "_kind")
|
|
44
|
+
_data: bytes
|
|
45
|
+
_kind: DigestKind
|
|
46
|
+
|
|
47
|
+
def __init__(self, data: bytes, kind: DigestKind | int) -> None:
|
|
48
|
+
data = bytes(data)
|
|
49
|
+
if not 0 < len(data) <= PH_DIGEST_MAX_BYTES:
|
|
50
|
+
raise InvalidArgumentError(
|
|
51
|
+
f"a digest holds 1 to {PH_DIGEST_MAX_BYTES} bytes, got {len(data)}"
|
|
52
|
+
)
|
|
53
|
+
try:
|
|
54
|
+
kind = DigestKind(kind)
|
|
55
|
+
except ValueError:
|
|
56
|
+
raise InvalidArgumentError(f"unknown digest kind {kind!r}") from None
|
|
57
|
+
object.__setattr__(self, "_data", data)
|
|
58
|
+
object.__setattr__(self, "_kind", kind)
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_uint64(cls, value: int) -> Digest:
|
|
62
|
+
"""Wrap a 64-bit hash as an 8-byte ``BITS`` digest.
|
|
63
|
+
|
|
64
|
+
The most significant byte comes first, as in :func:`hash_to_hex`.
|
|
65
|
+
"""
|
|
66
|
+
return cls(_check_uint64(value).to_bytes(8, "big"), DigestKind.BITS)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_hex(cls, text: str) -> Digest:
|
|
70
|
+
"""Parse the text form written by :meth:`hex` (``"<kind>:<hex>"``)."""
|
|
71
|
+
out = ffi.new("ph_digest_t *")
|
|
72
|
+
code = lib.ph_digest_from_hex(encode_text(text), out)
|
|
73
|
+
if code != PH_SUCCESS:
|
|
74
|
+
raise make_error(code, context=f"parsing digest {text[:40]!r}")
|
|
75
|
+
return digest_from_c(out)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def data(self) -> bytes:
|
|
79
|
+
return self._data
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def kind(self) -> DigestKind:
|
|
83
|
+
return self._kind
|
|
84
|
+
|
|
85
|
+
def __len__(self) -> int:
|
|
86
|
+
return len(self._data)
|
|
87
|
+
|
|
88
|
+
def __bytes__(self) -> bytes:
|
|
89
|
+
return self._data
|
|
90
|
+
|
|
91
|
+
def __eq__(self, other: object) -> bool:
|
|
92
|
+
if not isinstance(other, Digest):
|
|
93
|
+
return NotImplemented
|
|
94
|
+
return self._kind == other._kind and self._data == other._data
|
|
95
|
+
|
|
96
|
+
def __hash__(self) -> int:
|
|
97
|
+
return hash((self._kind, self._data))
|
|
98
|
+
|
|
99
|
+
def __repr__(self) -> str:
|
|
100
|
+
return f"Digest.from_hex({self.hex()!r})"
|
|
101
|
+
|
|
102
|
+
def __setattr__(self, name: str, value: object) -> NoReturn:
|
|
103
|
+
raise AttributeError("Digest is immutable")
|
|
104
|
+
|
|
105
|
+
def __reduce__(self) -> tuple[type[Digest], tuple[bytes, int]]:
|
|
106
|
+
return (Digest, (self._data, int(self._kind)))
|
|
107
|
+
|
|
108
|
+
def hex(self) -> str:
|
|
109
|
+
"""Return the text form: the kind, a colon, the bytes in lowercase hex.
|
|
110
|
+
|
|
111
|
+
For example ``"bits:3fa0..."``; :meth:`from_hex` parses it back.
|
|
112
|
+
"""
|
|
113
|
+
size = int(lib.PH_DIGEST_HEX_BUFFER_SIZE)
|
|
114
|
+
out = ffi.new("char[]", size)
|
|
115
|
+
code = lib.ph_digest_to_hex(digest_to_c(self), out, size)
|
|
116
|
+
if code != PH_SUCCESS:
|
|
117
|
+
raise make_error(code, context="encoding digest")
|
|
118
|
+
return str(ffi.string(out).decode("ascii"))
|
|
119
|
+
|
|
120
|
+
# --- Comparisons -----------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def _pair(
|
|
123
|
+
self, other: object, metric: str
|
|
124
|
+
) -> tuple[ph_digest_t_ptr, ph_digest_t_ptr]:
|
|
125
|
+
other = _as_digest(other)
|
|
126
|
+
allowed = _METRIC_KINDS[metric]
|
|
127
|
+
for d in (self, other):
|
|
128
|
+
if d._kind != DigestKind.UNSPECIFIED and d._kind not in allowed:
|
|
129
|
+
_wrong_kind(metric, d._kind)
|
|
130
|
+
if len(self._data) != len(other._data):
|
|
131
|
+
raise InvalidArgumentError(
|
|
132
|
+
f"{metric}: digests of different sizes "
|
|
133
|
+
f"({len(self._data)} and {len(other._data)} bytes)"
|
|
134
|
+
)
|
|
135
|
+
return digest_to_c(self), digest_to_c(other)
|
|
136
|
+
|
|
137
|
+
def hamming_distance(self, other: Digest) -> int:
|
|
138
|
+
"""Count the differing bits between two ``BITS`` digests (BMH, mHash)."""
|
|
139
|
+
a, b = self._pair(other, "hamming_distance")
|
|
140
|
+
return int(
|
|
141
|
+
_sentinel(int(lib.ph_hamming_distance_digest(a, b)), "hamming_distance")
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def l2_distance(self, other: Digest) -> float:
|
|
145
|
+
"""Euclidean distance between two feature vectors (ColorMoments)."""
|
|
146
|
+
a, b = self._pair(other, "l2_distance")
|
|
147
|
+
return float(_sentinel(float(lib.ph_l2_distance(a, b)), "l2_distance"))
|
|
148
|
+
|
|
149
|
+
def histogram_intersection(self, other: Digest) -> float:
|
|
150
|
+
"""Share of color two ``HISTOGRAM`` digests (ColorHash) have in common.
|
|
151
|
+
|
|
152
|
+
The result is 0.0 to 1.0.
|
|
153
|
+
"""
|
|
154
|
+
a, b = self._pair(other, "histogram_intersection")
|
|
155
|
+
return _out_double(
|
|
156
|
+
lib.ph_histogram_intersection, a, b, "histogram_intersection"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def radial_similarity(self, other: Digest) -> float:
|
|
160
|
+
"""Peak cross-correlation of two Radial digests, -1.0 to 1.0.
|
|
161
|
+
|
|
162
|
+
Raises :class:`NoStructureError` when either digest is flat (all bytes equal),
|
|
163
|
+
as the Radial hash of an image without angular structure is: any score would be
|
|
164
|
+
invented. :data:`RADIAL_PCC_THRESHOLD` is the source's cut for "same image".
|
|
165
|
+
"""
|
|
166
|
+
a, b = self._pair(other, "radial_similarity")
|
|
167
|
+
return _out_double(lib.ph_radial_similarity, a, b, "radial_similarity")
|
|
168
|
+
|
|
169
|
+
def similarity(self, other: Digest) -> float:
|
|
170
|
+
"""Compare with the similarity measure that belongs to the digests' kind.
|
|
171
|
+
|
|
172
|
+
``BITS``: share of equal bits, 0.0 to 1.0. ``HISTOGRAM``: histogram
|
|
173
|
+
intersection, 0.0 to 1.0. ``COEFFICIENTS``: radial peak correlation, -1.0 to
|
|
174
|
+
1.0. ``VECTOR16`` (ColorMoments) has no normalized similarity: use
|
|
175
|
+
:meth:`l2_distance`.
|
|
176
|
+
"""
|
|
177
|
+
other = _as_digest(other)
|
|
178
|
+
kind = self._kind if self._kind != DigestKind.UNSPECIFIED else other._kind
|
|
179
|
+
if kind == DigestKind.BITS:
|
|
180
|
+
a, b = self._pair(other, "hamming_distance")
|
|
181
|
+
value = float(lib.ph_similarity_digest(a, b))
|
|
182
|
+
return float(_sentinel(value, "similarity"))
|
|
183
|
+
if kind == DigestKind.HISTOGRAM:
|
|
184
|
+
return self.histogram_intersection(other)
|
|
185
|
+
if kind == DigestKind.COEFFICIENTS:
|
|
186
|
+
return self.radial_similarity(other)
|
|
187
|
+
if kind == DigestKind.UNSPECIFIED:
|
|
188
|
+
raise DigestKindError(
|
|
189
|
+
"similarity: neither digest states its kind, so the measure is "
|
|
190
|
+
"unknown; call hamming_distance(), l2_distance(), "
|
|
191
|
+
"histogram_intersection() or radial_similarity() explicitly"
|
|
192
|
+
)
|
|
193
|
+
raise DigestKindError(
|
|
194
|
+
f"similarity: {kind.name} digests have no normalized similarity; "
|
|
195
|
+
"use l2_distance()"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def digest_from_c(c_digest: ph_digest_t_ptr) -> Digest:
|
|
200
|
+
"""Copy a ``ph_digest_t *`` into a Digest."""
|
|
201
|
+
return Digest(bytes(ffi.buffer(c_digest.data, c_digest.size)), int(c_digest.kind))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def digest_to_c(digest: Digest) -> ph_digest_t_ptr:
|
|
205
|
+
"""Return a new ``ph_digest_t *`` holding ``digest``."""
|
|
206
|
+
data = digest.data
|
|
207
|
+
c_digest = ffi.new("ph_digest_t *")
|
|
208
|
+
c_digest.size = len(data)
|
|
209
|
+
c_digest.kind = int(digest.kind)
|
|
210
|
+
ffi.memmove(c_digest.data, data, len(data))
|
|
211
|
+
return c_digest
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _as_digest(other: object) -> Digest:
|
|
215
|
+
if not isinstance(other, Digest):
|
|
216
|
+
raise TypeError(f"expected a Digest, got {type(other).__name__}")
|
|
217
|
+
return other
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _wrong_kind(metric: str, kind: DigestKind) -> NoReturn:
|
|
221
|
+
right = {
|
|
222
|
+
DigestKind.BITS: "hamming_distance() or similarity()",
|
|
223
|
+
DigestKind.VECTOR: "l2_distance()",
|
|
224
|
+
DigestKind.VECTOR16: "l2_distance()",
|
|
225
|
+
DigestKind.HISTOGRAM: "histogram_intersection() or similarity()",
|
|
226
|
+
DigestKind.COEFFICIENTS: "radial_similarity() or similarity()",
|
|
227
|
+
}[kind]
|
|
228
|
+
raise DigestKindError(
|
|
229
|
+
f"{metric} does not apply to {kind.name} digests; use {right}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _sentinel(value: _N, metric: str) -> _N:
|
|
234
|
+
# The checks in _pair leave the C library nothing to reject; -1 here would be a
|
|
235
|
+
# contract the binding missed.
|
|
236
|
+
if value < 0:
|
|
237
|
+
raise InvalidArgumentError(f"{metric}: the C library rejected the digests")
|
|
238
|
+
return value
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _out_double(
|
|
242
|
+
func: Callable[[ph_digest_t_ptr, ph_digest_t_ptr, double_ptr], int],
|
|
243
|
+
a: ph_digest_t_ptr,
|
|
244
|
+
b: ph_digest_t_ptr,
|
|
245
|
+
metric: str,
|
|
246
|
+
) -> float:
|
|
247
|
+
out = ffi.new("double *")
|
|
248
|
+
code = func(a, b, out)
|
|
249
|
+
if code != PH_SUCCESS:
|
|
250
|
+
raise make_error(code, context=metric)
|
|
251
|
+
return float(out[0])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _check_uint64(value: object) -> int:
|
|
255
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
256
|
+
raise TypeError(f"a 64-bit hash is an int, got {type(value).__name__}")
|
|
257
|
+
if not 0 <= value <= _UINT64_MAX:
|
|
258
|
+
raise InvalidArgumentError(f"a 64-bit hash is in [0, 2**64), got {value}")
|
|
259
|
+
return value
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def hamming_distance(a: int, b: int) -> int:
|
|
263
|
+
"""Count the differing bits between two 64-bit hashes, 0 to 64."""
|
|
264
|
+
return int(lib.ph_hamming_distance(_check_uint64(a), _check_uint64(b)))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def similarity(a: int, b: int) -> float:
|
|
268
|
+
"""Share of equal bits between two 64-bit hashes.
|
|
269
|
+
|
|
270
|
+
1.0 means identical; unrelated images score near 0.5.
|
|
271
|
+
"""
|
|
272
|
+
return float(lib.ph_similarity(_check_uint64(a), _check_uint64(b)))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def hash_to_hex(value: int) -> str:
|
|
276
|
+
"""Format a 64-bit hash as 16 lowercase hex digits, most significant first."""
|
|
277
|
+
out = ffi.new("char[]", 17)
|
|
278
|
+
code = lib.ph_hash_to_hex(_check_uint64(value), out, 17)
|
|
279
|
+
if code != PH_SUCCESS:
|
|
280
|
+
raise make_error(code, context="encoding hash")
|
|
281
|
+
return str(ffi.string(out).decode("ascii"))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def hash_from_hex(text: str) -> int:
|
|
285
|
+
"""Parse the 16 hex digits written by :func:`hash_to_hex`."""
|
|
286
|
+
out = ffi.new("uint64_t *")
|
|
287
|
+
code = lib.ph_hash_from_hex(encode_text(text), out)
|
|
288
|
+
if code != PH_SUCCESS:
|
|
289
|
+
raise make_error(code, context=f"parsing hash {text[:40]!r}")
|
|
290
|
+
return int(out[0])
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
RADIAL_PCC_THRESHOLD: float = float(lib.PH_RADIAL_PCC_THRESHOLD)
|
libphash/_errors.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Turning a ``ph_error_t`` into the exception of :mod:`libphash.exceptions`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ._native import ffi, lib
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
AllocationError,
|
|
12
|
+
BatchCancelledError,
|
|
13
|
+
CorruptDataError,
|
|
14
|
+
DecoderUnavailableError,
|
|
15
|
+
EmptyImageError,
|
|
16
|
+
ImageIOError,
|
|
17
|
+
ImageTooLargeError,
|
|
18
|
+
InvalidArgumentError,
|
|
19
|
+
NoStructureError,
|
|
20
|
+
PhashError,
|
|
21
|
+
RequiresColorError,
|
|
22
|
+
UnsupportedFormatError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from ._sources import StrPath
|
|
27
|
+
|
|
28
|
+
PH_SUCCESS = int(lib.PH_SUCCESS)
|
|
29
|
+
PH_ERR_IO = int(lib.PH_ERR_IO)
|
|
30
|
+
PH_ERR_CANCELLED = int(lib.PH_ERR_CANCELLED)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# The OSError subclasses an ImageIOError is raised as, so that ``except
|
|
34
|
+
# FileNotFoundError`` catches a missing image file. Their repr is ImageIOError's.
|
|
35
|
+
class FileNotFoundImageError(ImageIOError, FileNotFoundError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PermissionDeniedImageError(ImageIOError, PermissionError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IsADirectoryImageError(ImageIOError, IsADirectoryError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_CLASSES: dict[int, type[PhashError]] = {
|
|
48
|
+
int(lib.PH_ERR_ALLOCATION_FAILED): AllocationError,
|
|
49
|
+
int(lib.PH_ERR_INVALID_ARGUMENT): InvalidArgumentError,
|
|
50
|
+
int(lib.PH_ERR_EMPTY_IMAGE): EmptyImageError,
|
|
51
|
+
int(lib.PH_ERR_IMAGE_TOO_LARGE): ImageTooLargeError,
|
|
52
|
+
int(lib.PH_ERR_UNSUPPORTED_FORMAT): UnsupportedFormatError,
|
|
53
|
+
int(lib.PH_ERR_CORRUPT_DATA): CorruptDataError,
|
|
54
|
+
int(lib.PH_ERR_DECODER_UNAVAILABLE): DecoderUnavailableError,
|
|
55
|
+
PH_ERR_IO: ImageIOError,
|
|
56
|
+
int(lib.PH_ERR_REQUIRES_COLOR): RequiresColorError,
|
|
57
|
+
PH_ERR_CANCELLED: BatchCancelledError,
|
|
58
|
+
int(lib.PH_ERR_NO_STRUCTURE): NoStructureError,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def error_string(code: int) -> str:
|
|
63
|
+
"""Return the C library's one-line description of a ``ph_error_t``."""
|
|
64
|
+
return str(ffi.string(lib.ph_get_error_string(code)).decode())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def make_error(
|
|
68
|
+
code: int, *, context: str = "", detail: str = "", path: StrPath | None = None
|
|
69
|
+
) -> PhashError:
|
|
70
|
+
"""Build the exception for a ``ph_error_t``.
|
|
71
|
+
|
|
72
|
+
``context`` says what the binding was doing ("computing phash"); ``detail`` is the C
|
|
73
|
+
library's message for a load, and is passed only by the load functions, the only
|
|
74
|
+
ones that write it.
|
|
75
|
+
"""
|
|
76
|
+
cls = _CLASSES.get(code, PhashError)
|
|
77
|
+
message = detail or error_string(code)
|
|
78
|
+
if context:
|
|
79
|
+
message = f"{context}: {message}"
|
|
80
|
+
if cls is ImageIOError:
|
|
81
|
+
return _io_error(message, code, detail, path)
|
|
82
|
+
return cls(message, code=code, detail=detail)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _io_error(
|
|
86
|
+
message: str, code: int, detail: str, path: StrPath | None
|
|
87
|
+
) -> ImageIOError:
|
|
88
|
+
err: int | None = None
|
|
89
|
+
cls: type[ImageIOError] = ImageIOError
|
|
90
|
+
if path is not None:
|
|
91
|
+
# The C library reports every unusable path as PH_ERR_IO; the file system tells
|
|
92
|
+
# which OSError it is. isdir() and exists() answer False for a path they cannot
|
|
93
|
+
# check, so access() sees only paths that exist.
|
|
94
|
+
if os.path.isdir(path):
|
|
95
|
+
err, cls = errno.EISDIR, IsADirectoryImageError
|
|
96
|
+
elif not os.path.exists(path):
|
|
97
|
+
err, cls = errno.ENOENT, FileNotFoundImageError
|
|
98
|
+
elif not os.access(path, os.R_OK):
|
|
99
|
+
err, cls = errno.EACCES, PermissionDeniedImageError
|
|
100
|
+
exc = cls(message, code=code, detail=detail)
|
|
101
|
+
exc.errno = err
|
|
102
|
+
exc.filename = os.fsdecode(path) if path is not None else None
|
|
103
|
+
return exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def check(code: int, *, context: str = "") -> None:
|
|
107
|
+
"""Raise the exception for ``code`` unless it is ``PH_SUCCESS``."""
|
|
108
|
+
if code != PH_SUCCESS:
|
|
109
|
+
raise make_error(code, context=context)
|
libphash/_native.pyd
ADDED
|
Binary file
|