pith-text 0.1.1b1__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_text/__init__.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 pith-hash
|
|
3
|
+
"""pith-text SDK: text fingerprints through ctypes.
|
|
4
|
+
|
|
5
|
+
The single Rust core (built by ``cargo build --release``) is loaded at
|
|
6
|
+
runtime; the package carries zero runtime dependencies beyond the
|
|
7
|
+
standard library.
|
|
8
|
+
|
|
9
|
+
Discovery order (the suite's cdylib convention):
|
|
10
|
+
|
|
11
|
+
1. ``PITH_CDYLIB`` — an explicit cdylib file path;
|
|
12
|
+
2. ``PITH_CDYLIB_DIR`` — a directory scanned for the cdylib names
|
|
13
|
+
(the CD pipeline points this at ``target/release``);
|
|
14
|
+
3. the package directory — the packaged wheel's own cdylib copy;
|
|
15
|
+
4. ``<repo root>/target/release`` — the repository working-tree
|
|
16
|
+
layout, so a source checkout runs against a local cargo build
|
|
17
|
+
unconfigured.
|
|
18
|
+
|
|
19
|
+
The FFI surface is two operations plus one free:
|
|
20
|
+
|
|
21
|
+
* ``pith_text_fingerprint`` takes the UTF-8 bytes of a string (the
|
|
22
|
+
``len`` argument is a **byte** length) and hands out the canonical
|
|
23
|
+
fingerprint stream the ``reference.json`` vectors are defined over:
|
|
24
|
+
|
|
25
|
+
::
|
|
26
|
+
|
|
27
|
+
[0..4) word_count u32 big-endian
|
|
28
|
+
[4..8) shingle_count u32 big-endian
|
|
29
|
+
[8..8+C) canonical the canonical UTF-8 bytes (C bytes)
|
|
30
|
+
[8+C..) signature 128 u64 little-endian words (1024 bytes)
|
|
31
|
+
|
|
32
|
+
so ``canonical_sha256 == sha256(stream[8..8+C])`` and the
|
|
33
|
+
``signature_fnv1a64`` / ``signature_sha256`` folds of
|
|
34
|
+
``reference.json`` are the standard folds over the 1024-byte
|
|
35
|
+
little-endian tail. The empty input is valid and pins the sentinel
|
|
36
|
+
stream (zero counts, the one-byte canonical form ``"\\n"``, 128
|
|
37
|
+
``u64::MAX`` words) — it is *not* a refusal.
|
|
38
|
+
|
|
39
|
+
* ``pith_text_jaccard`` takes two u64 word slices (the little-endian
|
|
40
|
+
tails of two fingerprint streams) and writes the estimate through a
|
|
41
|
+
``u64`` out-parameter as its ``f64`` bit pattern — no float crosses
|
|
42
|
+
the ABI. An empty slice is the legal sentinel operand
|
|
43
|
+
(``reference.json``'s ``empty-signature`` arm passes ``Vec::new()``,
|
|
44
|
+
*not* ``signature("")``, whose all-``u64::MAX`` words would score
|
|
45
|
+
≈ 0).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import ctypes
|
|
51
|
+
import os
|
|
52
|
+
import struct as _struct
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"Fingerprint",
|
|
58
|
+
"FfiError",
|
|
59
|
+
"LibraryNotFoundError",
|
|
60
|
+
"SIGNATURE_WORDS",
|
|
61
|
+
"STATUS_OK",
|
|
62
|
+
"STATUS_INVALID",
|
|
63
|
+
"STATUS_REJECTED",
|
|
64
|
+
"find_cdylib",
|
|
65
|
+
"fingerprint_stream",
|
|
66
|
+
"parse_fingerprint",
|
|
67
|
+
"jaccard_bits",
|
|
68
|
+
"jaccard",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
#: Status: success.
|
|
72
|
+
STATUS_OK = 0
|
|
73
|
+
#: Status: a caller argument is invalid (a null pointer, or a null
|
|
74
|
+
#: buffer carrying a nonzero length).
|
|
75
|
+
STATUS_INVALID = -1
|
|
76
|
+
#: Status: the core pipeline refused the input (the bytes are not
|
|
77
|
+
#: valid UTF-8).
|
|
78
|
+
STATUS_REJECTED = -2
|
|
79
|
+
|
|
80
|
+
#: Length of a full signature in ``u64`` words (and of the little-)
|
|
81
|
+
#: endian tail of every fingerprint stream).
|
|
82
|
+
SIGNATURE_WORDS = 128
|
|
83
|
+
|
|
84
|
+
#: Every cdylib file name cargo may drop into the build directory, per
|
|
85
|
+
#: platform (windows / linux / macOS).
|
|
86
|
+
CDYLIB_NAMES = ("pith_text.dll", "libpith_text.so", "libpith_text.dylib")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Fingerprint:
|
|
91
|
+
"""The fingerprint of one text, re-expressed from the canonical
|
|
92
|
+
byte stream.
|
|
93
|
+
|
|
94
|
+
``canonical`` is the NFC-lowercased, whitespace-normalised UTF-8
|
|
95
|
+
form with exactly one trailing newline — exactly the bytes the
|
|
96
|
+
``canonical_sha256`` digest covers. ``signature`` is the 128-word
|
|
97
|
+
MinHash signature in little-endian stream order.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
#: Whitespace-separated word count of the canonical form.
|
|
101
|
+
word_count: int
|
|
102
|
+
#: Consecutive ``k``-word window count, ``k = min(3, n)``.
|
|
103
|
+
shingle_count: int
|
|
104
|
+
#: The canonical UTF-8 bytes (everything between the header and
|
|
105
|
+
#: the signature tail).
|
|
106
|
+
canonical: bytes
|
|
107
|
+
#: The 128 signature words, little-endian stream order.
|
|
108
|
+
signature: tuple[int, ...]
|
|
109
|
+
#: The canonical byte stream the digests are computed over.
|
|
110
|
+
raw: bytes
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class LibraryNotFoundError(OSError):
|
|
114
|
+
"""No cdylib was found through the discovery chain."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class FfiError(Exception):
|
|
118
|
+
"""A non-zero status code came back from the cdylib."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, op: str, status: int) -> None:
|
|
121
|
+
kind = {
|
|
122
|
+
STATUS_INVALID: "invalid argument",
|
|
123
|
+
STATUS_REJECTED: "input rejected",
|
|
124
|
+
}.get(status, "unknown failure")
|
|
125
|
+
super().__init__(f"{op} failed: {kind} (status {status})")
|
|
126
|
+
#: The raw status code the FFI returned.
|
|
127
|
+
self.status = status
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def find_cdylib() -> Path:
|
|
131
|
+
"""Locates the cdylib through the suite's discovery chain."""
|
|
132
|
+
explicit = os.environ.get("PITH_CDYLIB")
|
|
133
|
+
if explicit:
|
|
134
|
+
p = Path(explicit)
|
|
135
|
+
if p.is_file():
|
|
136
|
+
return p
|
|
137
|
+
env_dir = os.environ.get("PITH_CDYLIB_DIR")
|
|
138
|
+
candidates: list[Path] = []
|
|
139
|
+
if env_dir:
|
|
140
|
+
env_dir_path = Path(env_dir)
|
|
141
|
+
candidates.append(env_dir_path)
|
|
142
|
+
if not env_dir_path.is_absolute():
|
|
143
|
+
# CD and local runs invoke tools from the repository root or
|
|
144
|
+
# from sdk/<lang>; resolve the env value against both.
|
|
145
|
+
candidates.append(Path.cwd() / env_dir_path)
|
|
146
|
+
candidates.append(Path(__file__).resolve().parents[3] / env_dir_path)
|
|
147
|
+
candidates.append(Path(__file__).resolve().parent) # packaged wheel
|
|
148
|
+
candidates.append(Path(__file__).resolve().parents[3] / "target" / "release")
|
|
149
|
+
for directory in candidates:
|
|
150
|
+
for name in CDYLIB_NAMES:
|
|
151
|
+
p = directory / name
|
|
152
|
+
if p.is_file():
|
|
153
|
+
return p
|
|
154
|
+
raise LibraryNotFoundError(
|
|
155
|
+
"no pith-text cdylib found (searched PITH_CDYLIB, PITH_CDYLIB_DIR, "
|
|
156
|
+
"the package directory and <repo>/target/release); "
|
|
157
|
+
"run `cargo build --release` first"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
_lib: ctypes.CDLL | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load() -> ctypes.CDLL:
|
|
165
|
+
global _lib
|
|
166
|
+
if _lib is None:
|
|
167
|
+
lib = ctypes.CDLL(str(find_cdylib()))
|
|
168
|
+
lib.pith_text_fingerprint.argtypes = [
|
|
169
|
+
ctypes.c_void_p, # data (UTF-8 bytes)
|
|
170
|
+
ctypes.c_size_t, # len (BYTE length)
|
|
171
|
+
ctypes.POINTER(ctypes.c_void_p), # out buffer
|
|
172
|
+
ctypes.POINTER(ctypes.c_size_t), # out length
|
|
173
|
+
]
|
|
174
|
+
lib.pith_text_fingerprint.restype = ctypes.c_int32
|
|
175
|
+
lib.pith_text_jaccard.argtypes = [
|
|
176
|
+
ctypes.c_void_p, # a words
|
|
177
|
+
ctypes.c_size_t, # a word count
|
|
178
|
+
ctypes.c_void_p, # b words
|
|
179
|
+
ctypes.c_size_t, # b word count
|
|
180
|
+
ctypes.POINTER(ctypes.c_uint64), # out f64 bits
|
|
181
|
+
]
|
|
182
|
+
lib.pith_text_jaccard.restype = ctypes.c_int32
|
|
183
|
+
lib.pith_text_free.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
|
|
184
|
+
lib.pith_text_free.restype = None
|
|
185
|
+
_lib = lib
|
|
186
|
+
return _lib
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _as_bytes(data: bytes | str) -> bytes:
|
|
190
|
+
"""The FFI takes UTF-8 bytes; ``str`` is encoded, ``bytes`` passes
|
|
191
|
+
through untouched (so callers can probe the non-UTF-8 refusal)."""
|
|
192
|
+
if isinstance(data, str):
|
|
193
|
+
return data.encode("utf-8")
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def fingerprint_stream(data: bytes | str) -> bytes:
|
|
198
|
+
"""Computes the full fingerprint of a UTF-8 string into the
|
|
199
|
+
canonical byte stream the ``reference.json`` vectors are defined
|
|
200
|
+
over (layout in the module docstring).
|
|
201
|
+
|
|
202
|
+
A ``str`` is encoded UTF-8; ``bytes`` are passed through as-is, so
|
|
203
|
+
a non-UTF-8 probe raises :class:`FfiError` with ``status ==
|
|
204
|
+
STATUS_REJECTED`` — the empty input is valid and pins the
|
|
205
|
+
sentinel stream, it is *not* a refusal. The handed-out cdylib
|
|
206
|
+
buffer is copied and released before returning.
|
|
207
|
+
"""
|
|
208
|
+
blob = _as_bytes(data)
|
|
209
|
+
out = ctypes.c_void_p()
|
|
210
|
+
out_len = ctypes.c_size_t()
|
|
211
|
+
status = _load().pith_text_fingerprint(blob, len(blob), ctypes.byref(out), ctypes.byref(out_len))
|
|
212
|
+
if status != STATUS_OK:
|
|
213
|
+
raise FfiError("pith_text_fingerprint", status)
|
|
214
|
+
try:
|
|
215
|
+
return ctypes.string_at(out, out_len.value)
|
|
216
|
+
finally:
|
|
217
|
+
_load().pith_text_free(out, out_len.value)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_fingerprint(raw: bytes) -> Fingerprint:
|
|
221
|
+
"""Re-expresses the canonical byte stream as a :class:`Fingerprint`."""
|
|
222
|
+
tail = SIGNATURE_WORDS * 8
|
|
223
|
+
if len(raw) < 8 + tail:
|
|
224
|
+
raise ValueError("canonical stream is shorter than the header plus signature tail")
|
|
225
|
+
canonical_len = len(raw) - 8 - tail
|
|
226
|
+
signature = tuple(
|
|
227
|
+
int.from_bytes(raw[8 + canonical_len + i * 8 : 8 + canonical_len + (i + 1) * 8], "little")
|
|
228
|
+
for i in range(SIGNATURE_WORDS)
|
|
229
|
+
)
|
|
230
|
+
return Fingerprint(
|
|
231
|
+
word_count=int.from_bytes(raw[0:4], "big"),
|
|
232
|
+
shingle_count=int.from_bytes(raw[4:8], "big"),
|
|
233
|
+
canonical=raw[8 : 8 + canonical_len],
|
|
234
|
+
signature=signature,
|
|
235
|
+
raw=raw,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _words_blob(words) -> bytes:
|
|
240
|
+
"""Serializes a sequence of ``u64`` words into the little-endian
|
|
241
|
+
byte blob the jaccard FFI reads (alignment is not guaranteed on
|
|
242
|
+
the Rust side, so a plain bytes buffer is the contract)."""
|
|
243
|
+
return b"".join((int(w) & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little") for w in words)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def jaccard_bits(a, b) -> int:
|
|
247
|
+
"""Estimates the Jaccard index of the two shingle sets that
|
|
248
|
+
produced the ``a`` and ``b`` word sequences, returned as the raw
|
|
249
|
+
``f64`` **bit pattern** (a ``u64``) — the exact form the
|
|
250
|
+
``reference.json`` ``value_bits`` fields pin, comparable with
|
|
251
|
+
``int(value_bits, 16)``.
|
|
252
|
+
|
|
253
|
+
Callers comparing signatures pass full 128-word signatures (the
|
|
254
|
+
little-endian tail of :func:`fingerprint_stream`); the empty
|
|
255
|
+
sequence is the legal sentinel operand and scores ``1.0``
|
|
256
|
+
(``0x3ff0000000000000``) against anything.
|
|
257
|
+
"""
|
|
258
|
+
a_blob = _words_blob(a)
|
|
259
|
+
b_blob = _words_blob(b)
|
|
260
|
+
out = ctypes.c_uint64()
|
|
261
|
+
status = _load().pith_text_jaccard(
|
|
262
|
+
a_blob, len(a_blob) // 8, b_blob, len(b_blob) // 8, ctypes.byref(out)
|
|
263
|
+
)
|
|
264
|
+
if status != STATUS_OK:
|
|
265
|
+
raise FfiError("pith_text_jaccard", status)
|
|
266
|
+
return out.value
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def jaccard(a, b) -> float:
|
|
270
|
+
"""The :func:`jaccard_bits` estimate as a Python ``float``: the
|
|
271
|
+
u64 crossing the ABI is an IEEE-754 bit pattern, reinterpreted
|
|
272
|
+
natively — the value is exact."""
|
|
273
|
+
return _struct.unpack("=d", _struct.pack("=Q", jaccard_bits(a, b)))[0]
|
pith_text/pith_text.dll
ADDED
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pith-text
|
|
3
|
+
Version: 0.1.1b1
|
|
4
|
+
Summary: Text fingerprints SDK: NFC canonicalisation, word 3-shingles and a 128-word MinHash signature via the pith-text Rust cdylib (ctypes, zero-dep)
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/pith-hash/pith-text
|
|
7
|
+
Project-URL: Homepage, https://github.com/pith-hash/pith-text
|
|
8
|
+
Keywords: text,minhash,similarity,fingerprint,hashing
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Rust
|
|
11
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
12
|
+
Requires-Python: >=3.9
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pith_text/__init__.py,sha256=E_-5eHqeEUqmORyvzk71sGD0xSM8OBYpF-89alqtRho,10037
|
|
2
|
+
pith_text/pith_text.dll,sha256=X24hfPbOLL72YMbAHQS2p8AbP5IB96blIBvpFf985rw,205824
|
|
3
|
+
pith_text-0.1.1b1.dist-info/METADATA,sha256=kPefJUO62w1MUj1P0NxAm_2cKrn869CzGdhEdFQ1RVY,586
|
|
4
|
+
pith_text-0.1.1b1.dist-info/WHEEL,sha256=Zx98gwb_dQKckJK3HEOVKnxxD52EfU_PXDG_usMJ2ng,98
|
|
5
|
+
pith_text-0.1.1b1.dist-info/top_level.txt,sha256=aaVVXwaIhxihCb0B20TehH9V3D9EWDqHMO6_msbDvr4,10
|
|
6
|
+
pith_text-0.1.1b1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pith_text
|