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/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
+ )