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 (%i) byte(s).",
144 length,
145 )
146 elif is_too_large_sequence:
147 logger.log(
148 TRACE,
149 "Using lazy str decoding because the payload is quite large, (%i) byte(s).",
150 length,
151 )
152
153 prioritized_encodings: list[str] = []
154
155 specified_encoding: str | None = (
156 any_specified_encoding(sequences) if preemptive_behaviour else None
157 )
158
159 if specified_encoding is not None:
160 prioritized_encodings.append(specified_encoding)
161 logger.log(
162 TRACE,
163 "Detected declarative mark in sequence. Priority +1 given for %s.",
164 specified_encoding,
165 )
166
167 tested: set[str] = set()
168 tested_but_hard_failure: list[str] = []
169 tested_but_soft_failure: list[str] = []
170 soft_failure_skip: set[str] = set()
171
172 # Avoid unoptimized RSS usage.
173 # this cache is mostly interesting for
174 # local usage. Garbage collected at the
175 # end. Like it should.
176 cached_mess_ratio = lru_cache(maxsize=None)(mess_ratio)
177 cached_coherence_ratio = lru_cache(maxsize=None)(coherence_ratio)
178
179 # When a definitive result (chaos=0.0 and good coherence) is found after testing
180 # the prioritized encodings (ascii, utf_8), we can significantly reduce the remaining
181 # work. Encodings that target completely different language families (e.g., Cyrillic
182 # when the definitive match is Latin) are skipped entirely.
183 # Additionally, for same-family encodings that pass chaos probing, we reuse the
184 # definitive match's coherence ratios instead of recomputing them — a major savings
185 # since coherence_ratio accounts for ~30% of total time on slow Latin files.
186 definitive_match_found: bool = False
187 definitive_target_languages: set[str] = set()
188 # After the definitive match fires, we cap the number of additional same-family
189 # single-byte encodings that pass chaos probing. Once we've accumulated enough
190 # good candidates (N), further same-family SB encodings are unlikely to produce
191 # a better best() result and just waste mess_ratio + coherence_ratio time.
192 # The first encoding to trigger the definitive match is NOT counted (it's already in).
193 post_definitive_sb_success_count: int = 0
194 POST_DEFINITIVE_SB_CAP: int = 7
195
196 # When a non-UTF multibyte encoding passes chaos probing with significant multibyte
197 # content (decoded length < 98% of raw length), skip all remaining single-byte encodings.
198 # Rationale: multi-byte decoders (CJK) have strict byte-sequence validation — if they
199 # decode without error AND pass chaos probing with substantial multibyte content, the
200 # data is genuinely multibyte encoded. Single-byte encodings will always decode (every
201 # byte maps to something) but waste time on mess_ratio before failing.
202 # The 98% threshold prevents false triggers on files that happen to have a few valid
203 # multibyte pairs (e.g., cp424/_ude_1.txt where big5 decodes with 99% ratio).
204 mb_definitive_match_found: bool = False
205
206 fallback_ascii: CharsetMatch | None = None
207 fallback_u8: CharsetMatch | None = None
208 fallback_specified: CharsetMatch | None = None
209
210 results: CharsetMatches = CharsetMatches()
211
212 early_stop_results: CharsetMatches = CharsetMatches()
213
214 sig_encoding, sig_payload = identify_sig_or_bom(sequences)
215
216 if sig_encoding is not None:
217 prioritized_encodings.insert(0, sig_encoding)
218 logger.log(
219 TRACE,
220 "Detected a SIG or BOM mark on first %i byte(s). Priority +1 given for %s.",
221 len(sig_payload),
222 sig_encoding,
223 )
224
225 prioritized_encodings.append("ascii")
226
227 if "utf_8" not in prioritized_encodings:
228 prioritized_encodings.append("utf_8")
229
230 for encoding_iana in prioritized_encodings + IANA_SUPPORTED_MB_FIRST:
231 if cp_isolation and encoding_iana not in cp_isolation:
232 continue
233
234 if cp_exclusion and encoding_iana in cp_exclusion:
235 continue
236
237 if encoding_iana in tested:
238 continue
239
240 tested.add(encoding_iana)
241
242 decoded_payload: str | None = None
243 bom_or_sig_available: bool = sig_encoding == encoding_iana
244 strip_sig_or_bom: bool = bom_or_sig_available and should_strip_sig_or_bom(
245 encoding_iana
246 )
247
248 if encoding_iana in {"utf_16", "utf_32"} and not bom_or_sig_available:
249 logger.log(
250 TRACE,
251 "Encoding %s won't be tested as-is because it require a BOM. Will try some sub-encoder LE/BE.",
252 encoding_iana,
253 )
254 continue
255 if encoding_iana in {"utf_7"} and not bom_or_sig_available:
256 logger.log(
257 TRACE,
258 "Encoding %s won't be tested as-is because detection is unreliable without BOM/SIG.",
259 encoding_iana,
260 )
261 continue
262
263 # Skip encodings similar to ones that already soft-failed (high mess ratio).
264 # Checked BEFORE the expensive decode attempt.
265 if encoding_iana in soft_failure_skip:
266 logger.log(
267 TRACE,
268 "%s is deemed too similar to a code page that was already considered unsuited. Continuing!",
269 encoding_iana,
270 )
271 continue
272
273 try:
274 is_multi_byte_decoder: bool = is_multi_byte_encoding(encoding_iana)
275 except (ModuleNotFoundError, ImportError): # Defensive:
276 logger.log(
277 TRACE,
278 "Encoding %s does not provide an IncrementalDecoder",
279 encoding_iana,
280 )
281 continue
282
283 # When we've already found a definitive match (chaos=0.0 with good coherence)
284 # after testing the prioritized encodings, skip encodings that target
285 # completely different language families. This avoids running expensive
286 # mess_ratio + coherence_ratio on clearly unrelated candidates (e.g., Cyrillic
287 # when the definitive match is Latin-based).
288 if definitive_match_found:
289 if not is_multi_byte_decoder:
290 enc_languages = set(encoding_languages(encoding_iana))
291 else:
292 enc_languages = set(mb_encoding_languages(encoding_iana))
293 if not enc_languages.intersection(definitive_target_languages):
294 logger.log(
295 TRACE,
296 "Skipping %s: definitive match already found, this encoding targets different languages (%s vs %s).",
297 encoding_iana,
298 enc_languages,
299 definitive_target_languages,
300 )
301 continue
302
303 # After the definitive match, cap the number of additional same-family
304 # single-byte encodings that pass chaos probing. This avoids testing the
305 # tail of rare, low-value same-family encodings (mac_iceland, cp860, etc.)
306 # that almost never change best() but each cost ~1-2ms of mess_ratio + coherence.
307 if (
308 definitive_match_found
309 and not is_multi_byte_decoder
310 and post_definitive_sb_success_count >= POST_DEFINITIVE_SB_CAP
311 ):
312 logger.log(
313 TRACE,
314 "Skipping %s: already accumulated %d same-family results after definitive match (cap=%d).",
315 encoding_iana,
316 post_definitive_sb_success_count,
317 POST_DEFINITIVE_SB_CAP,
318 )
319 continue
320
321 # When a multibyte encoding with significant multibyte content has already
322 # passed chaos probing, skip all single-byte encodings. They will either fail
323 # chaos probing (wasting mess_ratio time) or produce inferior results.
324 if mb_definitive_match_found and not is_multi_byte_decoder:
325 logger.log(
326 TRACE,
327 "Skipping single-byte %s: multi-byte definitive match already found.",
328 encoding_iana,
329 )
330 continue
331
332 # Single-byte candidates of regular size defer the expensive whole
333 # payload decode until after chunk probing: single-byte codecs are
334 # stateless (1 byte == 1 char) so decoding chunk slices is provably
335 # identical to slicing the decoded payload, and candidates rejected
336 # by chaos probing (the common case) never pay the full decode nor
337 # the payload hash.
338 deferred_decoding: bool = (
339 not is_multi_byte_decoder and not is_too_large_sequence
340 )
341
342 try:
343 if is_too_large_sequence and not is_multi_byte_decoder:
344 str(
345 (
346 sequences[: int(50e4)]
347 if not strip_sig_or_bom
348 else sequences[len(sig_payload) : int(50e4)]
349 ),
350 encoding=encoding_iana,
351 )
352 elif not deferred_decoding:
353 # UTF-7 BOM is encoded in modified Base64 whose byte boundary
354 # can overlap with the next character. Stripping raw SIG bytes
355 # before decoding may leave stray bytes that decode as garbage.
356 # Decode the full sequence and remove the leading BOM char instead.
357 # see https://github.com/jawah/charset_normalizer/issues/718
358 # and https://github.com/jawah/charset_normalizer/issues/716
359 if encoding_iana == "utf_7" and bom_or_sig_available:
360 decoded_payload = str(
361 sequences,
362 encoding=encoding_iana,
363 )
364 if decoded_payload and decoded_payload[0] == "\ufeff":
365 decoded_payload = decoded_payload[1:]
366 else:
367 decoded_payload = str(
368 (
369 sequences
370 if not strip_sig_or_bom
371 else sequences[len(sig_payload) :]
372 ),
373 encoding=encoding_iana,
374 )
375 except (UnicodeDecodeError, LookupError) as e:
376 if not isinstance(e, LookupError):
377 logger.log(
378 TRACE,
379 "Code page %s does not fit given bytes sequence at ALL. %s",
380 encoding_iana,
381 str(e),
382 )
383 tested_but_hard_failure.append(encoding_iana)
384 continue
385
386 r_ = range(
387 0 if not bom_or_sig_available else len(sig_payload),
388 length,
389 int(length / steps),
390 )
391
392 multi_byte_bonus: bool = (
393 is_multi_byte_decoder
394 and decoded_payload is not None
395 and len(decoded_payload) < length
396 )
397
398 if multi_byte_bonus:
399 logger.log(
400 TRACE,
401 "Code page %s is a multi byte encoding table and it appear that at least one character "
402 "was encoded using n-bytes.",
403 encoding_iana,
404 )
405
406 max_chunk_gave_up: int = int(len(r_) / 4)
407
408 max_chunk_gave_up = max(max_chunk_gave_up, 2)
409 early_stop_count: int = 0
410 lazy_str_hard_failure = False
411
412 md_chunks: list[str] = []
413 md_ratios = []
414
415 try:
416 for chunk in cut_sequence_chunks(
417 sequences,
418 encoding_iana,
419 r_,
420 chunk_size,
421 bom_or_sig_available,
422 strip_sig_or_bom,
423 sig_payload,
424 is_multi_byte_decoder,
425 decoded_payload,
426 deferred_decoding,
427 ):
428 md_chunks.append(chunk)
429
430 md_ratios.append(
431 cached_mess_ratio(
432 chunk,
433 threshold,
434 explain and 1 <= len(cp_isolation) <= 2,
435 )
436 )
437
438 if md_ratios[-1] >= threshold:
439 early_stop_count += 1
440
441 if (early_stop_count >= max_chunk_gave_up) or (
442 bom_or_sig_available and not strip_sig_or_bom
443 ):
444 break
445 except (
446 UnicodeDecodeError,
447 LookupError,
448 ) as e: # Lazy str loading may have missed something there
449 if deferred_decoding:
450 # Deferred single-byte validation failed on a chunk (or the
451 # codec is unavailable on this interpreter build): identical
452 # outcome and bookkeeping to the eager full-decode failure.
453 logger.log(
454 TRACE,
455 "Code page %s does not fit given bytes sequence at ALL. %s",
456 encoding_iana,
457 str(e),
458 )
459 tested_but_hard_failure.append(encoding_iana)
460 continue
461 logger.log(
462 TRACE,
463 "LazyStr Loading: After MD chunk decode, code page %s does not fit given bytes sequence at ALL. %s",
464 encoding_iana,
465 str(e),
466 )
467 early_stop_count = max_chunk_gave_up
468 lazy_str_hard_failure = True
469
470 mean_mess_ratio: float = sum(md_ratios) / len(md_ratios) if md_ratios else 0.0
471
472 # We might want to check the sequence again with the whole content,
473 # but only if initial MD tests passed.
474 if (
475 not lazy_str_hard_failure
476 and is_too_large_sequence
477 and not is_multi_byte_decoder
478 and mean_mess_ratio < threshold
479 and early_stop_count < max_chunk_gave_up
480 ):
481 try:
482 sequences[int(50e3) :].decode(encoding_iana, errors="strict")
483 except UnicodeDecodeError as e:
484 logger.log(
485 TRACE,
486 "LazyStr Loading: After final lookup, code page %s does not fit given bytes sequence at ALL. %s",
487 encoding_iana,
488 str(e),
489 )
490 tested_but_hard_failure.append(encoding_iana)
491 continue
492
493 if mean_mess_ratio >= threshold or early_stop_count >= max_chunk_gave_up:
494 tested_but_soft_failure.append(encoding_iana)
495 if encoding_iana in IANA_SUPPORTED_SIMILAR:
496 soft_failure_skip.update(IANA_SUPPORTED_SIMILAR[encoding_iana])
497 logger.log(
498 TRACE,
499 "%s was excluded because of initial chaos probing. Gave up %i time(s). "
500 "Computed mean chaos is %f %%.",
501 encoding_iana,
502 early_stop_count,
503 round(mean_mess_ratio * 100, ndigits=3),
504 )
505 # Preparing those fallbacks in case we got nothing.
506 if (
507 enable_fallback
508 and encoding_iana
509 in ["ascii", "utf_8", specified_encoding, "utf_16", "utf_32"]
510 and not lazy_str_hard_failure
511 ):
512 # Always fully decode payload before.
513 # We've missed a UnicodeDecodeError proof
514 # while issuing release 3.4.8
515 # see https://github.com/jawah/charset_normalizer/issues/771
516 if decoded_payload is None:
517 try:
518 decoded_payload = str(
519 (
520 sequences
521 if not strip_sig_or_bom
522 else sequences[len(sig_payload) :]
523 ),
524 encoding=encoding_iana,
525 )
526 except (UnicodeDecodeError, LookupError):
527 logger.log(
528 TRACE,
529 "%s does not decode the whole payload: fallback entry withheld.",
530 encoding_iana,
531 )
532 continue
533 if is_too_large_sequence:
534 # Don't retain huge payload in RAM.
535 decoded_payload = None
536
537 fallback_entry = CharsetMatch(
538 sequences,
539 encoding_iana,
540 threshold,
541 bom_or_sig_available,
542 [],
543 decoded_payload,
544 preemptive_declaration=specified_encoding,
545 )
546 if encoding_iana == specified_encoding:
547 fallback_specified = fallback_entry
548 elif encoding_iana == "ascii":
549 fallback_ascii = fallback_entry
550 else:
551 fallback_u8 = fallback_entry
552 continue
553
554 if deferred_decoding:
555 # The candidate passed chaos probing: perform the whole payload
556 # decode (validation + payload reuse) that was deferred earlier.
557 try:
558 decoded_payload = str(
559 (
560 sequences
561 if not strip_sig_or_bom
562 else sequences[len(sig_payload) :]
563 ),
564 encoding=encoding_iana,
565 )
566 except (UnicodeDecodeError, LookupError) as e:
567 logger.log(
568 TRACE,
569 "Code page %s does not fit given bytes sequence at ALL. %s",
570 encoding_iana,
571 str(e),
572 )
573 tested_but_hard_failure.append(encoding_iana)
574 continue
575
576 logger.log(
577 TRACE,
578 "%s passed initial chaos probing. Mean measured chaos is %f %%",
579 encoding_iana,
580 round(mean_mess_ratio * 100, ndigits=3),
581 )
582
583 if not is_multi_byte_decoder:
584 target_languages: list[str] = encoding_languages(encoding_iana)
585 else:
586 target_languages = mb_encoding_languages(encoding_iana)
587
588 if target_languages:
589 logger.log(
590 TRACE,
591 "%s should target any language(s) of %s",
592 encoding_iana,
593 target_languages,
594 )
595
596 cd_ratios = []
597
598 # Run coherence detection on all chunks. We previously tried limiting to
599 # 1-2 chunks for post-definitive encodings to save time, but this caused
600 # coverage regressions by producing unrepresentative coherence scores.
601 # The SB cap and language-family skip optimizations provide sufficient
602 # speedup without sacrificing coherence accuracy.
603 if encoding_iana != "ascii":
604 # We shall skip the CD when its about ASCII
605 # Most of the time its not relevant to run "language-detection" on it.
606 lg_inclusion: str | None = (
607 ",".join(target_languages) if target_languages else None
608 )
609
610 for chunk in md_chunks:
611 chunk_languages = cached_coherence_ratio(
612 chunk,
613 language_threshold,
614 lg_inclusion,
615 )
616
617 cd_ratios.append(chunk_languages)
618
619 cd_ratios_merged = merge_coherence_ratios(cd_ratios)
620
621 if cd_ratios_merged:
622 logger.log(
623 TRACE,
624 "We detected language %s using %s",
625 cd_ratios_merged,
626 encoding_iana,
627 )
628
629 current_match = CharsetMatch(
630 sequences,
631 encoding_iana,
632 mean_mess_ratio,
633 bom_or_sig_available,
634 cd_ratios_merged,
635 (
636 decoded_payload
637 if (
638 not is_too_large_sequence
639 or encoding_iana in [specified_encoding, "ascii", "utf_8"]
640 )
641 else None
642 ),
643 preemptive_declaration=specified_encoding,
644 )
645
646 results.append(current_match)
647
648 # Count post-definitive same-family SB successes for the early termination cap.
649 # Only count low-mess encodings (< 2%) toward the cap. High-mess encodings are
650 # marginal results that shouldn't prevent better-quality candidates from being
651 # tested. For example, iso8859_4 (mess=0%) should not be skipped just because
652 # 7 high-mess Latin encodings (cp1252 at 8%, etc.) were tried first.
653 if (
654 definitive_match_found
655 and not is_multi_byte_decoder
656 and mean_mess_ratio < 0.02
657 ):
658 post_definitive_sb_success_count += 1
659
660 if (
661 encoding_iana in [specified_encoding, "ascii", "utf_8"]
662 and mean_mess_ratio < 0.1
663 ):
664 # If md says nothing to worry about, then... stop immediately!
665 if mean_mess_ratio == 0.0:
666 logger.debug(
667 "Encoding detection: %s is most likely the one.",
668 current_match.encoding,
669 )
670 if explain: # Defensive: ensure exit path clean handler
671 logger.removeHandler(explain_handler)
672 logger.setLevel(previous_logger_level)
673 return CharsetMatches([current_match])
674
675 early_stop_results.append(current_match)
676
677 if (
678 len(early_stop_results)
679 and (specified_encoding is None or specified_encoding in tested)
680 and "ascii" in tested
681 and "utf_8" in tested
682 ):
683 probable_result = early_stop_results.best()
684 assert probable_result is not None
685 logger.debug(
686 "Encoding detection: %s is most likely the one.",
687 probable_result.encoding,
688 )
689 if explain: # Defensive: ensure exit path clean handler
690 logger.removeHandler(explain_handler)
691 logger.setLevel(previous_logger_level)
692
693 return CharsetMatches([probable_result])
694
695 # Once we find a result with good coherence (>= 0.5) after testing the
696 # prioritized encodings (ascii, utf_8), activate "definitive mode": skip
697 # encodings that target completely different language families. This avoids
698 # running expensive mess_ratio + coherence_ratio on clearly unrelated
699 # candidates (e.g., Cyrillic encodings when the match is Latin-based).
700 # We require coherence >= 0.5 to avoid false positives (e.g., cp1251 decoding
701 # Hebrew text with 0.0 chaos but wrong language detection at coherence 0.33).
702 if not definitive_match_found and not is_multi_byte_decoder:
703 best_coherence = (
704 max((v for _, v in cd_ratios_merged), default=0.0)
705 if cd_ratios_merged
706 else 0.0
707 )
708 if best_coherence >= 0.5 and "ascii" in tested and "utf_8" in tested:
709 definitive_match_found = True
710 definitive_target_languages.update(target_languages)
711 logger.log(
712 TRACE,
713 "Definitive match found: %s (chaos=%.3f, coherence=%.2f). Encodings targeting different language families will be skipped.",
714 encoding_iana,
715 mean_mess_ratio,
716 best_coherence,
717 )
718
719 # When a non-UTF multibyte encoding passes chaos probing with significant
720 # multibyte content (decoded < 98% of raw), activate mb_definitive_match.
721 # This skips all remaining single-byte encodings which would either soft-fail
722 # (running expensive mess_ratio for nothing) or produce inferior results.
723 if (
724 not mb_definitive_match_found
725 and is_multi_byte_decoder
726 and multi_byte_bonus
727 and decoded_payload is not None
728 and len(decoded_payload) < length * 0.98
729 and encoding_iana
730 not in {
731 "utf_8",
732 "utf_8_sig",
733 "utf_16",
734 "utf_16_be",
735 "utf_16_le",
736 "utf_32",
737 "utf_32_be",
738 "utf_32_le",
739 "utf_7",
740 }
741 and "ascii" in tested
742 and "utf_8" in tested
743 ):
744 mb_definitive_match_found = True
745 logger.log(
746 TRACE,
747 "Multi-byte definitive match: %s (chaos=%.3f, decoded=%d/%d=%.1f%%). Single-byte encodings will be skipped.",
748 encoding_iana,
749 mean_mess_ratio,
750 len(decoded_payload),
751 length,
752 len(decoded_payload) / length * 100,
753 )
754
755 if encoding_iana == sig_encoding:
756 logger.debug(
757 "Encoding detection: %s is most likely the one as we detected a BOM or SIG within "
758 "the beginning of the sequence.",
759 encoding_iana,
760 )
761 if explain: # Defensive: ensure exit path clean handler
762 logger.removeHandler(explain_handler)
763 logger.setLevel(previous_logger_level)
764 return CharsetMatches([results[encoding_iana]])
765
766 if len(results) == 0:
767 if fallback_u8 or fallback_ascii or fallback_specified:
768 logger.log(
769 TRACE,
770 "Nothing got out of the detection process. Using ASCII/UTF-8/Specified fallback.",
771 )
772
773 if fallback_specified:
774 logger.debug(
775 "Encoding detection: %s will be used as a fallback match",
776 fallback_specified.encoding,
777 )
778 results.append(fallback_specified)
779 elif (
780 (fallback_u8 and fallback_ascii is None)
781 or (
782 fallback_u8
783 and fallback_ascii
784 and fallback_u8.fingerprint != fallback_ascii.fingerprint
785 )
786 or (fallback_u8 is not None)
787 ):
788 logger.debug("Encoding detection: utf_8 will be used as a fallback match")
789 results.append(fallback_u8)
790 elif fallback_ascii:
791 logger.debug("Encoding detection: ascii will be used as a fallback match")
792 results.append(fallback_ascii)
793
794 if results:
795 logger.debug(
796 "Encoding detection: Found %s as plausible (best-candidate) for content. With %i alternatives.",
797 results.best().encoding, # type: ignore
798 len(results) - 1,
799 )
800 else:
801 logger.debug("Encoding detection: Unable to determine any suitable charset.")
802
803 if explain:
804 logger.removeHandler(explain_handler)
805 logger.setLevel(previous_logger_level)
806
807 return results
808
809
810def from_fp(
811 fp: BinaryIO,
812 steps: int = 5,
813 chunk_size: int = 512,
814 threshold: float = 0.20,
815 cp_isolation: list[str] | None = None,
816 cp_exclusion: list[str] | None = None,
817 preemptive_behaviour: bool = True,
818 explain: bool = False,
819 language_threshold: float = 0.1,
820 enable_fallback: bool = True,
821) -> CharsetMatches:
822 """
823 Same thing than the function from_bytes but using a file pointer that is already ready.
824 Will not close the file pointer.
825 """
826 return from_bytes(
827 fp.read(),
828 steps,
829 chunk_size,
830 threshold,
831 cp_isolation,
832 cp_exclusion,
833 preemptive_behaviour,
834 explain,
835 language_threshold,
836 enable_fallback,
837 )
838
839
840def from_path(
841 path: str | bytes | PathLike, # type: ignore[type-arg]
842 steps: int = 5,
843 chunk_size: int = 512,
844 threshold: float = 0.20,
845 cp_isolation: list[str] | None = None,
846 cp_exclusion: list[str] | None = None,
847 preemptive_behaviour: bool = True,
848 explain: bool = False,
849 language_threshold: float = 0.1,
850 enable_fallback: bool = True,
851) -> CharsetMatches:
852 """
853 Same thing than the function from_bytes but with one extra step. Opening and reading given file path in binary mode.
854 Can raise IOError.
855 """
856 with open(path, "rb") as fp:
857 return from_fp(
858 fp,
859 steps,
860 chunk_size,
861 threshold,
862 cp_isolation,
863 cp_exclusion,
864 preemptive_behaviour,
865 explain,
866 language_threshold,
867 enable_fallback,
868 )
869
870
871def is_binary(
872 fp_or_path_or_payload: PathLike | str | BinaryIO | bytes, # type: ignore[type-arg]
873 steps: int = 5,
874 chunk_size: int = 512,
875 threshold: float = 0.20,
876 cp_isolation: list[str] | None = None,
877 cp_exclusion: list[str] | None = None,
878 preemptive_behaviour: bool = True,
879 explain: bool = False,
880 language_threshold: float = 0.1,
881 enable_fallback: bool = False,
882) -> bool:
883 """
884 Detect if the given input (file, bytes, or path) points to a binary file. aka. not a string.
885 Based on the same main heuristic algorithms and default kwargs at the sole exception that fallbacks match
886 are disabled to be stricter around ASCII-compatible but unlikely to be a string.
887 """
888 if isinstance(fp_or_path_or_payload, (str, PathLike)):
889 guesses = from_path(
890 fp_or_path_or_payload,
891 steps=steps,
892 chunk_size=chunk_size,
893 threshold=threshold,
894 cp_isolation=cp_isolation,
895 cp_exclusion=cp_exclusion,
896 preemptive_behaviour=preemptive_behaviour,
897 explain=explain,
898 language_threshold=language_threshold,
899 enable_fallback=enable_fallback,
900 )
901 elif isinstance(
902 fp_or_path_or_payload,
903 (
904 bytes,
905 bytearray,
906 ),
907 ):
908 guesses = from_bytes(
909 fp_or_path_or_payload,
910 steps=steps,
911 chunk_size=chunk_size,
912 threshold=threshold,
913 cp_isolation=cp_isolation,
914 cp_exclusion=cp_exclusion,
915 preemptive_behaviour=preemptive_behaviour,
916 explain=explain,
917 language_threshold=language_threshold,
918 enable_fallback=enable_fallback,
919 )
920 else:
921 guesses = from_fp(
922 fp_or_path_or_payload,
923 steps=steps,
924 chunk_size=chunk_size,
925 threshold=threshold,
926 cp_isolation=cp_isolation,
927 cp_exclusion=cp_exclusion,
928 preemptive_behaviour=preemptive_behaviour,
929 explain=explain,
930 language_threshold=language_threshold,
931 enable_fallback=enable_fallback,
932 )
933
934 return not guesses