utils.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. from __future__ import annotations
  2. import importlib
  3. import logging
  4. import unicodedata
  5. from bisect import bisect_right
  6. from codecs import IncrementalDecoder
  7. from encodings.aliases import aliases
  8. from functools import lru_cache
  9. from re import findall
  10. from typing import Generator
  11. from .constant import (
  12. ENCODING_MARKS,
  13. IANA_SUPPORTED_SIMILAR,
  14. RE_POSSIBLE_ENCODING_INDICATION,
  15. UNICODE_RANGES_COMBINED,
  16. _SECONDARY_RANGE_NAMES,
  17. UTF8_MAXIMAL_ALLOCATION,
  18. COMMON_CJK_CHARACTERS,
  19. _LATIN,
  20. _CJK,
  21. _HANGUL,
  22. _KATAKANA,
  23. _HIRAGANA,
  24. _THAI,
  25. _ARABIC,
  26. _ARABIC_ISOLATED_FORM,
  27. _ACCENT_KEYWORDS,
  28. _ACCENTUATED,
  29. )
  30. def _character_flags(character: str) -> int:
  31. """Compute all name-based classification flags with a single unicodedata.name() call."""
  32. try:
  33. desc: str = unicodedata.name(character)
  34. except ValueError:
  35. return 0
  36. flags: int = 0
  37. if "LATIN" in desc:
  38. flags |= _LATIN
  39. if "CJK" in desc:
  40. flags |= _CJK
  41. if "HANGUL" in desc:
  42. flags |= _HANGUL
  43. if "KATAKANA" in desc:
  44. flags |= _KATAKANA
  45. if "HIRAGANA" in desc:
  46. flags |= _HIRAGANA
  47. if "THAI" in desc:
  48. flags |= _THAI
  49. if "ARABIC" in desc:
  50. flags |= _ARABIC
  51. if "ISOLATED FORM" in desc:
  52. flags |= _ARABIC_ISOLATED_FORM
  53. for kw in _ACCENT_KEYWORDS:
  54. if kw in desc:
  55. flags |= _ACCENTUATED
  56. break
  57. return flags
  58. def is_accentuated(character: str) -> bool:
  59. return bool(_character_flags(character) & _ACCENTUATED)
  60. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  61. def remove_accent(character: str) -> str:
  62. decomposed: str = unicodedata.decomposition(character)
  63. if not decomposed:
  64. return character
  65. codes: list[str] = decomposed.split(" ")
  66. return chr(int(codes[0], 16))
  67. # Pre-built sorted lookup table for O(log n) binary search in unicode_range().
  68. # Each entry is (range_start, range_end_exclusive, range_name).
  69. _UNICODE_RANGES_SORTED: list[tuple[int, int, str]] = sorted(
  70. (ord_range.start, ord_range.stop, name)
  71. for name, ord_range in UNICODE_RANGES_COMBINED.items()
  72. )
  73. _UNICODE_RANGE_STARTS: list[int] = [e[0] for e in _UNICODE_RANGES_SORTED]
  74. def unicode_range(character: str) -> str | None:
  75. """
  76. Retrieve the Unicode range official name from a single character.
  77. """
  78. character_ord: int = ord(character)
  79. # Binary search: find the rightmost range whose start <= character_ord
  80. idx = bisect_right(_UNICODE_RANGE_STARTS, character_ord) - 1
  81. if idx >= 0:
  82. start, stop, name = _UNICODE_RANGES_SORTED[idx]
  83. if character_ord < stop:
  84. return name
  85. return None
  86. def is_latin(character: str) -> bool:
  87. return bool(_character_flags(character) & _LATIN)
  88. def is_punctuation(character: str) -> bool:
  89. character_category: str = unicodedata.category(character)
  90. if "P" in character_category:
  91. return True
  92. character_range: str | None = unicode_range(character)
  93. if character_range is None:
  94. return False
  95. return "Punctuation" in character_range
  96. def is_symbol(character: str) -> bool:
  97. character_category: str = unicodedata.category(character)
  98. if "S" in character_category or "N" in character_category:
  99. return True
  100. character_range: str | None = unicode_range(character)
  101. if character_range is None:
  102. return False
  103. return "Forms" in character_range and character_category != "Lo"
  104. def is_emoticon(character: str) -> bool:
  105. character_range: str | None = unicode_range(character)
  106. if character_range is None:
  107. return False
  108. return "Emoticons" in character_range or "Pictographs" in character_range
  109. def is_separator(character: str) -> bool:
  110. if character.isspace() or character in {"|", "+", "<", ">"}:
  111. return True
  112. character_category: str = unicodedata.category(character)
  113. return "Z" in character_category or character_category in {"Po", "Pd", "Pc"}
  114. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  115. def is_case_variable(character: str) -> bool:
  116. return character.islower() != character.isupper()
  117. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  118. def is_cjk(character: str) -> bool:
  119. return bool(_character_flags(character) & _CJK)
  120. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  121. def is_hiragana(character: str) -> bool:
  122. return bool(_character_flags(character) & _HIRAGANA)
  123. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  124. def is_katakana(character: str) -> bool:
  125. return bool(_character_flags(character) & _KATAKANA)
  126. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  127. def is_hangul(character: str) -> bool:
  128. return bool(_character_flags(character) & _HANGUL)
  129. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  130. def is_thai(character: str) -> bool:
  131. return bool(_character_flags(character) & _THAI)
  132. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  133. def is_arabic(character: str) -> bool:
  134. return bool(_character_flags(character) & _ARABIC)
  135. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  136. def is_arabic_isolated_form(character: str) -> bool:
  137. return bool(_character_flags(character) & _ARABIC_ISOLATED_FORM)
  138. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  139. def is_cjk_uncommon(character: str) -> bool:
  140. return character not in COMMON_CJK_CHARACTERS
  141. def is_unicode_range_secondary(range_name: str) -> bool:
  142. return range_name in _SECONDARY_RANGE_NAMES
  143. @lru_cache(maxsize=UTF8_MAXIMAL_ALLOCATION)
  144. def is_unprintable(character: str) -> bool:
  145. return (
  146. not character.isspace() # includes \n \t \r \v
  147. and not character.isprintable()
  148. and character != "\x1a" # Why? Its the ASCII substitute character.
  149. and character != "\ufeff" # bug discovered in Python,
  150. # Zero Width No-Break Space located in Arabic Presentation Forms-B, Unicode 1.1 not acknowledged as space.
  151. )
  152. def any_specified_encoding(
  153. sequence: bytes | bytearray, search_zone: int = 8192
  154. ) -> str | None:
  155. """
  156. Extract using ASCII-only decoder any specified encoding in the first n-bytes.
  157. """
  158. if not isinstance(sequence, (bytes, bytearray)):
  159. raise TypeError
  160. seq_len: int = len(sequence)
  161. decoded_zone: str = sequence[: min(seq_len, search_zone)].decode(
  162. "ascii", errors="ignore"
  163. )
  164. # Cheap literal pre-filter.
  165. lowered_zone: str = decoded_zone.lower()
  166. if "coding" not in lowered_zone and "charset" not in lowered_zone:
  167. return None
  168. results: list[str] = findall(
  169. RE_POSSIBLE_ENCODING_INDICATION,
  170. decoded_zone,
  171. )
  172. if len(results) == 0:
  173. return None
  174. for specified_encoding in results:
  175. specified_encoding = specified_encoding.lower().replace("-", "_")
  176. encoding_alias: str
  177. encoding_iana: str
  178. for encoding_alias, encoding_iana in aliases.items():
  179. if encoding_alias == specified_encoding:
  180. return encoding_iana
  181. if encoding_iana == specified_encoding:
  182. return encoding_iana
  183. return None
  184. @lru_cache(maxsize=128)
  185. def is_multi_byte_encoding(name: str) -> bool:
  186. """
  187. Verify is a specific encoding is a multi byte one based on it IANA name
  188. """
  189. if name in {
  190. "utf_8",
  191. "utf_8_sig",
  192. "utf_16",
  193. "utf_16_be",
  194. "utf_16_le",
  195. "utf_32",
  196. "utf_32_le",
  197. "utf_32_be",
  198. "utf_7",
  199. }:
  200. return True
  201. # Besides the Unicode family above, every multibyte codec shipped with
  202. # Python is implemented by _multibytecodec through exactly one of the six
  203. # cjkcodecs providers below. Probing those providers directly (getcodec)
  204. # classifies a name without importing its "encodings.<name>" module:
  205. # classifying the whole IANA_SUPPORTED list would otherwise import many
  206. # modules and dominate "import charset_normalizer" wall time.
  207. # see https://github.com/jawah/charset_normalizer/issues/742
  208. for provider in (
  209. "_codecs_cn",
  210. "_codecs_hk",
  211. "_codecs_iso2022",
  212. "_codecs_jp",
  213. "_codecs_kr",
  214. "_codecs_tw",
  215. ):
  216. try:
  217. importlib.import_module(provider).getcodec(name) # type: ignore[attr-defined]
  218. except (ImportError, AttributeError, LookupError): # Defensive: edge cases
  219. continue
  220. return True
  221. return False
  222. def identify_sig_or_bom(sequence: bytes | bytearray) -> tuple[str | None, bytes]:
  223. """
  224. Identify and extract SIG/BOM in given sequence.
  225. """
  226. for iana_encoding in ENCODING_MARKS:
  227. marks: bytes | list[bytes] = ENCODING_MARKS[iana_encoding]
  228. if isinstance(marks, bytes):
  229. marks = [marks]
  230. for mark in marks:
  231. if sequence.startswith(mark):
  232. return iana_encoding, mark
  233. return None, b""
  234. def should_strip_sig_or_bom(iana_encoding: str) -> bool:
  235. return iana_encoding not in {"utf_16", "utf_32"}
  236. def iana_name(cp_name: str, strict: bool = True) -> str:
  237. """Returns the Python normalized encoding name (Not the IANA official name)."""
  238. cp_name = cp_name.lower().replace("-", "_")
  239. encoding_alias: str
  240. encoding_iana: str
  241. for encoding_alias, encoding_iana in aliases.items():
  242. if cp_name in [encoding_alias, encoding_iana]:
  243. return encoding_iana
  244. if strict:
  245. raise ValueError(f"Unable to retrieve IANA for '{cp_name}'")
  246. return cp_name
  247. def cp_similarity(iana_name_a: str, iana_name_b: str) -> float:
  248. if is_multi_byte_encoding(iana_name_a) or is_multi_byte_encoding(iana_name_b):
  249. return 0.0
  250. decoder_a = importlib.import_module(f"encodings.{iana_name_a}").IncrementalDecoder
  251. decoder_b = importlib.import_module(f"encodings.{iana_name_b}").IncrementalDecoder
  252. id_a: IncrementalDecoder = decoder_a(errors="ignore")
  253. id_b: IncrementalDecoder = decoder_b(errors="ignore")
  254. character_match_count: int = 0
  255. for i in range(256):
  256. to_be_decoded: bytes = bytes([i])
  257. if id_a.decode(to_be_decoded) == id_b.decode(to_be_decoded):
  258. character_match_count += 1
  259. return character_match_count / 256
  260. def is_cp_similar(iana_name_a: str, iana_name_b: str) -> bool:
  261. """
  262. Determine if two code page are at least 80% similar. IANA_SUPPORTED_SIMILAR dict was generated using
  263. the function cp_similarity.
  264. """
  265. return (
  266. iana_name_a in IANA_SUPPORTED_SIMILAR
  267. and iana_name_b in IANA_SUPPORTED_SIMILAR[iana_name_a]
  268. )
  269. def set_logging_handler(
  270. name: str = "charset_normalizer",
  271. level: int = logging.INFO,
  272. format_string: str = "%(asctime)s | %(levelname)s | %(message)s",
  273. ) -> None:
  274. logger = logging.getLogger(name)
  275. logger.setLevel(level)
  276. handler = logging.StreamHandler()
  277. handler.setFormatter(logging.Formatter(format_string))
  278. logger.addHandler(handler)
  279. def cut_sequence_chunks(
  280. sequences: bytes | bytearray,
  281. encoding_iana: str,
  282. offsets: range,
  283. chunk_size: int,
  284. bom_or_sig_available: bool,
  285. strip_sig_or_bom: bool,
  286. sig_payload: bytes,
  287. is_multi_byte_decoder: bool,
  288. decoded_payload: str | None = None,
  289. deferred_decoding: bool = False,
  290. ) -> Generator[str, None, None]:
  291. if decoded_payload and not is_multi_byte_decoder:
  292. for i in offsets:
  293. chunk = decoded_payload[i : i + chunk_size]
  294. if not chunk:
  295. break
  296. yield chunk
  297. elif deferred_decoding:
  298. # Deferred single-byte probing: the whole payload is not decoded
  299. # yet. Single-byte codecs are stateless (1 byte == 1 char), hence
  300. # decode(base)[i:j] == decode(base[i:j]): slicing the raw bytes
  301. # yields exactly the chunks the branch above would have produced,
  302. # short trailing chunks included, and raises UnicodeDecodeError on
  303. # invalid bytes just like the whole-payload decode would.
  304. base_bytes = (
  305. sequences if not strip_sig_or_bom else sequences[len(sig_payload) :]
  306. )
  307. for i in offsets:
  308. cut_sequence = base_bytes[i : i + chunk_size]
  309. if not cut_sequence:
  310. break
  311. yield str(cut_sequence, encoding_iana)
  312. else:
  313. for i in offsets:
  314. chunk_end = i + chunk_size
  315. if chunk_end > len(sequences) + 8:
  316. continue
  317. cut_sequence = sequences[i : i + chunk_size]
  318. if bom_or_sig_available and not strip_sig_or_bom:
  319. cut_sequence = sig_payload + cut_sequence
  320. chunk = cut_sequence.decode(
  321. encoding_iana,
  322. errors="ignore" if is_multi_byte_decoder else "strict",
  323. )
  324. # multi-byte bad cutting detector and adjustment
  325. # not the cleanest way to perform that fix but clever enough for now.
  326. if is_multi_byte_decoder and i > 0:
  327. chunk_partial_size_chk: int = min(chunk_size, 16)
  328. if (
  329. decoded_payload
  330. and chunk[:chunk_partial_size_chk] not in decoded_payload
  331. ):
  332. for j in range(i, i - 4, -1):
  333. cut_sequence = sequences[j:chunk_end]
  334. if bom_or_sig_available and not strip_sig_or_bom:
  335. cut_sequence = sig_payload + cut_sequence
  336. chunk = cut_sequence.decode(encoding_iana, errors="ignore")
  337. if chunk[:chunk_partial_size_chk] in decoded_payload:
  338. break
  339. yield chunk