Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/charset_normalizer/api.py: 6%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

316 statements  

1from __future__ import annotations 

2 

3import logging 

4from functools import lru_cache 

5from os import PathLike 

6from typing import BinaryIO 

7 

8from .cd import ( 

9 coherence_ratio, 

10 encoding_languages, 

11 mb_encoding_languages, 

12 merge_coherence_ratios, 

13) 

14from .constant import ( 

15 IANA_SUPPORTED, 

16 IANA_SUPPORTED_SIMILAR, 

17 TOO_BIG_SEQUENCE, 

18 TOO_SMALL_SEQUENCE, 

19 TRACE, 

20) 

21from .md import mess_ratio 

22from .models import CharsetMatch, CharsetMatches 

23from .utils import ( 

24 any_specified_encoding, 

25 cut_sequence_chunks, 

26 iana_name, 

27 identify_sig_or_bom, 

28 is_multi_byte_encoding, 

29 should_strip_sig_or_bom, 

30) 

31 

32logger = logging.getLogger("charset_normalizer") 

33explain_handler = logging.StreamHandler() 

34explain_handler.setFormatter( 

35 logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") 

36) 

37 

38# Pre-compute a reordered encoding list: multibyte first, then single-byte. 

39# This allows the mb_definitive_match optimization to fire earlier, skipping 

40# all single-byte encodings for genuine CJK content. Multibyte codecs 

41# hard-fail (UnicodeDecodeError) on single-byte data almost instantly, so 

42# testing them first costs negligible time for non-CJK files. 

43# Stable sort on a boolean key: multibyte (False) first, IANA order kept 

44# within each group. 

45IANA_SUPPORTED_MB_FIRST: list[str] = sorted( 

46 IANA_SUPPORTED, key=lambda encoding: not is_multi_byte_encoding(encoding) 

47) 

48 

49 

50def from_bytes( 

51 sequences: bytes | bytearray, 

52 steps: int = 5, 

53 chunk_size: int = 512, 

54 threshold: float = 0.2, 

55 cp_isolation: list[str] | None = None, 

56 cp_exclusion: list[str] | None = None, 

57 preemptive_behaviour: bool = True, 

58 explain: bool = False, 

59 language_threshold: float = 0.1, 

60 enable_fallback: bool = True, 

61) -> CharsetMatches: 

62 """ 

63 Given a raw bytes sequence, return the best possibles charset usable to render str objects. 

64 If there is no results, it is a strong indicator that the source is binary/not text. 

65 By default, the process will extract 5 blocks of 512o each to assess the mess and coherence of a given sequence. 

66 And will give up a particular code page after 20% of measured mess. Those criteria are customizable at will. 

67 

68 The preemptive behavior DOES NOT replace the traditional detection workflow, it prioritize a particular code page 

69 but never take it for granted. Can improve the performance. 

70 

71 You may want to focus your attention to some code page or/and not others, use cp_isolation and cp_exclusion for that 

72 purpose. 

73 

74 This function will strip the SIG in the payload/sequence every time except on UTF-16, UTF-32. 

75 By default the library does not setup any handler other than the NullHandler, if you choose to set the 'explain' 

76 toggle to True it will alter the logger configuration to add a StreamHandler that is suitable for debugging. 

77 Custom logging format and handler can be set manually. 

78 """ 

79 

80 if not isinstance(sequences, (bytearray, bytes)): 

81 raise TypeError( 

82 "Expected object of type bytes or bytearray, got: {}".format( 

83 type(sequences) 

84 ) 

85 ) 

86 

87 if explain: 

88 previous_logger_level: int = logger.level 

89 logger.addHandler(explain_handler) 

90 logger.setLevel(TRACE) 

91 

92 length: int = len(sequences) 

93 

94 if length == 0: 

95 logger.debug("Encoding detection on empty bytes, assuming utf_8 intention.") 

96 if explain: # Defensive: ensure exit path clean handler 

97 logger.removeHandler(explain_handler) 

98 logger.setLevel(previous_logger_level) 

99 return CharsetMatches([CharsetMatch(sequences, "utf_8", 0.0, False, [], "")]) 

100 

101 if cp_isolation is not None: 

102 logger.log( 

103 TRACE, 

104 "cp_isolation is set. use this flag for debugging purpose. " 

105 "limited list of encoding allowed : %s.", 

106 ", ".join(cp_isolation), 

107 ) 

108 cp_isolation = [iana_name(cp, False) for cp in cp_isolation] 

109 else: 

110 cp_isolation = [] 

111 

112 if cp_exclusion is not None: 

113 logger.log( 

114 TRACE, 

115 "cp_exclusion is set. use this flag for debugging purpose. " 

116 "limited list of encoding excluded : %s.", 

117 ", ".join(cp_exclusion), 

118 ) 

119 cp_exclusion = [iana_name(cp, False) for cp in cp_exclusion] 

120 else: 

121 cp_exclusion = [] 

122 

123 if length <= (chunk_size * steps): 

124 logger.log( 

125 TRACE, 

126 "override steps (%i) and chunk_size (%i) as content does not fit (%i byte(s) given) parameters.", 

127 steps, 

128 chunk_size, 

129 length, 

130 ) 

131 steps = 1 

132 chunk_size = length 

133 

134 if steps > 1 and length / steps < chunk_size: 

135 chunk_size = int(length / steps) 

136 

137 is_too_small_sequence: bool = len(sequences) < TOO_SMALL_SEQUENCE 

138 is_too_large_sequence: bool = len(sequences) >= TOO_BIG_SEQUENCE 

139 

140 if is_too_small_sequence: 

141 logger.log( 

142 TRACE, 

143 "Trying to detect encoding from a tiny portion of ({}) byte(s).".format( 

144 length 

145 ), 

146 ) 

147 elif is_too_large_sequence: 

148 logger.log( 

149 TRACE, 

150 "Using lazy str decoding because the payload is quite large, ({}) byte(s).".format( 

151 length 

152 ), 

153 ) 

154 

155 prioritized_encodings: list[str] = [] 

156 

157 specified_encoding: str | None = ( 

158 any_specified_encoding(sequences) if preemptive_behaviour else None 

159 ) 

160 

161 if specified_encoding is not None: 

162 prioritized_encodings.append(specified_encoding) 

163 logger.log( 

164 TRACE, 

165 "Detected declarative mark in sequence. Priority +1 given for %s.", 

166 specified_encoding, 

167 ) 

168 

169 tested: set[str] = set() 

170 tested_but_hard_failure: list[str] = [] 

171 tested_but_soft_failure: list[str] = [] 

172 soft_failure_skip: set[str] = set() 

173 success_fast_tracked: set[str] = set() 

174 

175 # Cache for decoded payload deduplication: hash(decoded_payload) -> (mean_mess_ratio, cd_ratios_merged, passed) 

176 # When multiple encodings decode to the exact same string, we can skip the expensive 

177 # mess_ratio and coherence_ratio analysis and reuse the results from the first encoding. 

178 payload_result_cache: dict[int, tuple[float, list[tuple[str, float]], bool]] = {} 

179 

180 # Avoid unoptimized RSS usage. 

181 # this cache is mostly interesting for 

182 # local usage. Garbage collected at the 

183 # end. Like it should. 

184 cached_mess_ratio = lru_cache(maxsize=None)(mess_ratio) 

185 cached_coherence_ratio = lru_cache(maxsize=None)(coherence_ratio) 

186 

187 # When a definitive result (chaos=0.0 and good coherence) is found after testing 

188 # the prioritized encodings (ascii, utf_8), we can significantly reduce the remaining 

189 # work. Encodings that target completely different language families (e.g., Cyrillic 

190 # when the definitive match is Latin) are skipped entirely. 

191 # Additionally, for same-family encodings that pass chaos probing, we reuse the 

192 # definitive match's coherence ratios instead of recomputing them — a major savings 

193 # since coherence_ratio accounts for ~30% of total time on slow Latin files. 

194 definitive_match_found: bool = False 

195 definitive_target_languages: set[str] = set() 

196 # After the definitive match fires, we cap the number of additional same-family 

197 # single-byte encodings that pass chaos probing. Once we've accumulated enough 

198 # good candidates (N), further same-family SB encodings are unlikely to produce 

199 # a better best() result and just waste mess_ratio + coherence_ratio time. 

200 # The first encoding to trigger the definitive match is NOT counted (it's already in). 

201 post_definitive_sb_success_count: int = 0 

202 POST_DEFINITIVE_SB_CAP: int = 7 

203 

204 # When a non-UTF multibyte encoding passes chaos probing with significant multibyte 

205 # content (decoded length < 98% of raw length), skip all remaining single-byte encodings. 

206 # Rationale: multi-byte decoders (CJK) have strict byte-sequence validation — if they 

207 # decode without error AND pass chaos probing with substantial multibyte content, the 

208 # data is genuinely multibyte encoded. Single-byte encodings will always decode (every 

209 # byte maps to something) but waste time on mess_ratio before failing. 

210 # The 98% threshold prevents false triggers on files that happen to have a few valid 

211 # multibyte pairs (e.g., cp424/_ude_1.txt where big5 decodes with 99% ratio). 

212 mb_definitive_match_found: bool = False 

213 

214 fallback_ascii: CharsetMatch | None = None 

215 fallback_u8: CharsetMatch | None = None 

216 fallback_specified: CharsetMatch | None = None 

217 

218 results: CharsetMatches = CharsetMatches() 

219 

220 early_stop_results: CharsetMatches = CharsetMatches() 

221 

222 sig_encoding, sig_payload = identify_sig_or_bom(sequences) 

223 

224 if sig_encoding is not None: 

225 prioritized_encodings.append(sig_encoding) 

226 logger.log( 

227 TRACE, 

228 "Detected a SIG or BOM mark on first %i byte(s). Priority +1 given for %s.", 

229 len(sig_payload), 

230 sig_encoding, 

231 ) 

232 

233 prioritized_encodings.append("ascii") 

234 

235 if "utf_8" not in prioritized_encodings: 

236 prioritized_encodings.append("utf_8") 

237 

238 for encoding_iana in prioritized_encodings + IANA_SUPPORTED_MB_FIRST: 

239 if cp_isolation and encoding_iana not in cp_isolation: 

240 continue 

241 

242 if cp_exclusion and encoding_iana in cp_exclusion: 

243 continue 

244 

245 if encoding_iana in tested: 

246 continue 

247 

248 tested.add(encoding_iana) 

249 

250 decoded_payload: str | None = None 

251 bom_or_sig_available: bool = sig_encoding == encoding_iana 

252 strip_sig_or_bom: bool = bom_or_sig_available and should_strip_sig_or_bom( 

253 encoding_iana 

254 ) 

255 

256 if encoding_iana in {"utf_16", "utf_32"} and not bom_or_sig_available: 

257 logger.log( 

258 TRACE, 

259 "Encoding %s won't be tested as-is because it require a BOM. Will try some sub-encoder LE/BE.", 

260 encoding_iana, 

261 ) 

262 continue 

263 if encoding_iana in {"utf_7"} and not bom_or_sig_available: 

264 logger.log( 

265 TRACE, 

266 "Encoding %s won't be tested as-is because detection is unreliable without BOM/SIG.", 

267 encoding_iana, 

268 ) 

269 continue 

270 

271 # Skip encodings similar to ones that already soft-failed (high mess ratio). 

272 # Checked BEFORE the expensive decode attempt. 

273 if encoding_iana in soft_failure_skip: 

274 logger.log( 

275 TRACE, 

276 "%s is deemed too similar to a code page that was already considered unsuited. Continuing!", 

277 encoding_iana, 

278 ) 

279 continue 

280 

281 # Skip encodings that were already fast-tracked from a similar successful encoding. 

282 if encoding_iana in success_fast_tracked: 

283 logger.log( 

284 TRACE, 

285 "Skipping %s: already fast-tracked from a similar successful encoding.", 

286 encoding_iana, 

287 ) 

288 continue 

289 

290 try: 

291 is_multi_byte_decoder: bool = is_multi_byte_encoding(encoding_iana) 

292 except (ModuleNotFoundError, ImportError): # Defensive: 

293 logger.log( 

294 TRACE, 

295 "Encoding %s does not provide an IncrementalDecoder", 

296 encoding_iana, 

297 ) 

298 continue 

299 

300 # When we've already found a definitive match (chaos=0.0 with good coherence) 

301 # after testing the prioritized encodings, skip encodings that target 

302 # completely different language families. This avoids running expensive 

303 # mess_ratio + coherence_ratio on clearly unrelated candidates (e.g., Cyrillic 

304 # when the definitive match is Latin-based). 

305 if definitive_match_found: 

306 if not is_multi_byte_decoder: 

307 enc_languages = set(encoding_languages(encoding_iana)) 

308 else: 

309 enc_languages = set(mb_encoding_languages(encoding_iana)) 

310 if not enc_languages.intersection(definitive_target_languages): 

311 logger.log( 

312 TRACE, 

313 "Skipping %s: definitive match already found, this encoding targets different languages (%s vs %s).", 

314 encoding_iana, 

315 enc_languages, 

316 definitive_target_languages, 

317 ) 

318 continue 

319 

320 # After the definitive match, cap the number of additional same-family 

321 # single-byte encodings that pass chaos probing. This avoids testing the 

322 # tail of rare, low-value same-family encodings (mac_iceland, cp860, etc.) 

323 # that almost never change best() but each cost ~1-2ms of mess_ratio + coherence. 

324 if ( 

325 definitive_match_found 

326 and not is_multi_byte_decoder 

327 and post_definitive_sb_success_count >= POST_DEFINITIVE_SB_CAP 

328 ): 

329 logger.log( 

330 TRACE, 

331 "Skipping %s: already accumulated %d same-family results after definitive match (cap=%d).", 

332 encoding_iana, 

333 post_definitive_sb_success_count, 

334 POST_DEFINITIVE_SB_CAP, 

335 ) 

336 continue 

337 

338 # When a multibyte encoding with significant multibyte content has already 

339 # passed chaos probing, skip all single-byte encodings. They will either fail 

340 # chaos probing (wasting mess_ratio time) or produce inferior results. 

341 if mb_definitive_match_found and not is_multi_byte_decoder: 

342 logger.log( 

343 TRACE, 

344 "Skipping single-byte %s: multi-byte definitive match already found.", 

345 encoding_iana, 

346 ) 

347 continue 

348 

349 # Single-byte candidates of regular size defer the expensive whole 

350 # payload decode until after chunk probing: single-byte codecs are 

351 # stateless (1 byte == 1 char) so decoding chunk slices is provably 

352 # identical to slicing the decoded payload, and candidates rejected 

353 # by chaos probing (the common case) never pay the full decode nor 

354 # the payload hash. 

355 deferred_decoding: bool = ( 

356 not is_multi_byte_decoder and not is_too_large_sequence 

357 ) 

358 

359 try: 

360 if is_too_large_sequence and not is_multi_byte_decoder: 

361 str( 

362 ( 

363 sequences[: int(50e4)] 

364 if not strip_sig_or_bom 

365 else sequences[len(sig_payload) : int(50e4)] 

366 ), 

367 encoding=encoding_iana, 

368 ) 

369 elif not deferred_decoding: 

370 # UTF-7 BOM is encoded in modified Base64 whose byte boundary 

371 # can overlap with the next character. Stripping raw SIG bytes 

372 # before decoding may leave stray bytes that decode as garbage. 

373 # Decode the full sequence and remove the leading BOM char instead. 

374 # see https://github.com/jawah/charset_normalizer/issues/718 

375 # and https://github.com/jawah/charset_normalizer/issues/716 

376 if encoding_iana == "utf_7" and bom_or_sig_available: 

377 decoded_payload = str( 

378 sequences, 

379 encoding=encoding_iana, 

380 ) 

381 if decoded_payload and decoded_payload[0] == "\ufeff": 

382 decoded_payload = decoded_payload[1:] 

383 else: 

384 decoded_payload = str( 

385 ( 

386 sequences 

387 if not strip_sig_or_bom 

388 else sequences[len(sig_payload) :] 

389 ), 

390 encoding=encoding_iana, 

391 ) 

392 except (UnicodeDecodeError, LookupError) as e: 

393 if not isinstance(e, LookupError): 

394 logger.log( 

395 TRACE, 

396 "Code page %s does not fit given bytes sequence at ALL. %s", 

397 encoding_iana, 

398 str(e), 

399 ) 

400 tested_but_hard_failure.append(encoding_iana) 

401 continue 

402 

403 r_ = range( 

404 0 if not bom_or_sig_available else len(sig_payload), 

405 length, 

406 int(length / steps), 

407 ) 

408 

409 multi_byte_bonus: bool = ( 

410 is_multi_byte_decoder 

411 and decoded_payload is not None 

412 and len(decoded_payload) < length 

413 ) 

414 

415 if multi_byte_bonus: 

416 logger.log( 

417 TRACE, 

418 "Code page %s is a multi byte encoding table and it appear that at least one character " 

419 "was encoded using n-bytes.", 

420 encoding_iana, 

421 ) 

422 

423 max_chunk_gave_up: int = int(len(r_) / 4) 

424 

425 max_chunk_gave_up = max(max_chunk_gave_up, 2) 

426 early_stop_count: int = 0 

427 lazy_str_hard_failure = False 

428 

429 md_chunks: list[str] = [] 

430 md_ratios = [] 

431 

432 try: 

433 for chunk in cut_sequence_chunks( 

434 sequences, 

435 encoding_iana, 

436 r_, 

437 chunk_size, 

438 bom_or_sig_available, 

439 strip_sig_or_bom, 

440 sig_payload, 

441 is_multi_byte_decoder, 

442 decoded_payload, 

443 deferred_decoding, 

444 ): 

445 md_chunks.append(chunk) 

446 

447 md_ratios.append( 

448 cached_mess_ratio( 

449 chunk, 

450 threshold, 

451 explain and 1 <= len(cp_isolation) <= 2, 

452 ) 

453 ) 

454 

455 if md_ratios[-1] >= threshold: 

456 early_stop_count += 1 

457 

458 if (early_stop_count >= max_chunk_gave_up) or ( 

459 bom_or_sig_available and not strip_sig_or_bom 

460 ): 

461 break 

462 except ( 

463 UnicodeDecodeError, 

464 LookupError, 

465 ) as e: # Lazy str loading may have missed something there 

466 if deferred_decoding: 

467 # Deferred single-byte validation failed on a chunk (or the 

468 # codec is unavailable on this interpreter build): identical 

469 # outcome and bookkeeping to the eager full-decode failure. 

470 logger.log( 

471 TRACE, 

472 "Code page %s does not fit given bytes sequence at ALL. %s", 

473 encoding_iana, 

474 str(e), 

475 ) 

476 tested_but_hard_failure.append(encoding_iana) 

477 continue 

478 logger.log( 

479 TRACE, 

480 "LazyStr Loading: After MD chunk decode, code page %s does not fit given bytes sequence at ALL. %s", 

481 encoding_iana, 

482 str(e), 

483 ) 

484 early_stop_count = max_chunk_gave_up 

485 lazy_str_hard_failure = True 

486 

487 # We might want to check the sequence again with the whole content 

488 # Only if initial MD tests passes 

489 if ( 

490 not lazy_str_hard_failure 

491 and is_too_large_sequence 

492 and not is_multi_byte_decoder 

493 ): 

494 try: 

495 sequences[int(50e3) :].decode(encoding_iana, errors="strict") 

496 except UnicodeDecodeError as e: 

497 logger.log( 

498 TRACE, 

499 "LazyStr Loading: After final lookup, code page %s does not fit given bytes sequence at ALL. %s", 

500 encoding_iana, 

501 str(e), 

502 ) 

503 tested_but_hard_failure.append(encoding_iana) 

504 continue 

505 

506 mean_mess_ratio: float = sum(md_ratios) / len(md_ratios) if md_ratios else 0.0 

507 if mean_mess_ratio >= threshold or early_stop_count >= max_chunk_gave_up: 

508 tested_but_soft_failure.append(encoding_iana) 

509 if encoding_iana in IANA_SUPPORTED_SIMILAR: 

510 soft_failure_skip.update(IANA_SUPPORTED_SIMILAR[encoding_iana]) 

511 # Cache this soft-failure so identical decoding from other encodings 

512 # can be skipped immediately. 

513 if decoded_payload is not None and not is_multi_byte_decoder: 

514 payload_result_cache.setdefault( 

515 hash(decoded_payload), (mean_mess_ratio, [], False) 

516 ) 

517 logger.log( 

518 TRACE, 

519 "%s was excluded because of initial chaos probing. Gave up %i time(s). " 

520 "Computed mean chaos is %f %%.", 

521 encoding_iana, 

522 early_stop_count, 

523 round(mean_mess_ratio * 100, ndigits=3), 

524 ) 

525 # Preparing those fallbacks in case we got nothing. 

526 if ( 

527 enable_fallback 

528 and encoding_iana 

529 in ["ascii", "utf_8", specified_encoding, "utf_16", "utf_32"] 

530 and not lazy_str_hard_failure 

531 ): 

532 # Always fully decode payload before. 

533 # We've missed a UnicodeDecodeError proof 

534 # while issuing release 3.4.8 

535 # see https://github.com/jawah/charset_normalizer/issues/771 

536 if decoded_payload is None: 

537 try: 

538 decoded_payload = str( 

539 ( 

540 sequences 

541 if not strip_sig_or_bom 

542 else sequences[len(sig_payload) :] 

543 ), 

544 encoding=encoding_iana, 

545 ) 

546 except (UnicodeDecodeError, LookupError): 

547 logger.log( 

548 TRACE, 

549 "%s does not decode the whole payload: fallback entry withheld.", 

550 encoding_iana, 

551 ) 

552 continue 

553 if is_too_large_sequence: 

554 # Don't retain huge payload in RAM. 

555 decoded_payload = None 

556 

557 fallback_entry = CharsetMatch( 

558 sequences, 

559 encoding_iana, 

560 threshold, 

561 bom_or_sig_available, 

562 [], 

563 decoded_payload, 

564 preemptive_declaration=specified_encoding, 

565 ) 

566 if encoding_iana == specified_encoding: 

567 fallback_specified = fallback_entry 

568 elif encoding_iana == "ascii": 

569 fallback_ascii = fallback_entry 

570 else: 

571 fallback_u8 = fallback_entry 

572 continue 

573 

574 if deferred_decoding: 

575 # The candidate passed chaos probing: perform the whole payload 

576 # decode (validation + payload reuse) that was deferred earlier. 

577 try: 

578 decoded_payload = str( 

579 ( 

580 sequences 

581 if not strip_sig_or_bom 

582 else sequences[len(sig_payload) :] 

583 ), 

584 encoding=encoding_iana, 

585 ) 

586 except (UnicodeDecodeError, LookupError) as e: 

587 logger.log( 

588 TRACE, 

589 "Code page %s does not fit given bytes sequence at ALL. %s", 

590 encoding_iana, 

591 str(e), 

592 ) 

593 tested_but_hard_failure.append(encoding_iana) 

594 continue 

595 

596 # Payload-hash deduplication: if another encoding already decoded to the 

597 # exact same string, reuse its mess_ratio and coherence results entirely. 

598 # This is strictly more general than the old IANA_SUPPORTED_SIMILAR approach 

599 # because it catches ALL identical decoding, not just pre-mapped ones. 

600 if decoded_payload is not None and not is_multi_byte_decoder: 

601 payload_hash: int = hash(decoded_payload) 

602 cached = payload_result_cache.get(payload_hash) 

603 if cached is not None: 

604 cached_mess, cached_cd, cached_passed = cached 

605 if cached_passed: 

606 # The previous encoding with identical output passed chaos probing. 

607 fast_match = CharsetMatch( 

608 sequences, 

609 encoding_iana, 

610 cached_mess, 

611 bom_or_sig_available, 

612 cached_cd, 

613 ( 

614 decoded_payload 

615 if ( 

616 not is_too_large_sequence 

617 or encoding_iana 

618 in [specified_encoding, "ascii", "utf_8"] 

619 ) 

620 else None 

621 ), 

622 preemptive_declaration=specified_encoding, 

623 ) 

624 results.append(fast_match) 

625 success_fast_tracked.add(encoding_iana) 

626 logger.log( 

627 TRACE, 

628 "%s fast-tracked (identical decoded payload to a prior encoding, chaos=%f %%).", 

629 encoding_iana, 

630 round(cached_mess * 100, ndigits=3), 

631 ) 

632 

633 if ( 

634 encoding_iana in [specified_encoding, "ascii", "utf_8"] 

635 and cached_mess < 0.1 

636 ): 

637 if cached_mess == 0.0: 

638 logger.debug( 

639 "Encoding detection: %s is most likely the one.", 

640 fast_match.encoding, 

641 ) 

642 if explain: 

643 logger.removeHandler(explain_handler) 

644 logger.setLevel(previous_logger_level) 

645 return CharsetMatches([fast_match]) 

646 early_stop_results.append(fast_match) 

647 

648 if ( 

649 len(early_stop_results) 

650 and (specified_encoding is None or specified_encoding in tested) 

651 and "ascii" in tested 

652 and "utf_8" in tested 

653 ): 

654 probable_result: CharsetMatch = early_stop_results.best() # type: ignore[assignment] 

655 logger.debug( 

656 "Encoding detection: %s is most likely the one.", 

657 probable_result.encoding, 

658 ) 

659 if explain: 

660 logger.removeHandler(explain_handler) 

661 logger.setLevel(previous_logger_level) 

662 return CharsetMatches([probable_result]) 

663 

664 continue 

665 else: 

666 # The previous encoding with identical output failed chaos 

667 # probing. Unreachable when the current candidate passed 

668 # probing on the identical payload (deterministic ratios), 

669 # kept for structural parity with the historic flow. 

670 tested_but_soft_failure.append(encoding_iana) 

671 logger.log( 

672 TRACE, 

673 "%s fast-skipped (identical decoded payload to a prior encoding that failed chaos probing).", 

674 encoding_iana, 

675 ) 

676 # Prepare fallbacks for special encodings even when skipped. 

677 if enable_fallback and encoding_iana in [ 

678 "ascii", 

679 "utf_8", 

680 specified_encoding, 

681 "utf_16", 

682 "utf_32", 

683 ]: 

684 fallback_entry = CharsetMatch( 

685 sequences, 

686 encoding_iana, 

687 threshold, 

688 bom_or_sig_available, 

689 [], 

690 decoded_payload, 

691 preemptive_declaration=specified_encoding, 

692 ) 

693 if encoding_iana == specified_encoding: 

694 fallback_specified = fallback_entry 

695 elif encoding_iana == "ascii": 

696 fallback_ascii = fallback_entry 

697 else: 

698 fallback_u8 = fallback_entry 

699 continue 

700 

701 logger.log( 

702 TRACE, 

703 "%s passed initial chaos probing. Mean measured chaos is %f %%", 

704 encoding_iana, 

705 round(mean_mess_ratio * 100, ndigits=3), 

706 ) 

707 

708 if not is_multi_byte_decoder: 

709 target_languages: list[str] = encoding_languages(encoding_iana) 

710 else: 

711 target_languages = mb_encoding_languages(encoding_iana) 

712 

713 if target_languages: 

714 logger.log( 

715 TRACE, 

716 "{} should target any language(s) of {}".format( 

717 encoding_iana, str(target_languages) 

718 ), 

719 ) 

720 

721 cd_ratios = [] 

722 

723 # Run coherence detection on all chunks. We previously tried limiting to 

724 # 1-2 chunks for post-definitive encodings to save time, but this caused 

725 # coverage regressions by producing unrepresentative coherence scores. 

726 # The SB cap and language-family skip optimizations provide sufficient 

727 # speedup without sacrificing coherence accuracy. 

728 if encoding_iana != "ascii": 

729 # We shall skip the CD when its about ASCII 

730 # Most of the time its not relevant to run "language-detection" on it. 

731 lg_inclusion: str | None = ( 

732 ",".join(target_languages) if target_languages else None 

733 ) 

734 

735 for chunk in md_chunks: 

736 chunk_languages = cached_coherence_ratio( 

737 chunk, 

738 language_threshold, 

739 lg_inclusion, 

740 ) 

741 

742 cd_ratios.append(chunk_languages) 

743 

744 cd_ratios_merged = merge_coherence_ratios(cd_ratios) 

745 

746 if cd_ratios_merged: 

747 logger.log( 

748 TRACE, 

749 "We detected language {} using {}".format( 

750 cd_ratios_merged, encoding_iana 

751 ), 

752 ) 

753 

754 current_match = CharsetMatch( 

755 sequences, 

756 encoding_iana, 

757 mean_mess_ratio, 

758 bom_or_sig_available, 

759 cd_ratios_merged, 

760 ( 

761 decoded_payload 

762 if ( 

763 not is_too_large_sequence 

764 or encoding_iana in [specified_encoding, "ascii", "utf_8"] 

765 ) 

766 else None 

767 ), 

768 preemptive_declaration=specified_encoding, 

769 ) 

770 

771 results.append(current_match) 

772 

773 # Cache the successful result for payload-hash deduplication. 

774 if decoded_payload is not None and not is_multi_byte_decoder: 

775 payload_result_cache.setdefault( 

776 hash(decoded_payload), 

777 (mean_mess_ratio, cd_ratios_merged, True), 

778 ) 

779 

780 # Count post-definitive same-family SB successes for the early termination cap. 

781 # Only count low-mess encodings (< 2%) toward the cap. High-mess encodings are 

782 # marginal results that shouldn't prevent better-quality candidates from being 

783 # tested. For example, iso8859_4 (mess=0%) should not be skipped just because 

784 # 7 high-mess Latin encodings (cp1252 at 8%, etc.) were tried first. 

785 if ( 

786 definitive_match_found 

787 and not is_multi_byte_decoder 

788 and mean_mess_ratio < 0.02 

789 ): 

790 post_definitive_sb_success_count += 1 

791 

792 if ( 

793 encoding_iana in [specified_encoding, "ascii", "utf_8"] 

794 and mean_mess_ratio < 0.1 

795 ): 

796 # If md says nothing to worry about, then... stop immediately! 

797 if mean_mess_ratio == 0.0: 

798 logger.debug( 

799 "Encoding detection: %s is most likely the one.", 

800 current_match.encoding, 

801 ) 

802 if explain: # Defensive: ensure exit path clean handler 

803 logger.removeHandler(explain_handler) 

804 logger.setLevel(previous_logger_level) 

805 return CharsetMatches([current_match]) 

806 

807 early_stop_results.append(current_match) 

808 

809 if ( 

810 len(early_stop_results) 

811 and (specified_encoding is None or specified_encoding in tested) 

812 and "ascii" in tested 

813 and "utf_8" in tested 

814 ): 

815 probable_result = early_stop_results.best() # type: ignore[assignment] 

816 logger.debug( 

817 "Encoding detection: %s is most likely the one.", 

818 probable_result.encoding, # type: ignore[union-attr] 

819 ) 

820 if explain: # Defensive: ensure exit path clean handler 

821 logger.removeHandler(explain_handler) 

822 logger.setLevel(previous_logger_level) 

823 

824 return CharsetMatches([probable_result]) 

825 

826 # Once we find a result with good coherence (>= 0.5) after testing the 

827 # prioritized encodings (ascii, utf_8), activate "definitive mode": skip 

828 # encodings that target completely different language families. This avoids 

829 # running expensive mess_ratio + coherence_ratio on clearly unrelated 

830 # candidates (e.g., Cyrillic encodings when the match is Latin-based). 

831 # We require coherence >= 0.5 to avoid false positives (e.g., cp1251 decoding 

832 # Hebrew text with 0.0 chaos but wrong language detection at coherence 0.33). 

833 if not definitive_match_found and not is_multi_byte_decoder: 

834 best_coherence = ( 

835 max((v for _, v in cd_ratios_merged), default=0.0) 

836 if cd_ratios_merged 

837 else 0.0 

838 ) 

839 if best_coherence >= 0.5 and "ascii" in tested and "utf_8" in tested: 

840 definitive_match_found = True 

841 definitive_target_languages.update(target_languages) 

842 logger.log( 

843 TRACE, 

844 "Definitive match found: %s (chaos=%.3f, coherence=%.2f). Encodings targeting different language families will be skipped.", 

845 encoding_iana, 

846 mean_mess_ratio, 

847 best_coherence, 

848 ) 

849 

850 # When a non-UTF multibyte encoding passes chaos probing with significant 

851 # multibyte content (decoded < 98% of raw), activate mb_definitive_match. 

852 # This skips all remaining single-byte encodings which would either soft-fail 

853 # (running expensive mess_ratio for nothing) or produce inferior results. 

854 if ( 

855 not mb_definitive_match_found 

856 and is_multi_byte_decoder 

857 and multi_byte_bonus 

858 and decoded_payload is not None 

859 and len(decoded_payload) < length * 0.98 

860 and encoding_iana 

861 not in { 

862 "utf_8", 

863 "utf_8_sig", 

864 "utf_16", 

865 "utf_16_be", 

866 "utf_16_le", 

867 "utf_32", 

868 "utf_32_be", 

869 "utf_32_le", 

870 "utf_7", 

871 } 

872 and "ascii" in tested 

873 and "utf_8" in tested 

874 ): 

875 mb_definitive_match_found = True 

876 logger.log( 

877 TRACE, 

878 "Multi-byte definitive match: %s (chaos=%.3f, decoded=%d/%d=%.1f%%). Single-byte encodings will be skipped.", 

879 encoding_iana, 

880 mean_mess_ratio, 

881 len(decoded_payload), 

882 length, 

883 len(decoded_payload) / length * 100, 

884 ) 

885 

886 if encoding_iana == sig_encoding: 

887 logger.debug( 

888 "Encoding detection: %s is most likely the one as we detected a BOM or SIG within " 

889 "the beginning of the sequence.", 

890 encoding_iana, 

891 ) 

892 if explain: # Defensive: ensure exit path clean handler 

893 logger.removeHandler(explain_handler) 

894 logger.setLevel(previous_logger_level) 

895 return CharsetMatches([results[encoding_iana]]) 

896 

897 if len(results) == 0: 

898 if fallback_u8 or fallback_ascii or fallback_specified: 

899 logger.log( 

900 TRACE, 

901 "Nothing got out of the detection process. Using ASCII/UTF-8/Specified fallback.", 

902 ) 

903 

904 if fallback_specified: 

905 logger.debug( 

906 "Encoding detection: %s will be used as a fallback match", 

907 fallback_specified.encoding, 

908 ) 

909 results.append(fallback_specified) 

910 elif ( 

911 (fallback_u8 and fallback_ascii is None) 

912 or ( 

913 fallback_u8 

914 and fallback_ascii 

915 and fallback_u8.fingerprint != fallback_ascii.fingerprint 

916 ) 

917 or (fallback_u8 is not None) 

918 ): 

919 logger.debug("Encoding detection: utf_8 will be used as a fallback match") 

920 results.append(fallback_u8) 

921 elif fallback_ascii: 

922 logger.debug("Encoding detection: ascii will be used as a fallback match") 

923 results.append(fallback_ascii) 

924 

925 if results: 

926 logger.debug( 

927 "Encoding detection: Found %s as plausible (best-candidate) for content. With %i alternatives.", 

928 results.best().encoding, # type: ignore 

929 len(results) - 1, 

930 ) 

931 else: 

932 logger.debug("Encoding detection: Unable to determine any suitable charset.") 

933 

934 if explain: 

935 logger.removeHandler(explain_handler) 

936 logger.setLevel(previous_logger_level) 

937 

938 return results 

939 

940 

941def from_fp( 

942 fp: BinaryIO, 

943 steps: int = 5, 

944 chunk_size: int = 512, 

945 threshold: float = 0.20, 

946 cp_isolation: list[str] | None = None, 

947 cp_exclusion: list[str] | None = None, 

948 preemptive_behaviour: bool = True, 

949 explain: bool = False, 

950 language_threshold: float = 0.1, 

951 enable_fallback: bool = True, 

952) -> CharsetMatches: 

953 """ 

954 Same thing than the function from_bytes but using a file pointer that is already ready. 

955 Will not close the file pointer. 

956 """ 

957 return from_bytes( 

958 fp.read(), 

959 steps, 

960 chunk_size, 

961 threshold, 

962 cp_isolation, 

963 cp_exclusion, 

964 preemptive_behaviour, 

965 explain, 

966 language_threshold, 

967 enable_fallback, 

968 ) 

969 

970 

971def from_path( 

972 path: str | bytes | PathLike, # type: ignore[type-arg] 

973 steps: int = 5, 

974 chunk_size: int = 512, 

975 threshold: float = 0.20, 

976 cp_isolation: list[str] | None = None, 

977 cp_exclusion: list[str] | None = None, 

978 preemptive_behaviour: bool = True, 

979 explain: bool = False, 

980 language_threshold: float = 0.1, 

981 enable_fallback: bool = True, 

982) -> CharsetMatches: 

983 """ 

984 Same thing than the function from_bytes but with one extra step. Opening and reading given file path in binary mode. 

985 Can raise IOError. 

986 """ 

987 with open(path, "rb") as fp: 

988 return from_fp( 

989 fp, 

990 steps, 

991 chunk_size, 

992 threshold, 

993 cp_isolation, 

994 cp_exclusion, 

995 preemptive_behaviour, 

996 explain, 

997 language_threshold, 

998 enable_fallback, 

999 ) 

1000 

1001 

1002def is_binary( 

1003 fp_or_path_or_payload: PathLike | str | BinaryIO | bytes, # type: ignore[type-arg] 

1004 steps: int = 5, 

1005 chunk_size: int = 512, 

1006 threshold: float = 0.20, 

1007 cp_isolation: list[str] | None = None, 

1008 cp_exclusion: list[str] | None = None, 

1009 preemptive_behaviour: bool = True, 

1010 explain: bool = False, 

1011 language_threshold: float = 0.1, 

1012 enable_fallback: bool = False, 

1013) -> bool: 

1014 """ 

1015 Detect if the given input (file, bytes, or path) points to a binary file. aka. not a string. 

1016 Based on the same main heuristic algorithms and default kwargs at the sole exception that fallbacks match 

1017 are disabled to be stricter around ASCII-compatible but unlikely to be a string. 

1018 """ 

1019 if isinstance(fp_or_path_or_payload, (str, PathLike)): 

1020 guesses = from_path( 

1021 fp_or_path_or_payload, 

1022 steps=steps, 

1023 chunk_size=chunk_size, 

1024 threshold=threshold, 

1025 cp_isolation=cp_isolation, 

1026 cp_exclusion=cp_exclusion, 

1027 preemptive_behaviour=preemptive_behaviour, 

1028 explain=explain, 

1029 language_threshold=language_threshold, 

1030 enable_fallback=enable_fallback, 

1031 ) 

1032 elif isinstance( 

1033 fp_or_path_or_payload, 

1034 ( 

1035 bytes, 

1036 bytearray, 

1037 ), 

1038 ): 

1039 guesses = from_bytes( 

1040 fp_or_path_or_payload, 

1041 steps=steps, 

1042 chunk_size=chunk_size, 

1043 threshold=threshold, 

1044 cp_isolation=cp_isolation, 

1045 cp_exclusion=cp_exclusion, 

1046 preemptive_behaviour=preemptive_behaviour, 

1047 explain=explain, 

1048 language_threshold=language_threshold, 

1049 enable_fallback=enable_fallback, 

1050 ) 

1051 else: 

1052 guesses = from_fp( 

1053 fp_or_path_or_payload, 

1054 steps=steps, 

1055 chunk_size=chunk_size, 

1056 threshold=threshold, 

1057 cp_isolation=cp_isolation, 

1058 cp_exclusion=cp_exclusion, 

1059 preemptive_behaviour=preemptive_behaviour, 

1060 explain=explain, 

1061 language_threshold=language_threshold, 

1062 enable_fallback=enable_fallback, 

1063 ) 

1064 

1065 return not guesses