pith-hash 0.1.1__py3-none-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.
pith_hash/__init__.py ADDED
@@ -0,0 +1,498 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 pith-hash
3
+ """pith-hash SDK: the suite curator through ctypes.
4
+
5
+ Detect, tier-1 content hash, tier-2 signatures, match and describe via
6
+ the `pith_hash` Rust cdylib. The byte streams the cdylib hands out are
7
+ the canonical serializations of the Rust `reference` module — the same
8
+ layouts the repository-root `reference.json` records its fnv1a64 /
9
+ sha256 sub-digests over — so this wrapper parses them back into plain
10
+ Python objects and re-hashes every payload.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ctypes
16
+ import os
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ __all__ = [
21
+ "Detection",
22
+ "Signature",
23
+ "Description",
24
+ "MatchResult",
25
+ "FfiError",
26
+ "LibraryNotFoundError",
27
+ "find_cdylib",
28
+ "detect",
29
+ "signature",
30
+ "describe",
31
+ "match_signatures",
32
+ "content_hash",
33
+ "parse_signature",
34
+ "parse_describe",
35
+ "fnv1a64",
36
+ "FORMATS",
37
+ "MODALITIES",
38
+ "MATCH_TAGS",
39
+ "STATUS_OK",
40
+ "STATUS_INVALID",
41
+ "STATUS_REJECTED",
42
+ ]
43
+
44
+ #: Status: success.
45
+ STATUS_OK = 0
46
+ #: Status: a caller argument is invalid (a null pointer, a capacity too
47
+ #: small for the fixed wire shape).
48
+ STATUS_INVALID = -1
49
+ #: Status: the core pipeline refused the input (decode failure in any
50
+ #: modality, a pending lane, a PDF/audio-layer rejection, or a
51
+ #: cross-modality match).
52
+ STATUS_REJECTED = -2
53
+
54
+ #: Format names by wire code (`pith_digest::Format` declaration order).
55
+ FORMATS = ("png", "jpeg", "bmp", "zip", "mp4", "mp3", "wav", "flac", "pdf", "gif", "unknown")
56
+
57
+ #: Modality names by wire code (declaration order).
58
+ MODALITIES = ("image", "audio", "text", "binary", "video")
59
+
60
+ #: Match-outcome tags by wire code (declaration order).
61
+ MATCH_TAGS = ("image", "audio", "text", "binary", "video")
62
+
63
+ #: Signature-stream tag bytes (declaration order).
64
+ SIGNATURE_TAGS = ("image", "audio", "text", "binary", "video")
65
+
66
+ #: Every cdylib file name cargo may drop into the build directory, per
67
+ #: platform (windows / linux / macOS).
68
+ CDYLIB_NAMES = ("pith_hash.dll", "libpith_hash.so", "libpith_hash.dylib")
69
+
70
+
71
+ class LibraryNotFoundError(OSError):
72
+ """No cdylib was found through the discovery chain."""
73
+
74
+
75
+ class FfiError(Exception):
76
+ """A non-zero status code came back from the cdylib."""
77
+
78
+ def __init__(self, op: str, status: int) -> None:
79
+ kind = {
80
+ STATUS_INVALID: "invalid argument",
81
+ STATUS_REJECTED: "input rejected",
82
+ }.get(status, "unknown failure")
83
+ super().__init__(f"{op} failed: {kind} (status {status})")
84
+ #: The raw status code the FFI returned.
85
+ self.status = status
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Detection:
90
+ """What `detect` found, re-expressed from the wire codes."""
91
+
92
+ #: The sniffed container name (`unknown` for bare text/binary).
93
+ format: str
94
+ #: The modality lane that handles it.
95
+ modality: str
96
+ #: `True` when the lane is named but not served yet.
97
+ pending: bool
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Signature:
102
+ """A tier-2 signature, re-expressed from the signature stream.
103
+
104
+ The `*_count` fields mirror `reference.json`'s semantics: they are
105
+ the *byte length* of the canonical payload the sub-digests cover
106
+ (594 = 99 peaks × 6 B, 1024 = 128 words × 8 B).
107
+ """
108
+
109
+ #: The modality tag (`image` … `video`).
110
+ modality: str
111
+ #: Image: the 64-bit perceptual hash.
112
+ phash: int | None = None
113
+ #: Audio: peak payload byte count, analysis frames, `(t, f)` peaks.
114
+ peak_count: int | None = None
115
+ #: Audio: analysis frames the signature covered.
116
+ frames: int | None = None
117
+ #: Audio: parsed `(t, f)` peak pairs.
118
+ peaks: tuple[tuple[int, int], ...] | None = None
119
+ #: Audio: the canonical peaks payload the digests cover.
120
+ peaks_bytes: bytes | None = None
121
+ #: Text/binary/video: payload byte counts.
122
+ word_count: int | None = None
123
+ #: Text: the 128 big-endian-carried MinHash words.
124
+ words: tuple[int, ...] | None = None
125
+ #: Binary: digest payload byte count and the 32-byte digests.
126
+ chunk_count: int | None = None
127
+ #: Binary: the sorted unique chunk digests, concatenated.
128
+ digest_bytes: bytes | None = None
129
+ #: Video: frame payload byte count, geometry, duration IEEE bits.
130
+ frame_count: int | None = None
131
+ #: Video: displayed geometry.
132
+ width: int | None = None
133
+ #: Video: displayed height.
134
+ height: int | None = None
135
+ #: Video: track duration as the raw IEEE-754 bit pattern.
136
+ duration_bits: int | None = None
137
+ #: Video: the frame-pHash chain, little-endian words.
138
+ frame_hashes: tuple[int, ...] | None = None
139
+ #: Video: the MinHash words over the chain's shingles.
140
+ minhash: tuple[int, ...] | None = None
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Description:
145
+ """Both tiers plus facts, re-expressed from the describe stream."""
146
+
147
+ #: The sniffed container name.
148
+ format: str
149
+ #: The modality lane that answered.
150
+ modality: str
151
+ #: Tier 1: hex of the 32-byte content digest.
152
+ tier1: str
153
+ #: Tier 2: the parsed signature stream.
154
+ signature: Signature
155
+ #: Modality facts (dimensions, rate, counts).
156
+ facts: dict
157
+
158
+
159
+ @dataclass(frozen=True)
160
+ class MatchResult:
161
+ """A match outcome, re-expressed from the slots."""
162
+
163
+ #: The outcome tag (`image` … `video`).
164
+ tag: str
165
+ #: The outcome's fields: `hamming`; `votes, delta_t` (delta_t is
166
+ #: the `i64` two's-complement pattern, sign-extended back);
167
+ #: `jaccard`; `score, minhash_jaccard` (f64 bit patterns).
168
+ slots: tuple[int, ...]
169
+ #: Advisory verdict.
170
+ matched: bool
171
+
172
+ def slot_float(self, index: int) -> float:
173
+ """Reinterprets slot `index` as an IEEE-754 double."""
174
+ import struct
175
+
176
+ return struct.unpack("<d", struct.pack("<Q", self.slots[index]))[0]
177
+
178
+
179
+ def fnv1a64(data: bytes) -> int:
180
+ """The 64-bit FNV-1a fold the reference sub-digests use."""
181
+ h = 0xCBF29CE484222325
182
+ for b in data:
183
+ h ^= b
184
+ h = (h * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF
185
+ return h
186
+
187
+
188
+ def find_cdylib() -> Path:
189
+ """Locates the cdylib through the suite's discovery chain."""
190
+ explicit = os.environ.get("PITH_CDYLIB")
191
+ if explicit:
192
+ p = Path(explicit)
193
+ if p.is_file():
194
+ return p
195
+ env_dir = os.environ.get("PITH_CDYLIB_DIR")
196
+ candidates: list[Path] = []
197
+ if env_dir:
198
+ env_dir_path = Path(env_dir)
199
+ candidates.append(env_dir_path)
200
+ if not env_dir_path.is_absolute():
201
+ # CD and local runs invoke tools from the repository root or
202
+ # from sdk/<lang>; resolve the env value against both.
203
+ candidates.append(Path.cwd() / env_dir_path)
204
+ candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
205
+ candidates.append(Path(__file__).resolve().parent) # packaged wheel
206
+ candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
207
+ for directory in candidates:
208
+ for name in CDYLIB_NAMES:
209
+ p = directory / name
210
+ if p.is_file():
211
+ return p
212
+ raise LibraryNotFoundError(
213
+ "no pith-hash cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
214
+ "the package directory and <repo>/target/release); "
215
+ "run `cargo build --release` first"
216
+ )
217
+
218
+
219
+ _lib: ctypes.CDLL | None = None
220
+
221
+
222
+ def _load() -> ctypes.CDLL:
223
+ global _lib
224
+ if _lib is None:
225
+ lib = ctypes.CDLL(str(find_cdylib()))
226
+ lib.pith_hash_detect.argtypes = [
227
+ ctypes.c_void_p, # data
228
+ ctypes.c_size_t, # len
229
+ ctypes.POINTER(ctypes.c_uint32), # out format
230
+ ctypes.POINTER(ctypes.c_uint32), # out modality
231
+ ctypes.POINTER(ctypes.c_uint32), # out pending
232
+ ]
233
+ lib.pith_hash_detect.restype = ctypes.c_int32
234
+ for op in ("pith_hash_signature", "pith_hash_describe"):
235
+ fn = getattr(lib, op)
236
+ fn.argtypes = [
237
+ ctypes.c_void_p, # data
238
+ ctypes.c_size_t, # len
239
+ ctypes.POINTER(ctypes.c_void_p), # out buffer
240
+ ctypes.POINTER(ctypes.c_size_t), # out length
241
+ ]
242
+ fn.restype = ctypes.c_int32
243
+ lib.pith_hash_match.argtypes = [
244
+ ctypes.c_void_p, # a
245
+ ctypes.c_size_t, # a_len
246
+ ctypes.c_void_p, # b
247
+ ctypes.c_size_t, # b_len
248
+ ctypes.POINTER(ctypes.c_uint32), # out tag
249
+ ctypes.POINTER(ctypes.c_uint64), # out slots
250
+ ctypes.c_size_t, # slots capacity
251
+ ctypes.POINTER(ctypes.c_uint32), # out matched
252
+ ]
253
+ lib.pith_hash_match.restype = ctypes.c_int32
254
+ lib.pith_hash_content_hash.argtypes = [
255
+ ctypes.c_void_p, # data
256
+ ctypes.c_size_t, # len
257
+ ctypes.POINTER(ctypes.c_ubyte), # out buffer
258
+ ctypes.c_size_t, # out capacity
259
+ ctypes.POINTER(ctypes.c_size_t), # out length
260
+ ]
261
+ lib.pith_hash_content_hash.restype = ctypes.c_int32
262
+ lib.pith_hash_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
263
+ lib.pith_hash_free.restype = None
264
+ _lib = lib
265
+ return _lib
266
+
267
+
268
+ def _hand_out(status: int, op: str, out: ctypes.c_void_p, out_len: ctypes.c_size_t) -> bytes:
269
+ """Copies a handed-out buffer and frees it (null-safe on error)."""
270
+ if status != STATUS_OK:
271
+ raise FfiError(op, status)
272
+ try:
273
+ return ctypes.string_at(out, out_len.value)
274
+ finally:
275
+ _load().pith_hash_free(out, out_len.value)
276
+
277
+
278
+ def detect(data: bytes) -> Detection:
279
+ """Sniffs `data` without decoding: format, modality, pending."""
280
+ fmt = ctypes.c_uint32()
281
+ mod = ctypes.c_uint32()
282
+ pending = ctypes.c_uint32()
283
+ status = _load().pith_hash_detect(
284
+ data, len(data), ctypes.byref(fmt), ctypes.byref(mod), ctypes.byref(pending)
285
+ )
286
+ if status != STATUS_OK:
287
+ raise FfiError("pith_hash_detect", status)
288
+ return Detection(
289
+ format=FORMATS[fmt.value],
290
+ modality=MODALITIES[mod.value],
291
+ pending=pending.value == 1,
292
+ )
293
+
294
+
295
+ def _stream_op(op: str, data: bytes) -> bytes:
296
+ out = ctypes.c_void_p()
297
+ out_len = ctypes.c_size_t()
298
+ status = getattr(_load(), op)(data, len(data), ctypes.byref(out), ctypes.byref(out_len))
299
+ return _hand_out(status, op, out, out_len)
300
+
301
+
302
+ def signature(data: bytes) -> Signature:
303
+ """Computes the tier-2 signature and parses the signature stream."""
304
+ return parse_signature(_stream_op("pith_hash_signature", data))
305
+
306
+
307
+ def describe(data: bytes) -> Description:
308
+ """Computes both tiers and parses the describe stream."""
309
+ return parse_describe(_stream_op("pith_hash_describe", data))
310
+
311
+
312
+ def match_signatures(a: bytes, b: bytes) -> MatchResult:
313
+ """Scores two raw inputs, slots flattened per the wire table.
314
+
315
+ Raises :class:`FfiError` with ``status == STATUS_REJECTED`` for a
316
+ cross-modality pair (nothing is written on that path).
317
+ """
318
+ tag = ctypes.c_uint32()
319
+ slots = (ctypes.c_uint64 * 4)()
320
+ matched = ctypes.c_uint32()
321
+ status = _load().pith_hash_match(
322
+ a,
323
+ len(a),
324
+ b,
325
+ len(b),
326
+ ctypes.byref(tag),
327
+ slots,
328
+ len(slots),
329
+ ctypes.byref(matched),
330
+ )
331
+ if status != STATUS_OK:
332
+ raise FfiError("pith_hash_match", status)
333
+ return MatchResult(
334
+ tag=MATCH_TAGS[tag.value],
335
+ slots=tuple(s for s in slots),
336
+ matched=matched.value == 1,
337
+ )
338
+
339
+
340
+ def content_hash(data: bytes) -> bytes:
341
+ """Computes the 32-byte tier-1 content digest."""
342
+ buf = (ctypes.c_ubyte * 32)()
343
+ out_len = ctypes.c_size_t()
344
+ status = _load().pith_hash_content_hash(
345
+ data, len(data), buf, len(buf), ctypes.byref(out_len)
346
+ )
347
+ if status != STATUS_OK:
348
+ raise FfiError("pith_hash_content_hash", status)
349
+ return bytes(buf[: out_len.value])
350
+
351
+
352
+ def parse_signature(raw: bytes) -> Signature:
353
+ """Re-expresses the canonical signature stream as a :class:`Signature`."""
354
+ if len(raw) < 1:
355
+ raise ValueError("signature stream is empty")
356
+ tag = raw[0]
357
+ if tag >= len(SIGNATURE_TAGS):
358
+ raise ValueError(f"unknown signature tag {tag}")
359
+ modality = SIGNATURE_TAGS[tag]
360
+ if modality == "image":
361
+ if len(raw) != 9:
362
+ raise ValueError("image stream is not tag + u64")
363
+ return Signature(modality=modality, phash=int.from_bytes(raw[1:9], "big"))
364
+ if modality == "audio":
365
+ if len(raw) < 13:
366
+ raise ValueError("audio stream is shorter than its 13-byte header")
367
+ peak_count = int.from_bytes(raw[1:9], "big")
368
+ frames = int.from_bytes(raw[9:13], "big")
369
+ payload = raw[13:]
370
+ if peak_count != len(payload) or len(payload) % 6 != 0:
371
+ raise ValueError("audio peak count disagrees with the payload")
372
+ peaks = tuple(
373
+ (
374
+ int.from_bytes(payload[i : i + 4], "little"),
375
+ int.from_bytes(payload[i + 4 : i + 6], "little"),
376
+ )
377
+ for i in range(0, len(payload), 6)
378
+ )
379
+ return Signature(
380
+ modality=modality,
381
+ peak_count=peak_count,
382
+ frames=frames,
383
+ peaks=peaks,
384
+ peaks_bytes=payload,
385
+ )
386
+ if modality == "text":
387
+ if len(raw) < 9:
388
+ raise ValueError("text stream is shorter than its 9-byte header")
389
+ word_count = int.from_bytes(raw[1:9], "big")
390
+ payload = raw[9:]
391
+ if word_count != len(payload) or len(payload) % 8 != 0:
392
+ raise ValueError("text word count disagrees with the payload")
393
+ words = tuple(int.from_bytes(payload[i : i + 8], "big") for i in range(0, len(payload), 8))
394
+ return Signature(modality=modality, word_count=word_count, words=words)
395
+ if modality == "binary":
396
+ if len(raw) < 9:
397
+ raise ValueError("binary stream is shorter than its 9-byte header")
398
+ chunk_count = int.from_bytes(raw[1:9], "big")
399
+ payload = raw[9:]
400
+ if chunk_count != len(payload) or len(payload) % 32 != 0:
401
+ raise ValueError("binary chunk count disagrees with the payload")
402
+ return Signature(modality=modality, chunk_count=chunk_count, digest_bytes=payload)
403
+ # video
404
+ if len(raw) < 18:
405
+ raise ValueError("video stream is shorter than its 18-byte header")
406
+ frame_count = int.from_bytes(raw[1:5], "big")
407
+ width = int.from_bytes(raw[5:9], "big")
408
+ height = int.from_bytes(raw[9:13], "big")
409
+ duration_bits = int.from_bytes(raw[13:21], "big")
410
+ rest = raw[21:]
411
+ if frame_count > len(rest) or frame_count % 8 != 0:
412
+ raise ValueError("video frame count disagrees with the payload")
413
+ frames_payload = rest[:frame_count]
414
+ minhash_payload = rest[frame_count:]
415
+ if len(minhash_payload) % 8 != 0:
416
+ raise ValueError("video minhash payload is not whole words")
417
+ return Signature(
418
+ modality=modality,
419
+ frame_count=frame_count,
420
+ width=width,
421
+ height=height,
422
+ duration_bits=duration_bits,
423
+ frame_hashes=tuple(
424
+ int.from_bytes(frames_payload[i : i + 8], "little")
425
+ for i in range(0, len(frames_payload), 8)
426
+ ),
427
+ minhash=tuple(
428
+ int.from_bytes(minhash_payload[i : i + 8], "little")
429
+ for i in range(0, len(minhash_payload), 8)
430
+ ),
431
+ )
432
+
433
+
434
+ def parse_describe(raw: bytes) -> Description:
435
+ """Re-expresses the canonical describe stream as a :class:`Description`."""
436
+ if len(raw) < 35:
437
+ raise ValueError("describe stream is shorter than its 35-byte header")
438
+ fmt = raw[0]
439
+ mod = raw[1]
440
+ if fmt >= len(FORMATS) or mod >= len(MODALITIES):
441
+ raise ValueError(f"unknown describe codes {fmt}/{mod}")
442
+ tier1 = raw[2:34].hex()
443
+ modality = MODALITIES[mod]
444
+ # The facts block terminates the stream; its size is known from
445
+ # the modality code, so the signature stream is the exact middle
446
+ # slice (a bare parse_signature would see the facts as payload).
447
+ sig_end = len(raw) - _facts_len(modality)
448
+ sig = parse_signature(raw[34:sig_end])
449
+ facts_raw = raw[sig_end:]
450
+ facts: dict = {}
451
+ if modality == "image":
452
+ _need(facts_raw, 16)
453
+ facts = {"width": _be32(facts_raw, 0), "height": _be32(facts_raw, 4), "keypoints": _be64(facts_raw, 8)}
454
+ elif modality == "audio":
455
+ _need(facts_raw, 18)
456
+ facts = {
457
+ "sample_rate": _be32(facts_raw, 0),
458
+ "channels": int.from_bytes(facts_raw[4:6], "big"),
459
+ "frames": _be32(facts_raw, 6),
460
+ "peaks": _be64(facts_raw, 10),
461
+ }
462
+ elif modality == "text":
463
+ _need(facts_raw, 16)
464
+ facts = {"words": _be64(facts_raw, 0), "canonical_len": _be64(facts_raw, 8)}
465
+ elif modality == "binary":
466
+ _need(facts_raw, 16)
467
+ facts = {"len": _be64(facts_raw, 0), "chunks": _be64(facts_raw, 8)}
468
+ else:
469
+ _need(facts_raw, 24)
470
+ facts = {
471
+ "width": _be32(facts_raw, 0),
472
+ "height": _be32(facts_raw, 4),
473
+ "duration_bits": _be64(facts_raw, 8),
474
+ "frames_sampled": _be64(facts_raw, 16),
475
+ }
476
+ return Description(format=FORMATS[fmt], modality=modality, tier1=tier1, signature=sig, facts=facts)
477
+
478
+
479
+ def _be32(raw: bytes, off: int) -> int:
480
+ return int.from_bytes(raw[off : off + 4], "big")
481
+
482
+
483
+ def _be64(raw: bytes, off: int) -> int:
484
+ return int.from_bytes(raw[off : off + 8], "big")
485
+
486
+
487
+ def _need(raw: bytes, n: int) -> None:
488
+ if len(raw) != n:
489
+ raise ValueError(f"facts block is {len(raw)} bytes, want {n}")
490
+
491
+
492
+ def _facts_len(modality: str) -> int:
493
+ """The byte size of each modality's facts block."""
494
+ if modality == "audio":
495
+ return 18
496
+ if modality == "video":
497
+ return 24
498
+ return 16
Binary file
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: pith-hash
3
+ Version: 0.1.1
4
+ Summary: The pith suite curator SDK: detect, tier-1 content hash, tier-2 signatures, match and describe via the pith-hash Rust cdylib (ctypes, zero-dep)
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/pith-hash/pith-hash
7
+ Project-URL: Homepage, https://pith-hash.n24q02m.com
8
+ Keywords: hashing,perceptual-hash,minhash,content-defined-chunking,similarity
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Rust
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Requires-Python: >=3.9
@@ -0,0 +1,6 @@
1
+ pith_hash/__init__.py,sha256=Z2bamYsYVDrYzAAFYEJ9PR3lLLSrrxOxkNko6LWTj_U,17658
2
+ pith_hash/pith_hash.dll,sha256=9HTOHoXpos6gJQX1wYXdiXid_Lr0OItYZQoO0IhWkiQ,1163776
3
+ pith_hash-0.1.1.dist-info/METADATA,sha256=d0ZuKHJF2Y8NVw8qXdwg-PgN70Zg1cvvYL73fYucUh4,595
4
+ pith_hash-0.1.1.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
5
+ pith_hash-0.1.1.dist-info/top_level.txt,sha256=TT0LYUXFGlPseRbcyge9VGG13UvBDtJGTxvzsGnC0BY,10
6
+ pith_hash-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1 @@
1
+ pith_hash