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 +47 -0
- bytesense/_rust.py +58 -0
- bytesense/api.py +469 -0
- bytesense/candidate.py +199 -0
- bytesense/cli.py +73 -0
- bytesense/coherence.py +68 -0
- bytesense/constant.py +1313 -0
- bytesense/data/__init__.py +0 -0
- bytesense/data/fingerprints.py +94 -0
- bytesense/data/language.json.gz +0 -0
- bytesense/fingerprint.py +246 -0
- bytesense/heuristics.py +161 -0
- bytesense/hints.py +104 -0
- bytesense/legacy.py +38 -0
- bytesense/mess.py +179 -0
- bytesense/models.py +98 -0
- bytesense/multi.py +209 -0
- bytesense/py.typed +0 -0
- bytesense/repair.py +280 -0
- bytesense/scoring.py +100 -0
- bytesense/streaming.py +416 -0
- bytesense/version.py +4 -0
- bytesense-1.0.0.dist-info/METADATA +160 -0
- bytesense-1.0.0.dist-info/RECORD +28 -0
- bytesense-1.0.0.dist-info/WHEEL +5 -0
- bytesense-1.0.0.dist-info/entry_points.txt +2 -0
- bytesense-1.0.0.dist-info/licenses/LICENSE +21 -0
- bytesense-1.0.0.dist-info/top_level.txt +1 -0
bytesense/candidate.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Candidate encoding selector.
|
|
3
|
+
|
|
4
|
+
Reduces ~99 possible encodings to a short list before decode+mess,
|
|
5
|
+
using only byte-level evidence.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import array
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
from .constant import ALL_ENCODINGS, BOM_MARKERS, SIMILAR_ENCODINGS
|
|
14
|
+
from .fingerprint import (
|
|
15
|
+
byte_histogram,
|
|
16
|
+
cp1252_zone_ratio,
|
|
17
|
+
detect_null_pattern,
|
|
18
|
+
high_byte_ratio,
|
|
19
|
+
null_byte_ratio,
|
|
20
|
+
shortlist_encodings,
|
|
21
|
+
utf8_continuation_score,
|
|
22
|
+
)
|
|
23
|
+
from .heuristics import reorder_candidates
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _looks_like_iso2022_bytes(data: bytes) -> bool:
|
|
27
|
+
"""7-bit ISO-2022 uses ESC $ / ESC ( sequences — not plain ASCII text."""
|
|
28
|
+
if b"\x1b" not in data:
|
|
29
|
+
return False
|
|
30
|
+
return b"\x1b\x24" in data or b"\x1b\x28" in data or b"\x1b\x29" in data or b"\x1b\x2e" in data
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CandidateSelector:
|
|
34
|
+
"""
|
|
35
|
+
Select the most likely encoding candidates for a byte sequence
|
|
36
|
+
without performing any full decode.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, data: bytes) -> None:
|
|
40
|
+
self.data = data
|
|
41
|
+
self.length = len(data)
|
|
42
|
+
self._hist: Optional[array.array] = None
|
|
43
|
+
self._ascii_only: Optional[bool] = None
|
|
44
|
+
self._utf8_valid: Optional[bool] = None
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def hist(self) -> array.array:
|
|
48
|
+
if self._hist is None:
|
|
49
|
+
self._hist = byte_histogram(self.data)
|
|
50
|
+
return self._hist
|
|
51
|
+
|
|
52
|
+
def bom_encoding(self) -> Optional[str]:
|
|
53
|
+
"""Return the encoding if a BOM/SIG prefix is detected, else None."""
|
|
54
|
+
for encoding, bom in sorted(BOM_MARKERS.items(), key=lambda x: -len(x[1])):
|
|
55
|
+
if self.data.startswith(bom):
|
|
56
|
+
return encoding
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
def is_ascii_only(self) -> bool:
|
|
60
|
+
if self._ascii_only is None:
|
|
61
|
+
n = self.length
|
|
62
|
+
if n == 0:
|
|
63
|
+
self._ascii_only = True
|
|
64
|
+
else:
|
|
65
|
+
self._ascii_only = self.data.isascii() and b"\x00" not in self.data
|
|
66
|
+
return self._ascii_only
|
|
67
|
+
|
|
68
|
+
def is_utf8_valid(self) -> bool:
|
|
69
|
+
if self._utf8_valid is None:
|
|
70
|
+
try:
|
|
71
|
+
self.data.decode("utf_8")
|
|
72
|
+
self._utf8_valid = True
|
|
73
|
+
except UnicodeDecodeError:
|
|
74
|
+
self._utf8_valid = False
|
|
75
|
+
return self._utf8_valid
|
|
76
|
+
|
|
77
|
+
def get_candidates(self) -> List[str]:
|
|
78
|
+
"""
|
|
79
|
+
Return ordered list of encoding candidates, most likely first.
|
|
80
|
+
|
|
81
|
+
Decision tree:
|
|
82
|
+
1. BOM → single winner
|
|
83
|
+
2. ASCII-only → [ascii, utf_8]
|
|
84
|
+
3. Valid UTF-8 → [utf_8, …]
|
|
85
|
+
4. Null byte pattern → UTF-16/32 variants
|
|
86
|
+
5. Byte fingerprint cosine similarity → top 12
|
|
87
|
+
"""
|
|
88
|
+
bom = self.bom_encoding()
|
|
89
|
+
if bom:
|
|
90
|
+
return [bom]
|
|
91
|
+
|
|
92
|
+
if self.is_ascii_only() and not _looks_like_iso2022_bytes(self.data):
|
|
93
|
+
return ["ascii", "utf_8"]
|
|
94
|
+
|
|
95
|
+
# UTF-16/32 shape must come before valid-UTF-8: ASCII in UTF-16 is often valid UTF-8 (NUL bytes).
|
|
96
|
+
null_pat = detect_null_pattern(self.data)
|
|
97
|
+
if null_pat:
|
|
98
|
+
if "32" in null_pat:
|
|
99
|
+
return [null_pat, "utf_32", "utf_32_be", "utf_32_le"]
|
|
100
|
+
return [null_pat, "utf_16", "utf_16_be", "utf_16_le"]
|
|
101
|
+
|
|
102
|
+
if self.is_utf8_valid() and not _looks_like_iso2022_bytes(self.data):
|
|
103
|
+
candidates = ["utf_8"]
|
|
104
|
+
# Check for CJK 3-byte sequences (0xE2-0xE4 range is CJK in UTF-8)
|
|
105
|
+
cjk_signal = sum(self.hist[i] for i in range(0xE2, 0xE5))
|
|
106
|
+
if cjk_signal > self.length * 0.05:
|
|
107
|
+
candidates += ["gb18030", "big5", "shift_jis"]
|
|
108
|
+
return list(dict.fromkeys(candidates))
|
|
109
|
+
|
|
110
|
+
hbr = high_byte_ratio(self.hist, self.length)
|
|
111
|
+
nbr = null_byte_ratio(self.hist, self.length)
|
|
112
|
+
c1252 = cp1252_zone_ratio(self.hist, self.length)
|
|
113
|
+
u8s = utf8_continuation_score(self.data[:4096])
|
|
114
|
+
|
|
115
|
+
# Heavy null presence → UTF-16/32 only
|
|
116
|
+
if nbr > 0.15:
|
|
117
|
+
return ["utf_16", "utf_16_le", "utf_16_be", "utf_32", "utf_32_le", "utf_32_be"]
|
|
118
|
+
|
|
119
|
+
# Strong UTF-8 continuation but invalid → likely truncated or damaged UTF-8
|
|
120
|
+
exclude: set[str] = set()
|
|
121
|
+
if u8s > 0.8:
|
|
122
|
+
exclude.update(["shift_jis", "euc_kr", "johab", "cp949"])
|
|
123
|
+
|
|
124
|
+
# Fingerprint-based shortlist
|
|
125
|
+
scored = shortlist_encodings(self.hist, self.length, top_n=20)
|
|
126
|
+
candidates = [enc for enc, _ in scored if enc not in exclude]
|
|
127
|
+
|
|
128
|
+
if _looks_like_iso2022_bytes(self.data):
|
|
129
|
+
for enc in ("iso2022_jp", "iso2022_jp_2004", "euc_jp", "shift_jis", "cp932"):
|
|
130
|
+
if enc in exclude:
|
|
131
|
+
continue
|
|
132
|
+
if enc not in candidates:
|
|
133
|
+
candidates.insert(0, enc)
|
|
134
|
+
|
|
135
|
+
# SBCS / common legacy encodings must be reachable even when fingerprints rank MBCS first.
|
|
136
|
+
# Include big5/cp949/euc_kr — short cosine list often omits them for small files.
|
|
137
|
+
if hbr > 0.02:
|
|
138
|
+
preferred: List[str] = []
|
|
139
|
+
for enc in (
|
|
140
|
+
"latin_1",
|
|
141
|
+
"cp1252",
|
|
142
|
+
"cp1250",
|
|
143
|
+
"cp1254",
|
|
144
|
+
"cp1256",
|
|
145
|
+
"cp1255",
|
|
146
|
+
"cp1257",
|
|
147
|
+
"cp1258",
|
|
148
|
+
"iso8859_8",
|
|
149
|
+
"cp1251",
|
|
150
|
+
"koi8_r",
|
|
151
|
+
"koi8_u",
|
|
152
|
+
"cp866",
|
|
153
|
+
"cp1253",
|
|
154
|
+
"mac_cyrillic",
|
|
155
|
+
"tis_620",
|
|
156
|
+
"iso8859_11",
|
|
157
|
+
"big5",
|
|
158
|
+
"big5hkscs",
|
|
159
|
+
"gb2312",
|
|
160
|
+
"gbk",
|
|
161
|
+
"gb18030",
|
|
162
|
+
"shift_jis",
|
|
163
|
+
"euc_jp",
|
|
164
|
+
"iso2022_jp",
|
|
165
|
+
"cp949",
|
|
166
|
+
"euc_kr",
|
|
167
|
+
"johab",
|
|
168
|
+
"iso8859_7",
|
|
169
|
+
):
|
|
170
|
+
if enc in exclude:
|
|
171
|
+
continue
|
|
172
|
+
if enc not in preferred:
|
|
173
|
+
preferred.append(enc)
|
|
174
|
+
candidates = preferred + [c for c in candidates if c not in preferred]
|
|
175
|
+
|
|
176
|
+
# Ensure cp1252 is always tried for high-byte European content
|
|
177
|
+
if hbr > 0.05 and c1252 >= 0.001 and "cp1252" not in candidates:
|
|
178
|
+
candidates.insert(0, "cp1252")
|
|
179
|
+
|
|
180
|
+
# Deduplicate while preserving order
|
|
181
|
+
seen: set[str] = set()
|
|
182
|
+
final: List[str] = []
|
|
183
|
+
for enc in candidates:
|
|
184
|
+
if enc not in seen:
|
|
185
|
+
seen.add(enc)
|
|
186
|
+
final.append(enc)
|
|
187
|
+
|
|
188
|
+
final = reorder_candidates(self.data, final)
|
|
189
|
+
|
|
190
|
+
return final[:40] if final else ALL_ENCODINGS[:40]
|
|
191
|
+
|
|
192
|
+
def exclude_similar_to_failed(
|
|
193
|
+
self,
|
|
194
|
+
failed: str,
|
|
195
|
+
remaining: List[str],
|
|
196
|
+
) -> List[str]:
|
|
197
|
+
"""Remove encodings too similar to `failed` from `remaining`."""
|
|
198
|
+
similar = set(SIMILAR_ENCODINGS.get(failed, []))
|
|
199
|
+
return [enc for enc in remaining if enc not in similar]
|
bytesense/cli.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Command-line detection with machine-readable output and meaningful exit codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from .api import from_fp, from_path
|
|
12
|
+
from .version import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="bytesense", description="Detect encodings and validate the complete input."
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"files", nargs="+", metavar="FILE", help="File(s) to analyse; - reads binary stdin"
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Include the explanation")
|
|
23
|
+
parser.add_argument("-m", "--minimal", action="store_true", help="Print encoding name only")
|
|
24
|
+
parser.add_argument("--language", action="store_true", help="Include a language estimate")
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--sample-size",
|
|
27
|
+
type=int,
|
|
28
|
+
default=4096,
|
|
29
|
+
help="Linguistic sample budget (default: 4096 bytes)",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--min-confidence", type=float, default=0.0, help="Abstain below this evidence score (0–1)"
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--version", action="version", version=f"bytesense {__version__}")
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
if args.sample_size < 64 or not 0 <= args.min_confidence <= 1:
|
|
37
|
+
parser.error("sample-size must be at least 64; min-confidence must be between 0 and 1")
|
|
38
|
+
results = []
|
|
39
|
+
exit_code = 0
|
|
40
|
+
for filepath in args.files:
|
|
41
|
+
options = dict(
|
|
42
|
+
include_language=args.language,
|
|
43
|
+
sample_size=args.sample_size,
|
|
44
|
+
min_confidence=args.min_confidence,
|
|
45
|
+
)
|
|
46
|
+
try:
|
|
47
|
+
result = (
|
|
48
|
+
from_fp(sys.stdin.buffer, **options)
|
|
49
|
+
if filepath == "-"
|
|
50
|
+
else from_path(filepath, **options)
|
|
51
|
+
)
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
54
|
+
exit_code = 1
|
|
55
|
+
continue
|
|
56
|
+
if result.encoding is None:
|
|
57
|
+
exit_code = 1
|
|
58
|
+
if args.minimal:
|
|
59
|
+
print(result.encoding or "unknown")
|
|
60
|
+
continue
|
|
61
|
+
d = result.to_dict()
|
|
62
|
+
d["path"] = "-" if filepath == "-" else str(Path(filepath).resolve())
|
|
63
|
+
if not args.verbose:
|
|
64
|
+
d.pop("why", None)
|
|
65
|
+
results.append(d)
|
|
66
|
+
if not args.minimal:
|
|
67
|
+
output = results[0] if len(results) == 1 else results
|
|
68
|
+
print(json.dumps(output, indent=2, ensure_ascii=False))
|
|
69
|
+
return exit_code
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
raise SystemExit(main())
|
bytesense/coherence.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Language evidence from shared character statistics, without retaining user text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from .constant import CHAR_FREQUENCIES
|
|
9
|
+
|
|
10
|
+
# Only static language metadata is retained globally. Document text is never cached.
|
|
11
|
+
_RANKS = {
|
|
12
|
+
language: {c: i for i, c in enumerate(chars)} for language, chars in CHAR_FREQUENCIES.items()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _score(
|
|
17
|
+
counter: Counter[str],
|
|
18
|
+
language: str,
|
|
19
|
+
ordered: Optional[list[tuple[str, int]]] = None,
|
|
20
|
+
total: Optional[int] = None,
|
|
21
|
+
) -> float:
|
|
22
|
+
ranks = _RANKS.get(language)
|
|
23
|
+
if not ranks or not counter:
|
|
24
|
+
return 0.0
|
|
25
|
+
score = weight_sum = 0.0
|
|
26
|
+
total = sum(counter.values()) if total is None else total
|
|
27
|
+
covered = 0
|
|
28
|
+
for position, (char, count) in enumerate(
|
|
29
|
+
(counter.most_common() if ordered is None else ordered)[: len(ranks) + 5]
|
|
30
|
+
):
|
|
31
|
+
weight = 1.0 / (1 + position)
|
|
32
|
+
weight_sum += weight
|
|
33
|
+
if char in ranks:
|
|
34
|
+
score += weight / (1 + abs(position - ranks[char]) * 0.08)
|
|
35
|
+
covered += count
|
|
36
|
+
# Unknown letters are evidence too. The old denominator ignored them and
|
|
37
|
+
# reported perfect Arabic coherence for mostly Latin mojibake.
|
|
38
|
+
return (score / weight_sum) * (covered / total) ** 0.5 if weight_sum else 0.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _letters(text: str) -> Counter[str]:
|
|
42
|
+
counts = Counter(text.lower())
|
|
43
|
+
return Counter({char: n for char, n in counts.items() if char.isalpha()})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def coherence_score(text: str, language: str) -> float:
|
|
47
|
+
"""Heuristic language support in [0, 1], not a probability."""
|
|
48
|
+
return _score(_letters(text), language)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_language(
|
|
52
|
+
text: str,
|
|
53
|
+
candidates: Optional[list[str]] = None,
|
|
54
|
+
threshold: float = 0.1,
|
|
55
|
+
) -> list[tuple[str, float]]:
|
|
56
|
+
"""Rank languages using one character count for the entire candidate set."""
|
|
57
|
+
counts = _letters(text)
|
|
58
|
+
ordered = counts.most_common()
|
|
59
|
+
total = sum(counts.values())
|
|
60
|
+
result = [
|
|
61
|
+
(language, _score(counts, language, ordered, total))
|
|
62
|
+
for language in (_RANKS if candidates is None else candidates)
|
|
63
|
+
]
|
|
64
|
+
return sorted(
|
|
65
|
+
((language, score) for language, score in result if score >= threshold),
|
|
66
|
+
key=lambda item: item[1],
|
|
67
|
+
reverse=True,
|
|
68
|
+
)
|