bytesense 1.0.0__py3-none-any.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.
bytesense/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """
2
+ bytesense — Fast, accurate charset/encoding detection.
3
+
4
+ Full-input validation. Zero runtime dependencies. Optional Rust acceleration.
5
+ Author: Oğuzhan Kır
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from .api import from_bytes, from_fp, from_path, is_binary
13
+ from .hints import best_hint, hint_from_content, hint_from_http_headers
14
+ from .legacy import detect
15
+ from .models import DetectionResult, EncodingAlternative
16
+ from .multi import DocumentSegment, MultiEncodingResult, detect_multi
17
+ from .repair import RepairResult, is_mojibake, repair, repair_bytes
18
+ from .streaming import StreamDetector, detect_stream
19
+ from .version import VERSION, __version__
20
+
21
+ __all__ = [
22
+ "from_bytes",
23
+ "from_fp",
24
+ "from_path",
25
+ "is_binary",
26
+ "detect",
27
+ "DetectionResult",
28
+ "EncodingAlternative",
29
+ "StreamDetector",
30
+ "detect_stream",
31
+ "repair",
32
+ "repair_bytes",
33
+ "is_mojibake",
34
+ "RepairResult",
35
+ "hint_from_http_headers",
36
+ "hint_from_content",
37
+ "best_hint",
38
+ "detect_multi",
39
+ "MultiEncodingResult",
40
+ "DocumentSegment",
41
+ "__version__",
42
+ "VERSION",
43
+ ]
44
+
45
+ __author__ = "Oğuzhan Kır"
46
+
47
+ logging.getLogger("bytesense").addHandler(logging.NullHandler())
bytesense/_rust.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Optional Rust extension loader.
3
+
4
+ Exports accelerated implementations when the compiled extension is available.
5
+ Falls back to pure-Python equivalents in higher layers (see fingerprint.py).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import array
11
+ import os
12
+
13
+ _RUST_AVAILABLE: bool = False
14
+
15
+ try:
16
+ if os.environ.get("BYTESENSE_PURE_PYTHON") == "1":
17
+ raise ImportError("Pure Python explicitly requested")
18
+ from bytesense._rust_core import ( # type: ignore[import-untyped]
19
+ byte_histogram as _core_byte_histogram,
20
+ )
21
+ from bytesense._rust_core import (
22
+ utf8_check as _core_utf8_check,
23
+ )
24
+ from bytesense._rust_core import (
25
+ utf8_continuation_score as _core_utf8_continuation_score,
26
+ )
27
+
28
+ _RUST_AVAILABLE = True
29
+ except ImportError:
30
+ pass
31
+
32
+
33
+ def is_rust_available() -> bool:
34
+ """Return ``True`` if the compiled Rust extension is loaded."""
35
+ return _RUST_AVAILABLE
36
+
37
+
38
+ if _RUST_AVAILABLE:
39
+
40
+ def rust_byte_histogram(data: bytes) -> array.array:
41
+ return array.array("Q", _core_byte_histogram(data)) # type: ignore[misc]
42
+
43
+ def rust_utf8_continuation_score(data: bytes) -> float:
44
+ return _core_utf8_continuation_score(data) # type: ignore[misc]
45
+
46
+ def rust_utf8_check(data: bytes) -> tuple[bool, float]:
47
+ return _core_utf8_check(data) # type: ignore[misc]
48
+
49
+ else:
50
+
51
+ def rust_byte_histogram(data: bytes) -> array.array: # type: ignore[misc]
52
+ raise RuntimeError("Rust extension not available")
53
+
54
+ def rust_utf8_continuation_score(data: bytes) -> float: # type: ignore[misc]
55
+ raise RuntimeError("Rust extension not available")
56
+
57
+ def rust_utf8_check(data: bytes) -> tuple[bool, float]: # type: ignore[misc]
58
+ raise RuntimeError("Rust extension not available")
bytesense/api.py ADDED
@@ -0,0 +1,469 @@
1
+ """Encoding detection with bounded statistical scoring and complete decode validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import re
7
+ from dataclasses import replace
8
+ from os import PathLike
9
+ from typing import Any, BinaryIO, List, Optional
10
+
11
+ from .candidate import CandidateSelector
12
+ from .coherence import detect_language
13
+ from .constant import ALL_ENCODINGS
14
+ from .fingerprint import detect_null_pattern
15
+ from .hints import hint_from_content
16
+ from .models import DetectionResult, EncodingAlternative
17
+ from .scoring import text_quality
18
+
19
+
20
+ def _looks_like_iso2022(data: bytes) -> bool:
21
+ if b"\x1b" not in data:
22
+ return False
23
+ return any(marker in data for marker in (b"\x1b$", b"\x1b(", b"\x1b)", b"\x1b."))
24
+
25
+
26
+ def _make_result(
27
+ encoding: Optional[str],
28
+ chaos: float,
29
+ coherence: float,
30
+ language: str,
31
+ bom_detected: bool,
32
+ alternatives: List[EncodingAlternative],
33
+ why: str,
34
+ byte_count: int,
35
+ confidence: float = 0.0,
36
+ ) -> DetectionResult:
37
+ return DetectionResult(
38
+ encoding=encoding,
39
+ confidence=confidence,
40
+ confidence_interval=None,
41
+ language=language,
42
+ alternatives=alternatives,
43
+ bom_detected=bom_detected,
44
+ chaos=round(chaos, 4),
45
+ coherence=round(coherence, 4),
46
+ why=why,
47
+ byte_count=byte_count,
48
+ )
49
+
50
+
51
+ # Stable tie preferences. Statistical scoring still considers every supported codec.
52
+ _COMMON = [
53
+ "cp1252",
54
+ "latin_1",
55
+ "cp1254",
56
+ "cp1250",
57
+ "cp1251",
58
+ "cp1253",
59
+ "cp1255",
60
+ "cp1256",
61
+ "cp1257",
62
+ "cp1258",
63
+ "shift_jis",
64
+ "cp932",
65
+ "euc_jp",
66
+ "euc_kr",
67
+ "cp949",
68
+ "big5",
69
+ "gb2312",
70
+ "gbk",
71
+ "gb18030",
72
+ "cp850",
73
+ "cp437",
74
+ "cp866",
75
+ ]
76
+ _CANDIDATES = list(dict.fromkeys(_COMMON + ALL_ENCODINGS + ["cp720", "cp874", "cp875"]))
77
+
78
+
79
+ def _from_sample(
80
+ data: bytes,
81
+ threshold: float = 0.2,
82
+ cp_isolation: Optional[List[str]] = None,
83
+ cp_exclusion: Optional[List[str]] = None,
84
+ language_threshold: float = 0.1,
85
+ enable_fallback: bool = False,
86
+ ) -> DetectionResult:
87
+ """Score a bounded prefix. The caller must validate every final candidate."""
88
+ candidates = _CANDIDATES if cp_isolation is None else cp_isolation
89
+ rows: list[tuple[str, float, float, str]] = []
90
+ # Local only: codecs that produce identical Unicode share statistical work.
91
+ evidence: dict[str, tuple[float, float]] = {}
92
+ for name in candidates:
93
+ encoding = _codec(name)
94
+ if cp_exclusion and encoding in cp_exclusion:
95
+ continue
96
+ try:
97
+ decoded = codecs.getincrementaldecoder(encoding)(errors="strict").decode(
98
+ data, final=False
99
+ )
100
+ except UnicodeError:
101
+ continue
102
+ if decoded not in evidence:
103
+ evidence[decoded] = text_quality(decoded)
104
+ support, bad = evidence[decoded]
105
+ if bad > threshold or support < 0.25:
106
+ continue
107
+ rows.append((encoding, support, bad, decoded))
108
+ if not rows:
109
+ return _make_result(None, 1.0, 0.0, "", False, [], "Insufficient text evidence.", len(data))
110
+ rows.sort(key=lambda item: item[1], reverse=True)
111
+ encoding, support, bad, decoded = rows[0]
112
+ # Same-text aliases are not independent linguistic evidence. Use the margin
113
+ # to the next different decoding; score remains explicitly uncalibrated.
114
+ runner = next((score for _, score, _, text in rows[1:] if text != decoded), support)
115
+ margin = max(0.0, support - runner)
116
+ confidence = round(min(0.95, max(0.1, 0.5 * support + min(0.45, margin * 3))), 4)
117
+ alts = [
118
+ EncodingAlternative(enc, round(min(0.95, max(0.0, score * 0.5)), 4), "")
119
+ for enc, score, _, _ in rows[1:6]
120
+ ]
121
+ return _make_result(
122
+ encoding,
123
+ bad,
124
+ min(1.0, max(0.0, support)),
125
+ "",
126
+ False,
127
+ alts,
128
+ f"Selected {encoding}; character-pair support {support:.3f}, margin {margin:.3f}.",
129
+ len(data),
130
+ confidence=confidence,
131
+ )
132
+
133
+
134
+ _CONTROL_BYTES = re.compile(rb"[\x00-\x08\x0b\x0e-\x1a\x1c-\x1f\x7f]")
135
+ _BINARY_MAGIC = (
136
+ b"\x89PNG\r\n\x1a\n",
137
+ b"\xff\xd8\xff",
138
+ b"GIF87a",
139
+ b"GIF89a",
140
+ b"PK\x03\x04",
141
+ b"\x1f\x8b",
142
+ b"%PDF-",
143
+ b"\x7fELF",
144
+ b"Rar!\x1a\x07",
145
+ )
146
+
147
+
148
+ def _codec(name: str) -> str:
149
+ if not isinstance(name, str):
150
+ raise TypeError("encoding names must be strings")
151
+ info = codecs.lookup(name)
152
+ # Reject non-text transforms such as base64, zlib and rot13.
153
+ if not getattr(info, "_is_text_encoding", True):
154
+ raise LookupError(f"{name!r} is not a bytes-to-text codec")
155
+ if info.incrementaldecoder is None:
156
+ raise LookupError(f"{name!r} requires an incremental text decoder")
157
+ if not isinstance(info.incrementaldecoder(errors="strict").decode(b"", final=False), str):
158
+ raise LookupError(f"{name!r} is not a bytes-to-text codec")
159
+ norm = info.name.replace("-", "_")
160
+ return "latin_1" if norm == "iso8859_1" else norm
161
+
162
+
163
+ def _filters(
164
+ isolation: Optional[List[str]], exclusion: Optional[List[str]]
165
+ ) -> tuple[Optional[List[str]], List[str]]:
166
+ include = (
167
+ list(dict.fromkeys(_codec(name) for name in isolation)) if isolation is not None else None
168
+ )
169
+ exclude = (
170
+ list(dict.fromkeys(_codec(name) for name in exclusion)) if exclusion is not None else []
171
+ )
172
+ if include is not None:
173
+ include = [name for name in include if name not in exclude]
174
+ return include, exclude
175
+
176
+
177
+ def _allowed(name: str, include: Optional[List[str]], exclude: List[str]) -> bool:
178
+ return name not in exclude and (include is None or name in include)
179
+
180
+
181
+ def _binary(data: bytes) -> bool:
182
+ if data.startswith(_BINARY_MAGIC):
183
+ return True
184
+ sample = data[:4096]
185
+ if not sample or detect_null_pattern(sample):
186
+ return False
187
+ return len(_CONTROL_BYTES.findall(sample)) / len(sample) > 0.05
188
+
189
+
190
+ def _valid(data: bytes, encoding: str) -> bool:
191
+ try:
192
+ if len(data) <= 1_048_576:
193
+ data.decode(encoding, errors="strict")
194
+ else:
195
+ decoder = codecs.getincrementaldecoder(encoding)(errors="strict")
196
+ view = memoryview(data)
197
+ for offset in range(0, len(data), 65536):
198
+ decoder.decode(view[offset : offset + 65536], final=False)
199
+ decoder.decode(b"", final=True)
200
+ return True
201
+ except (UnicodeError, LookupError):
202
+ return False
203
+
204
+
205
+ def _result(
206
+ encoding: Optional[str],
207
+ size: int,
208
+ why: str,
209
+ confidence: float = 0.0,
210
+ status: str = "matched",
211
+ examined: Optional[int] = None,
212
+ ) -> DetectionResult:
213
+ r = _make_result(
214
+ encoding, 0.0 if encoding else 1.0, 0.0, "", False, [], why, size, confidence=confidence
215
+ )
216
+ return replace(
217
+ r,
218
+ bytes_examined=size if examined is None else examined,
219
+ bytes_validated=size if encoding else 0,
220
+ complete=True,
221
+ status=status if encoding else status if status != "matched" else "unknown",
222
+ )
223
+
224
+
225
+ _HIGH_BYTE = re.compile(b"[\x80-\xff\x1b]")
226
+
227
+
228
+ def _informative_sample(data: bytes | bytearray, size: int) -> bytes:
229
+ """Avoid spending the entire linguistic budget on an ASCII file header."""
230
+ first = _HIGH_BYTE.search(data)
231
+ start = max(0, first.start() - 64) if first and first.start() > size // 4 else 0
232
+ return bytes(data[start : start + size])
233
+
234
+
235
+ def _bom_codec(data: bytes, include: Optional[List[str]], exclude: List[str]) -> Optional[str]:
236
+ raw = CandidateSelector(data).bom_encoding()
237
+ if raw is None:
238
+ return None
239
+ canonical = (
240
+ "utf_32" if raw.startswith("utf_32") else "utf_16" if raw.startswith("utf_16") else raw
241
+ )
242
+ if _allowed(canonical, include, exclude):
243
+ return canonical
244
+ return raw if _allowed(raw, include, exclude) else None
245
+
246
+
247
+ def _transport_encoding(data: bytes) -> Optional[str]:
248
+ # Distinctive shift syntax is checked before the ASCII/UTF-8 fast paths.
249
+ if b"~" in data and b"~{" in data and b"~}" in data:
250
+ try:
251
+ decoded = data.decode("hz")
252
+ if len(re.findall(r"[\u4e00-\u9fff]", decoded)) / max(1, len(decoded)) > 0.1:
253
+ return "hz"
254
+ except UnicodeError:
255
+ pass
256
+ if b"+" in data and re.search(rb"\+[A-Za-z0-9/]{3,}(?:-|[^A-Za-z0-9/]|$)", data):
257
+ try:
258
+ decoded = data.decode("utf_7")
259
+ if not decoded.isascii() and all(c.isprintable() or c in "\n\r\t" for c in decoded):
260
+ return "utf_7"
261
+ except UnicodeError:
262
+ pass
263
+ return None
264
+
265
+
266
+ def from_bytes(
267
+ data: bytes | bytearray,
268
+ steps: int = 5,
269
+ chunk_size: int = 512,
270
+ threshold: float = 0.2,
271
+ cp_isolation: Optional[List[str]] = None,
272
+ cp_exclusion: Optional[List[str]] = None,
273
+ language_threshold: float = 0.1,
274
+ enable_fallback: bool = True,
275
+ *,
276
+ sample_size: int = 4096,
277
+ include_language: bool = False,
278
+ min_confidence: float = 0.0,
279
+ encoding_hint: Optional[str] = None,
280
+ use_hints: bool = True,
281
+ ) -> DetectionResult:
282
+ """Detect bytes and strictly validate the selected codec over the entire input.
283
+
284
+ Linguistic scoring is bounded by ``sample_size``; full validation is O(n).
285
+ ``confidence`` is an evidence score, not a calibrated probability.
286
+ Filters apply to all paths. An empty isolation list allows no encodings.
287
+ ``enable_fallback`` permits a validated, explicitly low-confidence last
288
+ candidate; it never returns an encoding that cannot decode the input.
289
+ Language reporting is opt-in. No document text is retained globally.
290
+ """
291
+ if not isinstance(data, (bytes, bytearray)):
292
+ raise TypeError(f"Expected bytes or bytearray, got {type(data).__name__}")
293
+ if isinstance(data, bytearray):
294
+ data = bytes(data)
295
+ for name, value in [("sample_size", sample_size), ("steps", steps), ("chunk_size", chunk_size)]:
296
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
297
+ raise ValueError(f"{name} must be a positive integer")
298
+ if sample_size < 64:
299
+ raise ValueError("sample_size must be at least 64 bytes")
300
+ for name, score_value in [
301
+ ("threshold", threshold),
302
+ ("language_threshold", language_threshold),
303
+ ("min_confidence", min_confidence),
304
+ ]:
305
+ if not 0.0 <= score_value <= 1.0:
306
+ raise ValueError(f"{name} must be between 0 and 1")
307
+ include, exclude = _filters(cp_isolation, cp_exclusion)
308
+ hint = _codec(encoding_hint) if encoding_hint is not None else None
309
+ size = len(data)
310
+ if include == []:
311
+ return _result(None, size, "No encodings allowed by the filters.")
312
+ if not data:
313
+ enc = (
314
+ ("utf_8" if _allowed("utf_8", include, exclude) else (include[0] if include else None))
315
+ if min_confidence == 0
316
+ else None
317
+ )
318
+ return _result(enc, 0, "Empty input; no encoding evidence.", 0.0, "ambiguous")
319
+ bom = _bom_codec(data, include, exclude)
320
+ if bom:
321
+ if not _valid(data, bom):
322
+ return _result(
323
+ None, size, "BOM declares an encoding but the input is invalid.", status="invalid"
324
+ )
325
+ r = _result(bom, size, f"BOM declares {bom}; all bytes validated.", 1.0)
326
+ return replace(r, bom_detected=True)
327
+ transport = _transport_encoding(data) if data.isascii() else None
328
+ if transport and _allowed(transport, include, exclude):
329
+ if min_confidence <= 0.85:
330
+ return _result(
331
+ transport, size, "Recognized and validated 7-bit shift syntax.", 0.85, "ambiguous"
332
+ )
333
+ return _result(None, size, "Below minimum confidence.")
334
+ shape = detect_null_pattern(data)
335
+ if shape and _allowed(shape, include, exclude) and _valid(data, shape):
336
+ return (
337
+ _result(shape, size, "Unicode byte-lane pattern; complete input validated.", 0.9)
338
+ if min_confidence <= 0.9
339
+ else _result(None, size, "Below minimum confidence.")
340
+ )
341
+ if _binary(data):
342
+ return _result(
343
+ None,
344
+ size,
345
+ "Binary signature or excessive control bytes.",
346
+ status="binary",
347
+ examined=min(size, 4096),
348
+ )
349
+ shape = detect_null_pattern(data)
350
+ if not shape and not _looks_like_iso2022(data):
351
+ if data.isascii() and _allowed("ascii", include, exclude):
352
+ return _result("ascii", size, "All bytes are valid ASCII text.", 1.0)
353
+ if _allowed("utf_8", include, exclude) and _valid(data, "utf_8"):
354
+ r = _result("utf_8", size, "All bytes validated as UTF-8.", 0.99)
355
+ if include_language:
356
+ language_sample = codecs.getincrementaldecoder("utf_8")().decode(
357
+ data[:sample_size], final=False
358
+ )
359
+ langs = detect_language(language_sample, threshold=language_threshold)
360
+ if langs:
361
+ r = replace(r, language=langs[0][0], coherence=round(langs[0][1], 4))
362
+ return (
363
+ r
364
+ if r.confidence >= min_confidence
365
+ else _result(None, size, "Below minimum confidence.")
366
+ )
367
+ if hint is None and use_hints:
368
+ declared = hint_from_content(data)
369
+ if declared:
370
+ try:
371
+ hint = _codec(declared)
372
+ except (LookupError, TypeError):
373
+ pass
374
+ if hint and _allowed(hint, include, exclude) and _valid(data, hint):
375
+ r = _result(hint, size, f"Encoding hint {hint}; all bytes validated.", 0.95)
376
+ return (
377
+ r
378
+ if r.confidence >= min_confidence
379
+ else _result(None, size, "Below minimum confidence.")
380
+ )
381
+ sample = _informative_sample(data, sample_size)
382
+ r = _from_sample(
383
+ sample,
384
+ threshold=threshold,
385
+ cp_isolation=include,
386
+ cp_exclusion=exclude,
387
+ language_threshold=language_threshold,
388
+ enable_fallback=False,
389
+ )
390
+ ranked = ([r.encoding] if r.encoding else []) + [a.encoding for a in r.alternatives]
391
+ for encoding in ranked:
392
+ if encoding and _allowed(encoding, include, exclude) and _valid(data, encoding):
393
+ confidence = r.confidence if encoding == r.encoding else min(0.5, r.confidence)
394
+ if confidence < min_confidence:
395
+ break
396
+ if include_language and encoding == r.encoding:
397
+ decoded = codecs.getincrementaldecoder(encoding)().decode(sample, final=False)
398
+ langs = detect_language(decoded, threshold=language_threshold)
399
+ if langs:
400
+ r = replace(r, language=langs[0][0])
401
+ return replace(
402
+ r,
403
+ encoding=encoding,
404
+ confidence=confidence,
405
+ byte_count=size,
406
+ bytes_examined=len(sample),
407
+ bytes_validated=size,
408
+ complete=True,
409
+ status="ambiguous" if r.alternatives else "matched",
410
+ chaos=r.chaos if encoding == r.encoding else 0.0,
411
+ coherence=r.coherence if encoding == r.encoding else 0.0,
412
+ alternatives=[a for a in r.alternatives if a.encoding != encoding],
413
+ language=r.language if include_language and encoding == r.encoding else "",
414
+ why=r.why + " Full input validated."
415
+ if encoding == r.encoding
416
+ else f"Full validation rejected the sampled winner; selected {encoding} as a validated alternative.",
417
+ )
418
+ # An explicit candidate can be absent from the statistical shortlist. It
419
+ # still must pass full validation before being offered as a fallback.
420
+ if enable_fallback and min_confidence <= 0.1:
421
+ for encoding in include or []:
422
+ if _valid(data, encoding):
423
+ return _result(
424
+ encoding,
425
+ size,
426
+ "Validated fallback; insufficient linguistic evidence.",
427
+ 0.1,
428
+ "ambiguous",
429
+ len(sample),
430
+ )
431
+ return _result(
432
+ None,
433
+ size,
434
+ "No permitted candidate validates the complete input.",
435
+ status="unknown",
436
+ examined=len(sample),
437
+ )
438
+
439
+
440
+ def from_path(path: str | bytes | PathLike[str], **kwargs: Any) -> DetectionResult:
441
+ """Read a file with bounded memory and validate every byte."""
442
+ with open(path, "rb") as fp:
443
+ return from_fp(fp, **kwargs)
444
+
445
+
446
+ def from_fp(fp: BinaryIO, **kwargs: Any) -> DetectionResult:
447
+ """Consume a binary stream without an unbounded read. Leaves it open."""
448
+ from .streaming import StreamDetector
449
+
450
+ detector = StreamDetector(**kwargs)
451
+ try:
452
+ while True:
453
+ chunk = fp.read(65536)
454
+ if not chunk:
455
+ break
456
+ detector.feed(chunk)
457
+ return detector.finalize()
458
+ finally:
459
+ detector.close()
460
+
461
+
462
+ def is_binary(data: bytes | str | PathLike[str], **kwargs: Any) -> bool:
463
+ """Return True only for positive binary evidence, not an unknown encoding."""
464
+ kwargs["enable_fallback"] = False
465
+ if isinstance(data, (str, PathLike)):
466
+ return from_path(data, **kwargs).status == "binary"
467
+ if isinstance(data, (bytes, bytearray)):
468
+ return from_bytes(data, **kwargs).status == "binary"
469
+ return from_fp(data, **kwargs).status == "binary"