util.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """
  2. babel.util
  3. ~~~~~~~~~~
  4. Various utility classes and functions.
  5. :copyright: (c) 2013-2026 by the Babel Team.
  6. :license: BSD, see LICENSE for more details.
  7. """
  8. from __future__ import annotations
  9. import codecs
  10. import datetime
  11. import os
  12. import re
  13. import textwrap
  14. import warnings
  15. from collections.abc import Generator, Iterable
  16. from typing import IO, Any, TypeVar
  17. from babel import dates, localtime
  18. missing = object()
  19. _T = TypeVar("_T")
  20. def distinct(iterable: Iterable[_T]) -> Generator[_T, None, None]:
  21. """Yield all items in an iterable collection that are distinct.
  22. Unlike when using sets for a similar effect, the original ordering of the
  23. items in the collection is preserved by this function.
  24. >>> print(list(distinct([1, 2, 1, 3, 4, 4])))
  25. [1, 2, 3, 4]
  26. >>> print(list(distinct('foobar')))
  27. ['f', 'o', 'b', 'a', 'r']
  28. :param iterable: the iterable collection providing the data
  29. """
  30. seen = set()
  31. for item in iter(iterable):
  32. if item not in seen:
  33. yield item
  34. seen.add(item)
  35. # Regexp to match python magic encoding line
  36. PYTHON_MAGIC_COMMENT_re = re.compile(
  37. rb'[ \t\f]* \# .* coding[=:][ \t]*([-\w.]+)',
  38. flags=re.VERBOSE,
  39. )
  40. def parse_encoding(fp: IO[bytes]) -> str | None:
  41. """Deduce the encoding of a source file from magic comment.
  42. It does this in the same way as the `Python interpreter`__
  43. .. __: https://docs.python.org/3.4/reference/lexical_analysis.html#encoding-declarations
  44. The ``fp`` argument should be a seekable file object.
  45. (From Jeff Dairiki)
  46. """
  47. pos = fp.tell()
  48. fp.seek(0)
  49. try:
  50. line1 = fp.readline()
  51. has_bom = line1.startswith(codecs.BOM_UTF8)
  52. if has_bom:
  53. line1 = line1[len(codecs.BOM_UTF8) :]
  54. m = PYTHON_MAGIC_COMMENT_re.match(line1)
  55. if not m:
  56. try:
  57. import ast
  58. ast.parse(line1.decode('latin-1'))
  59. except (ImportError, SyntaxError, UnicodeEncodeError):
  60. # Either it's a real syntax error, in which case the source is
  61. # not valid python source, or line2 is a continuation of line1,
  62. # in which case we don't want to scan line2 for a magic
  63. # comment.
  64. pass
  65. else:
  66. line2 = fp.readline()
  67. m = PYTHON_MAGIC_COMMENT_re.match(line2)
  68. if has_bom:
  69. if m:
  70. magic_comment_encoding = m.group(1).decode('latin-1')
  71. if magic_comment_encoding != 'utf-8':
  72. raise SyntaxError(f"encoding problem: {magic_comment_encoding} with BOM")
  73. return 'utf-8'
  74. elif m:
  75. return m.group(1).decode('latin-1')
  76. else:
  77. return None
  78. finally:
  79. fp.seek(pos)
  80. PYTHON_FUTURE_IMPORT_re = re.compile(r'from\s+__future__\s+import\s+\(*(.+)\)*')
  81. def parse_future_flags(fp: IO[bytes], encoding: str = 'latin-1') -> int:
  82. """Parse the compiler flags by :mod:`__future__` from the given Python
  83. code.
  84. """
  85. import __future__
  86. pos = fp.tell()
  87. fp.seek(0)
  88. flags = 0
  89. try:
  90. body = fp.read().decode(encoding)
  91. # Fix up the source to be (hopefully) parsable by regexpen.
  92. # This will likely do untoward things if the source code itself is broken.
  93. # (1) Fix `import (\n...` to be `import (...`.
  94. body = re.sub(r'import\s*\([\r\n]+', 'import (', body)
  95. # (2) Join line-ending commas with the next line.
  96. body = re.sub(r',\s*[\r\n]+', ', ', body)
  97. # (3) Remove backslash line continuations.
  98. body = re.sub(r'\\\s*[\r\n]+', ' ', body)
  99. for m in PYTHON_FUTURE_IMPORT_re.finditer(body):
  100. names = [x.strip().strip('()') for x in m.group(1).split(',')]
  101. for name in names:
  102. feature = getattr(__future__, name, None)
  103. if feature:
  104. flags |= feature.compiler_flag
  105. finally:
  106. fp.seek(pos)
  107. return flags
  108. def pathmatch(pattern: str, filename: str) -> bool:
  109. """Extended pathname pattern matching.
  110. This function is similar to what is provided by the ``fnmatch`` module in
  111. the Python standard library, but:
  112. * can match complete (relative or absolute) path names, and not just file
  113. names, and
  114. * also supports a convenience pattern ("**") to match files at any
  115. directory level.
  116. Examples:
  117. >>> pathmatch('**.py', 'bar.py')
  118. True
  119. >>> pathmatch('**.py', 'foo/bar/baz.py')
  120. True
  121. >>> pathmatch('**.py', 'templates/index.html')
  122. False
  123. >>> pathmatch('./foo/**.py', 'foo/bar/baz.py')
  124. True
  125. >>> pathmatch('./foo/**.py', 'bar/baz.py')
  126. False
  127. >>> pathmatch('^foo/**.py', 'foo/bar/baz.py')
  128. True
  129. >>> pathmatch('^foo/**.py', 'bar/baz.py')
  130. False
  131. >>> pathmatch('**/templates/*.html', 'templates/index.html')
  132. True
  133. >>> pathmatch('**/templates/*.html', 'templates/foo/bar.html')
  134. False
  135. :param pattern: the glob pattern
  136. :param filename: the path name of the file to match against
  137. """
  138. symbols = {
  139. '?': '[^/]',
  140. '?/': '[^/]/',
  141. '*': '[^/]+',
  142. '*/': '[^/]+/',
  143. '**/': '(?:.+/)*?',
  144. '**': '(?:.+/)*?[^/]+',
  145. }
  146. if pattern.startswith('^'):
  147. buf = ['^']
  148. pattern = pattern[1:]
  149. elif pattern.startswith('./'):
  150. buf = ['^']
  151. pattern = pattern[2:]
  152. else:
  153. buf = []
  154. for idx, part in enumerate(re.split('([?*]+/?)', pattern)):
  155. if idx % 2:
  156. buf.append(symbols[part])
  157. elif part:
  158. buf.append(re.escape(part))
  159. match = re.match(f"{''.join(buf)}$", filename.replace(os.sep, "/"))
  160. return match is not None
  161. class TextWrapper(textwrap.TextWrapper):
  162. wordsep_re = re.compile(
  163. r'(\s+|' # any whitespace
  164. r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w))', # em-dash
  165. )
  166. # e.g. '\u2068foo bar.py\u2069:42'
  167. _enclosed_filename_re = re.compile(r'(\u2068[^\u2068]+?\u2069(?::-?\d+)?)')
  168. def _split(self, text):
  169. """Splits the text into indivisible chunks while ensuring that file names
  170. containing spaces are not broken up.
  171. """
  172. enclosed_filename_start = '\u2068'
  173. if enclosed_filename_start not in text:
  174. # There are no file names which contain spaces, fallback to the default implementation
  175. return super()._split(text)
  176. chunks = []
  177. for chunk in re.split(self._enclosed_filename_re, text):
  178. if chunk.startswith(enclosed_filename_start):
  179. chunks.append(chunk)
  180. else:
  181. chunks.extend(super()._split(chunk))
  182. return [c for c in chunks if c]
  183. def wraptext(
  184. text: str,
  185. width: int = 70,
  186. initial_indent: str = '',
  187. subsequent_indent: str = '',
  188. ) -> list[str]:
  189. """Simple wrapper around the ``textwrap.wrap`` function in the standard
  190. library. This version does not wrap lines on hyphens in words. It also
  191. does not wrap PO file locations containing spaces.
  192. :param text: the text to wrap
  193. :param width: the maximum line width
  194. :param initial_indent: string that will be prepended to the first line of
  195. wrapped output
  196. :param subsequent_indent: string that will be prepended to all lines save
  197. the first of wrapped output
  198. """
  199. warnings.warn(
  200. "`babel.util.wraptext` is deprecated and will be removed in a future version of Babel. "
  201. "If you need this functionality, use the `babel.util.TextWrapper` class directly.",
  202. DeprecationWarning,
  203. stacklevel=2,
  204. )
  205. return TextWrapper(
  206. width=width,
  207. initial_indent=initial_indent,
  208. subsequent_indent=subsequent_indent,
  209. break_long_words=False,
  210. ).wrap(text)
  211. # TODO (Babel 3.x): Remove this re-export
  212. odict = dict
  213. class FixedOffsetTimezone(datetime.tzinfo):
  214. """
  215. Fixed offset in minutes east from UTC.
  216. DEPRECATED: Use the standard library `datetime.timezone` instead.
  217. """
  218. # TODO (Babel 3.x): Remove this class
  219. def __init__(self, offset: float, name: str | None = None) -> None:
  220. warnings.warn(
  221. "`FixedOffsetTimezone` is deprecated and will be removed in a future version of Babel. "
  222. "Use the standard library `datetime.timezone` class.",
  223. DeprecationWarning,
  224. stacklevel=2,
  225. )
  226. self._offset = datetime.timedelta(minutes=offset)
  227. if name is None:
  228. name = 'Etc/GMT%+d' % offset
  229. self.zone = name
  230. def __str__(self) -> str:
  231. return self.zone
  232. def __repr__(self) -> str:
  233. return f'<FixedOffset "{self.zone}" {self._offset}>'
  234. def utcoffset(self, dt: datetime.datetime) -> datetime.timedelta:
  235. return self._offset
  236. def tzname(self, dt: datetime.datetime) -> str:
  237. return self.zone
  238. def dst(self, dt: datetime.datetime) -> datetime.timedelta:
  239. return ZERO
  240. # Export the localtime functionality here because that's
  241. # where it was in the past.
  242. # TODO(3.0): remove these aliases
  243. UTC = dates.UTC
  244. LOCALTZ = dates.LOCALTZ
  245. get_localzone = localtime.get_localzone
  246. STDOFFSET = localtime.STDOFFSET
  247. DSTOFFSET = localtime.DSTOFFSET
  248. DSTDIFF = localtime.DSTDIFF
  249. ZERO = localtime.ZERO
  250. def _cmp(a: Any, b: Any):
  251. return (a > b) - (a < b)