python-libphash 2.0.1.0__cp315-cp315t-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/_cli.py ADDED
@@ -0,0 +1,518 @@
1
+ """The command line, ``libphash``: ``info``, ``hash``, ``compare``, ``dupes``.
2
+
3
+ Results go to standard output, one line per item, or as JSON with ``--json``; a file
4
+ that cannot be hashed is reported on standard error (in the JSON itself with ``--json``)
5
+ and does not stop the others. The exit status is 0 when every file was hashed, 1 when
6
+ some were not, and 2 for a wrong command line.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import dataclasses
13
+ import io
14
+ import json
15
+ import os
16
+ import platform
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import TYPE_CHECKING, TextIO, cast
20
+
21
+ from . import (
22
+ Algorithm,
23
+ Config,
24
+ Digest,
25
+ DigestKind,
26
+ ImageContext,
27
+ PhashError,
28
+ build_info,
29
+ can_decode,
30
+ hamming_distance,
31
+ hash_files,
32
+ hash_to_hex,
33
+ libphash_version,
34
+ similarity,
35
+ )
36
+ from ._algorithms import UINT64_ALGORITHMS
37
+ from ._version import __version__
38
+ from .exceptions import ImageIOError, NoStructureError
39
+
40
+ if TYPE_CHECKING:
41
+ from collections.abc import Iterator, Sequence
42
+
43
+ #: Suffixes taken from a directory: the formats libphash decodes. A file named on the
44
+ #: command line is hashed whatever its name.
45
+ IMAGE_SUFFIXES = frozenset(
46
+ {
47
+ ".jpg", ".jpeg", ".jpe", ".jfif", ".png", ".webp", ".gif", ".bmp", ".tga",
48
+ ".psd", ".hdr", ".pic", ".pnm", ".pbm", ".pgm", ".ppm",
49
+ }
50
+ ) # fmt: skip
51
+
52
+ EXIT_OK = 0
53
+ EXIT_FAILED = 1
54
+ EXIT_USAGE = 2
55
+
56
+ _ALGORITHMS = {str(a): a for a in Algorithm}
57
+ _FIELDS = {f.name: f for f in dataclasses.fields(Config)}
58
+ _TRUE = frozenset({"1", "true", "yes", "on"})
59
+ _FALSE = frozenset({"0", "false", "no", "off"})
60
+
61
+
62
+ def main(argv: Sequence[str] | None = None, prog: str = "libphash") -> int:
63
+ """Run the command line with ``argv`` (``sys.argv[1:]`` by default).
64
+
65
+ ``prog`` is the command its help and errors name. Return the exit status.
66
+ """
67
+ parser = _parser(prog)
68
+ args = parser.parse_args(argv)
69
+ try:
70
+ config = parse_config(args.config)
71
+ except (ValueError, TypeError) as exc:
72
+ parser.error(str(exc))
73
+ write_any_file_name(sys.stdout)
74
+ try:
75
+ status: int = args.run(args, config, sys.stdout, sys.stderr)
76
+ sys.stdout.flush()
77
+ except KeyboardInterrupt:
78
+ return 128 + 2
79
+ except BrokenPipeError:
80
+ # The reader went away (`| head`): stop quietly. Python would report the failed
81
+ # flush of stdout at exit, so stdout is pointed at devnull first.
82
+ devnull = os.open(os.devnull, os.O_WRONLY)
83
+ os.dup2(devnull, sys.stdout.fileno())
84
+ return EXIT_FAILED
85
+ return status
86
+
87
+
88
+ # --- Command line ---------------------------------------------------------------------
89
+
90
+
91
+ def _parser(prog: str) -> argparse.ArgumentParser:
92
+ parser = argparse.ArgumentParser(
93
+ prog=prog,
94
+ description="Perceptual image hashing with libphash.",
95
+ epilog=(
96
+ "Exit status: 0 when every file was hashed, 1 when some were not, "
97
+ "2 for a wrong command line."
98
+ ),
99
+ )
100
+ parser.add_argument("--version", action="version", version=_version_line())
101
+ commands = parser.add_subparsers(title="commands", required=True)
102
+
103
+ common = argparse.ArgumentParser(add_help=False)
104
+ common.add_argument("--json", action="store_true", help="write the results as JSON")
105
+
106
+ configured = argparse.ArgumentParser(add_help=False)
107
+ configured.add_argument(
108
+ "-c",
109
+ "--config",
110
+ action="append",
111
+ default=[],
112
+ metavar="FIELD=VALUE",
113
+ help=(
114
+ "a Config field, as auto_orient=false or gray_weights=38,75,15; "
115
+ "repeat for several"
116
+ ),
117
+ )
118
+
119
+ info = commands.add_parser(
120
+ "info",
121
+ parents=[common],
122
+ help="versions and build of the bundled libphash, for bug reports",
123
+ description="Print the versions and the build of the bundled libphash.",
124
+ )
125
+ info.set_defaults(run=_run_info, config=[])
126
+
127
+ hash_cmd = commands.add_parser(
128
+ "hash",
129
+ parents=[common, configured],
130
+ help="hash image files",
131
+ description=(
132
+ "Print the hashes of image files: 64-bit hashes as 16 hex digits, "
133
+ "digests as kind:hex. A directory is searched recursively for images; "
134
+ "'-' reads one image from standard input."
135
+ ),
136
+ )
137
+ hash_cmd.add_argument("paths", nargs="+", metavar="PATH")
138
+ _algorithm_option(hash_cmd, "algorithms to compute (default: phash)")
139
+ hash_cmd.set_defaults(run=_run_hash)
140
+
141
+ compare = commands.add_parser(
142
+ "compare",
143
+ parents=[common, configured],
144
+ help="compare two images",
145
+ description=(
146
+ "Compare two images with each algorithm, by the measures that belong to "
147
+ "it: Hamming distance and similarity for bit hashes, similarity for "
148
+ "ColorHash and Radial, L2 distance for ColorMoments."
149
+ ),
150
+ )
151
+ compare.add_argument("first", metavar="IMAGE")
152
+ compare.add_argument("second", metavar="IMAGE")
153
+ _algorithm_option(compare, "algorithms to compare with (default: phash)")
154
+ compare.set_defaults(run=_run_compare)
155
+
156
+ dupes = commands.add_parser(
157
+ "dupes",
158
+ parents=[common, configured],
159
+ help="find images that hash alike",
160
+ description=(
161
+ "Hash every image with a 64-bit algorithm on all CPUs and print the "
162
+ "groups of images whose hashes differ in at most --max-bits bits, one "
163
+ "group per paragraph. Measure the threshold on your own images: "
164
+ "https://gudoshnikovn.github.io/libphash/theory/comparing/"
165
+ ),
166
+ )
167
+ dupes.add_argument("paths", nargs="+", metavar="PATH")
168
+ dupes.add_argument(
169
+ "--max-bits",
170
+ type=_bits,
171
+ required=True,
172
+ metavar="N",
173
+ help="the most bits two hashes of one picture may differ in, 0 to 64",
174
+ )
175
+ dupes.add_argument(
176
+ "-a",
177
+ "--algorithm",
178
+ type=_uint64_algorithm,
179
+ default=Algorithm.PHASH,
180
+ metavar="NAME",
181
+ help="ahash, dhash, phash (default) or whash",
182
+ )
183
+ dupes.add_argument(
184
+ "--threads",
185
+ type=_threads,
186
+ default=0,
187
+ metavar="N",
188
+ help="worker threads; 0 (default) is one per CPU",
189
+ )
190
+ dupes.set_defaults(run=_run_dupes)
191
+ return parser
192
+
193
+
194
+ def _algorithm_option(parser: argparse.ArgumentParser, help_text: str) -> None:
195
+ names = ", ".join(_ALGORITHMS)
196
+ parser.add_argument(
197
+ "-a",
198
+ "--algorithm",
199
+ dest="algorithms",
200
+ type=_algorithm_list,
201
+ action="extend",
202
+ metavar="NAME[,NAME...]",
203
+ help=f"{help_text}; any of {names}",
204
+ )
205
+
206
+
207
+ def _algorithm_list(text: str) -> list[Algorithm]:
208
+ out: list[Algorithm] = []
209
+ for name in text.split(","):
210
+ algo = _ALGORITHMS.get(name.strip().lower())
211
+ if algo is None:
212
+ names = ", ".join(_ALGORITHMS)
213
+ raise argparse.ArgumentTypeError(
214
+ f"unknown algorithm {name!r}; expected one of {names}"
215
+ )
216
+ out.append(algo)
217
+ return out
218
+
219
+
220
+ def _uint64_algorithm(text: str) -> Algorithm:
221
+ [algo] = _algorithm_list(text)
222
+ if algo not in UINT64_ALGORITHMS:
223
+ raise argparse.ArgumentTypeError(
224
+ f"{algo} is not a 64-bit hash; expected ahash, dhash, phash or whash"
225
+ )
226
+ return algo
227
+
228
+
229
+ def _bits(text: str) -> int:
230
+ value = _integer(text)
231
+ if not 0 <= value <= 64:
232
+ raise argparse.ArgumentTypeError(f"expected 0 to 64 bits, got {value}")
233
+ return value
234
+
235
+
236
+ def _threads(text: str) -> int:
237
+ value = _integer(text)
238
+ if value < 0:
239
+ raise argparse.ArgumentTypeError(f"expected 0 or more threads, got {value}")
240
+ return value
241
+
242
+
243
+ def _integer(text: str) -> int:
244
+ try:
245
+ return int(text)
246
+ except ValueError:
247
+ raise argparse.ArgumentTypeError(f"expected a number, got {text!r}") from None
248
+
249
+
250
+ def parse_config(assignments: list[str]) -> Config:
251
+ """Build a Config from ``field=value`` strings; Config checks the values."""
252
+ changes: dict[str, object] = {}
253
+ for assignment in assignments:
254
+ name, sep, text = assignment.partition("=")
255
+ name = name.strip()
256
+ if not sep:
257
+ raise ValueError(f"--config expects FIELD=VALUE, got {assignment!r}")
258
+ if name not in _FIELDS:
259
+ fields = ", ".join(_FIELDS)
260
+ raise ValueError(f"unknown Config field {name!r}; fields: {fields}")
261
+ changes[name] = _parse_value(name, text.strip())
262
+ return Config().replace(**changes)
263
+
264
+
265
+ def _parse_value(name: str, text: str) -> object:
266
+ default = _FIELDS[name].default
267
+ if isinstance(default, bool):
268
+ if text.lower() in _TRUE:
269
+ return True
270
+ if text.lower() in _FALSE:
271
+ return False
272
+ raise ValueError(f"{name}: expected true or false, got {text!r}")
273
+ if isinstance(default, tuple):
274
+ return tuple(_number(name, part, int) for part in text.split(","))
275
+ if isinstance(default, int):
276
+ return _number(name, text, int)
277
+ if isinstance(default, float):
278
+ return _number(name, text, float)
279
+ return text
280
+
281
+
282
+ def _number(name: str, text: str, kind: type[int | float]) -> int | float:
283
+ try:
284
+ return kind(text.strip())
285
+ except ValueError:
286
+ raise ValueError(f"{name}: expected a number, got {text!r}") from None
287
+
288
+
289
+ # --- Commands -------------------------------------------------------------------------
290
+
291
+
292
+ def _run_info(args: argparse.Namespace, _cfg: Config, out: TextIO, _err: TextIO) -> int:
293
+ info = {
294
+ "python-libphash": __version__,
295
+ "libphash": libphash_version(),
296
+ "build": build_info(),
297
+ "decoders": [f for f in ("jpeg", "png", "webp") if can_decode(f)],
298
+ "python": f"{platform.python_implementation()} {platform.python_version()}",
299
+ "gil": _gil(),
300
+ "platform": platform.platform(),
301
+ }
302
+ if args.json:
303
+ json.dump(info, out, indent=2)
304
+ out.write("\n")
305
+ return EXIT_OK
306
+ width = max(len(key) for key in info)
307
+ for key, value in info.items():
308
+ text = " ".join(value) if isinstance(value, list) else value
309
+ out.write(f"{key:<{width}} {text}\n")
310
+ return EXIT_OK
311
+
312
+
313
+ def _gil() -> str:
314
+ """Say whether the GIL is on, and whether the interpreter can run without it."""
315
+ is_enabled = getattr(sys, "_is_gil_enabled", None)
316
+ if is_enabled is None:
317
+ return "enabled"
318
+ return "enabled" if is_enabled() else "disabled (free-threaded)"
319
+
320
+
321
+ def _run_hash(
322
+ args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
323
+ ) -> int:
324
+ algorithms: list[Algorithm] = args.algorithms or [Algorithm.PHASH]
325
+ status = EXIT_OK
326
+ with ImageContext(config) as ctx:
327
+ for path in _expand(args.paths):
328
+ try:
329
+ if path == "-":
330
+ ctx.load_bytes(sys.stdin.buffer.read())
331
+ else:
332
+ ctx.load_file(path)
333
+ hashes = ctx.hashes(*algorithms)
334
+ except PhashError as exc:
335
+ status = EXIT_FAILED
336
+ if args.json:
337
+ out.write(json.dumps({"path": path, "error": _error(exc)}) + "\n")
338
+ else:
339
+ _report(err, path, exc)
340
+ continue
341
+ texts = {str(a): _text(hashes[a]) for a in algorithms}
342
+ if args.json:
343
+ out.write(json.dumps({"path": path, "hashes": texts}) + "\n")
344
+ else:
345
+ out.write(f"{' '.join(texts.values())} {path}\n")
346
+ return status
347
+
348
+
349
+ def _run_compare(
350
+ args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
351
+ ) -> int:
352
+ algorithms: list[Algorithm] = args.algorithms or [Algorithm.PHASH]
353
+ hashes: list[dict[Algorithm, int | Digest]] = []
354
+ for path in (args.first, args.second):
355
+ with ImageContext(config) as ctx:
356
+ try:
357
+ ctx.load_file(path)
358
+ hashes.append(ctx.hashes(*algorithms))
359
+ except PhashError as exc:
360
+ if args.json:
361
+ json.dump({"path": path, "error": _error(exc)}, out, indent=2)
362
+ out.write("\n")
363
+ else:
364
+ _report(err, path, exc)
365
+ return EXIT_FAILED
366
+ first, second = hashes
367
+ results = {str(a): _measures(first[a], second[a]) for a in algorithms}
368
+ if args.json:
369
+ json.dump(results, out, indent=2)
370
+ out.write("\n")
371
+ return EXIT_OK
372
+ width = max(len(name) for name in results)
373
+ for name, measures in results.items():
374
+ text = ", ".join(_measure_text(key, value) for key, value in measures.items())
375
+ out.write(f"{name:<{width}} {text}\n")
376
+ return EXIT_OK
377
+
378
+
379
+ def _measures(a: int | Digest, b: int | Digest) -> dict[str, int | float | None]:
380
+ """Return the measures that belong to the hashes' kind, by name."""
381
+ if not (isinstance(a, Digest) and isinstance(b, Digest)):
382
+ a, b = cast("int", a), cast("int", b)
383
+ return {"distance": hamming_distance(a, b), "similarity": similarity(a, b)}
384
+ if a.kind == DigestKind.BITS:
385
+ return {"distance": a.hamming_distance(b), "similarity": a.similarity(b)}
386
+ if a.kind == DigestKind.VECTOR16:
387
+ return {"l2_distance": a.l2_distance(b)}
388
+ try:
389
+ return {"similarity": a.similarity(b)}
390
+ except NoStructureError:
391
+ # A flat image has no Radial structure to correlate: no score exists.
392
+ return {"similarity": None}
393
+
394
+
395
+ def _measure_text(name: str, value: float | None) -> str:
396
+ if value is None:
397
+ return f"{name} none (an image without structure)"
398
+ if isinstance(value, int):
399
+ return f"{name} {value}"
400
+ return f"{name} {value:.4f}"
401
+
402
+
403
+ def _run_dupes(
404
+ args: argparse.Namespace, config: Config, out: TextIO, err: TextIO
405
+ ) -> int:
406
+ algo: Algorithm = args.algorithm
407
+ paths = list(_expand(args.paths))
408
+ results = hash_files(paths, algo, config=config, threads=args.threads)
409
+ hashed: list[tuple[str, int]] = []
410
+ failed: list[tuple[str, PhashError]] = []
411
+ for result in results:
412
+ if result.hashes is not None:
413
+ hashed.append((result.source, result.hashes[algo]))
414
+ else:
415
+ failed.append((result.source, cast("PhashError", result.error)))
416
+ groups = group_hashes(hashed, args.max_bits)
417
+ status = EXIT_FAILED if failed else EXIT_OK
418
+ if args.json:
419
+ doc = {
420
+ "groups": [
421
+ [{"path": p, "hash": hash_to_hex(h)} for p, h in g] for g in groups
422
+ ],
423
+ "errors": [{"path": p, "error": _error(e)} for p, e in failed],
424
+ }
425
+ json.dump(doc, out, indent=2)
426
+ out.write("\n")
427
+ return status
428
+ for path, exc in failed:
429
+ _report(err, path, exc)
430
+ for i, group in enumerate(groups):
431
+ if i:
432
+ out.write("\n")
433
+ for path, value in group:
434
+ out.write(f"{hash_to_hex(value)} {path}\n")
435
+ return status
436
+
437
+
438
+ def group_hashes(
439
+ hashed: list[tuple[str, int]], max_bits: int
440
+ ) -> list[list[tuple[str, int]]]:
441
+ """Group the images linked by a chain of pairs within ``max_bits`` of each other.
442
+
443
+ Every pair is compared once. The groups and the images in each keep the order of
444
+ ``hashed``; images alone in their group are left out.
445
+ """
446
+ parent = list(range(len(hashed)))
447
+
448
+ def root(i: int) -> int:
449
+ while parent[i] != i:
450
+ parent[i] = parent[parent[i]]
451
+ i = parent[i]
452
+ return i
453
+
454
+ for i, (_, a) in enumerate(hashed):
455
+ for j in range(i + 1, len(hashed)):
456
+ if (a ^ hashed[j][1]).bit_count() <= max_bits:
457
+ parent[root(j)] = root(i)
458
+ members: dict[int, list[tuple[str, int]]] = {}
459
+ for i, item in enumerate(hashed):
460
+ members.setdefault(root(i), []).append(item)
461
+ return [group for group in members.values() if len(group) > 1]
462
+
463
+
464
+ # --- Helpers --------------------------------------------------------------------------
465
+
466
+
467
+ def write_any_file_name(stream: TextIO) -> None:
468
+ """Let the output stream write every file name it is given.
469
+
470
+ A POSIX file name is bytes, which need not decode in the file system encoding: the
471
+ command line and os.walk() give the undecodable bytes as surrogates, written back as
472
+ those bytes, the name as ``ls`` prints it. A Windows file name is Unicode, which the
473
+ console's code page may not hold: a character it lacks is written as an escape.
474
+ Either way the output goes on instead of stopping at UnicodeEncodeError.
475
+ """
476
+ if isinstance(stream, io.TextIOWrapper) and stream.errors == "strict":
477
+ errors = "backslashreplace" if sys.platform == "win32" else "surrogateescape"
478
+ stream.reconfigure(errors=errors)
479
+
480
+
481
+ def _expand(paths: Sequence[str]) -> Iterator[str]:
482
+ """Yield the files named, and the images in the directories named, in order.
483
+
484
+ A directory is walked recursively, its entries sorted by name, and contributes the
485
+ files whose suffix is in IMAGE_SUFFIXES. Anything else is yielded as given, so that
486
+ a missing file is reported by the load.
487
+ """
488
+ for path in paths:
489
+ if path != "-" and Path(path).is_dir():
490
+ for root, dirs, files in os.walk(path):
491
+ dirs.sort()
492
+ for name in sorted(files):
493
+ if Path(name).suffix.lower() in IMAGE_SUFFIXES:
494
+ yield os.path.join(root, name)
495
+ else:
496
+ yield path
497
+
498
+
499
+ def _text(value: int | Digest) -> str:
500
+ return hash_to_hex(value) if isinstance(value, int) else value.hex()
501
+
502
+
503
+ def _error(exc: PhashError) -> dict[str, str]:
504
+ # An ImageIOError is raised as a private subclass per errno; name the public one.
505
+ name = "ImageIOError" if isinstance(exc, ImageIOError) else type(exc).__name__
506
+ return {"type": name, "code": exc.code_name, "message": str(exc)}
507
+
508
+
509
+ def _report(err: TextIO, path: str, exc: PhashError) -> None:
510
+ # Most messages of a failed load name the file already.
511
+ message = str(exc)
512
+ if path not in message:
513
+ message = f"{path}: {message}"
514
+ err.write(f"libphash: {message}\n")
515
+
516
+
517
+ def _version_line() -> str:
518
+ return f"python-libphash {__version__} (libphash {libphash_version()})"