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]
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,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_text