from __future__ import annotations
import ctypes
import os
import struct as _struct
from dataclasses import dataclass
from pathlib import Path
__all__ = [
"Fingerprint",
"FfiError",
"LibraryNotFoundError",
"SIGNATURE_WORDS",
"STATUS_OK",
"STATUS_INVALID",
"STATUS_REJECTED",
"find_cdylib",
"fingerprint_stream",
"parse_fingerprint",
"jaccard_bits",
"jaccard",
]
STATUS_OK = 0
STATUS_INVALID = -1
STATUS_REJECTED = -2
SIGNATURE_WORDS = 128
CDYLIB_NAMES = ("pith_text.dll", "libpith_text.so", "libpith_text.dylib")
@dataclass(frozen=True)
class Fingerprint:
word_count: int
shingle_count: int
canonical: bytes
signature: tuple[int, ...]
raw: bytes
class LibraryNotFoundError(OSError):
class FfiError(Exception):
def __init__(self, op: str, status: int) -> None:
kind = {
STATUS_INVALID: "invalid argument",
STATUS_REJECTED: "input rejected",
}.get(status, "unknown failure")
super().__init__(f"{op} failed: {kind} (status {status})")
self.status = status
def find_cdylib() -> Path:
explicit = os.environ.get("PITH_CDYLIB")
if explicit:
p = Path(explicit)
if p.is_file():
return p
env_dir = os.environ.get("PITH_CDYLIB_DIR")
candidates: list[Path] = []
if env_dir:
env_dir_path = Path(env_dir)
candidates.append(env_dir_path)
if not env_dir_path.is_absolute():
candidates.append(Path.cwd() / env_dir_path)
candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
candidates.append(Path(__file__).resolve().parent) candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
for directory in candidates:
for name in CDYLIB_NAMES:
p = directory / name
if p.is_file():
return p
raise LibraryNotFoundError(
"no pith-text cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
"the package directory and <repo>/target/release); "
"run `cargo build --release` first"
)
_lib: ctypes.CDLL | None = None
def _load() -> ctypes.CDLL:
global _lib
if _lib is None:
lib = ctypes.CDLL(str(find_cdylib()))
lib.pith_text_fingerprint.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_void_p), ctypes.POINTER(ctypes.c_size_t), ]
lib.pith_text_fingerprint.restype = ctypes.c_int32
lib.pith_text_jaccard.argtypes = [
ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(ctypes.c_uint64), ]
lib.pith_text_jaccard.restype = ctypes.c_int32
lib.pith_text_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
lib.pith_text_free.restype = None
_lib = lib
return _lib
def _as_bytes(data: bytes | str) -> bytes:
if isinstance(data, str):
return data.encode("utf-8")
return data
def fingerprint_stream(data: bytes | str) -> bytes:
blob = _as_bytes(data)
out = ctypes.c_void_p()
out_len = ctypes.c_size_t()
status = _load().pith_text_fingerprint(blob, len(blob), ctypes.byref(out), ctypes.byref(out_len))
if status != STATUS_OK:
raise FfiError("pith_text_fingerprint", status)
try:
return ctypes.string_at(out, out_len.value)
finally:
_load().pith_text_free(out, out_len.value)
def parse_fingerprint(raw: bytes) -> Fingerprint:
tail = SIGNATURE_WORDS * 8
if len(raw) < 8 + tail:
raise ValueError("canonical stream is shorter than the header plus signature tail")
canonical_len = len(raw) - 8 - tail
signature = tuple(
int.from_bytes(raw[8 + canonical_len + i * 8 : 8 + canonical_len + (i + 1) * 8], "little")
for i in range(SIGNATURE_WORDS)
)
return Fingerprint(
word_count=int.from_bytes(raw[0:4], "big"),
shingle_count=int.from_bytes(raw[4:8], "big"),
canonical=raw[8 : 8 + canonical_len],
signature=signature,
raw=raw,
)
def _words_blob(words) -> bytes:
return b"".join((int(w) & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little") for w in words)
def jaccard_bits(a, b) -> int:
a_blob = _words_blob(a)
b_blob = _words_blob(b)
out = ctypes.c_uint64()
status = _load().pith_text_jaccard(
a_blob, len(a_blob) // 8, b_blob, len(b_blob) // 8, ctypes.byref(out)
)
if status != STATUS_OK:
raise FfiError("pith_text_jaccard", status)
return out.value
def jaccard(a, b) -> float:
return _struct.unpack("=d", _struct.pack("=Q", jaccard_bits(a, b)))[0]