locale.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587
  1. # Copyright 2009 Facebook
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License"); you may
  4. # not use this file except in compliance with the License. You may obtain
  5. # a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
  11. # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
  12. # License for the specific language governing permissions and limitations
  13. # under the License.
  14. """Translation methods for generating localized strings.
  15. To load a locale and generate a translated string::
  16. user_locale = tornado.locale.get("es_LA")
  17. print(user_locale.translate("Sign out"))
  18. `tornado.locale.get()` returns the closest matching locale, not necessarily the
  19. specific locale you requested. You can support pluralization with
  20. additional arguments to `~Locale.translate()`, e.g.::
  21. people = [...]
  22. message = user_locale.translate(
  23. "%(list)s is online", "%(list)s are online", len(people))
  24. print(message % {"list": user_locale.list(people)})
  25. The first string is chosen if ``len(people) == 1``, otherwise the second
  26. string is chosen.
  27. Applications should call one of `load_translations` (which uses a simple
  28. CSV format) or `load_gettext_translations` (which uses the ``.mo`` format
  29. supported by `gettext` and related tools). If neither method is called,
  30. the `Locale.translate` method will simply return the original string.
  31. """
  32. import codecs
  33. import csv
  34. import datetime
  35. import gettext
  36. import glob
  37. import os
  38. import re
  39. from tornado import escape
  40. from tornado.log import gen_log
  41. from tornado._locale_data import LOCALE_NAMES
  42. from typing import Iterable, Any, Union, Dict, Optional
  43. _default_locale = "en_US"
  44. _translations = {} # type: Dict[str, Any]
  45. _supported_locales = frozenset([_default_locale])
  46. _use_gettext = False
  47. CONTEXT_SEPARATOR = "\x04"
  48. def get(*locale_codes: str) -> "Locale":
  49. """Returns the closest match for the given locale codes.
  50. We iterate over all given locale codes in order. If we have a tight
  51. or a loose match for the code (e.g., "en" for "en_US"), we return
  52. the locale. Otherwise we move to the next code in the list.
  53. By default we return ``en_US`` if no translations are found for any of
  54. the specified locales. You can change the default locale with
  55. `set_default_locale()`.
  56. """
  57. return Locale.get_closest(*locale_codes)
  58. def set_default_locale(code: str) -> None:
  59. """Sets the default locale.
  60. The default locale is assumed to be the language used for all strings
  61. in the system. The translations loaded from disk are mappings from
  62. the default locale to the destination locale. Consequently, you don't
  63. need to create a translation file for the default locale.
  64. """
  65. global _default_locale
  66. global _supported_locales
  67. _default_locale = code
  68. _supported_locales = frozenset(list(_translations.keys()) + [_default_locale])
  69. def load_translations(directory: str, encoding: Optional[str] = None) -> None:
  70. """Loads translations from CSV files in a directory.
  71. Translations are strings with optional Python-style named placeholders
  72. (e.g., ``My name is %(name)s``) and their associated translations.
  73. The directory should have translation files of the form ``LOCALE.csv``,
  74. e.g. ``es_GT.csv``. The CSV files should have two or three columns: string,
  75. translation, and an optional plural indicator. Plural indicators should
  76. be one of "plural" or "singular". A given string can have both singular
  77. and plural forms. For example ``%(name)s liked this`` may have a
  78. different verb conjugation depending on whether %(name)s is one
  79. name or a list of names. There should be two rows in the CSV file for
  80. that string, one with plural indicator "singular", and one "plural".
  81. For strings with no verbs that would change on translation, simply
  82. use "unknown" or the empty string (or don't include the column at all).
  83. The file is read using the `csv` module in the default "excel" dialect.
  84. In this format there should not be spaces after the commas.
  85. If no ``encoding`` parameter is given, the encoding will be
  86. detected automatically (among UTF-8 and UTF-16) if the file
  87. contains a byte-order marker (BOM), defaulting to UTF-8 if no BOM
  88. is present.
  89. Example translation ``es_LA.csv``::
  90. "I love you","Te amo"
  91. "%(name)s liked this","A %(name)s les gustó esto","plural"
  92. "%(name)s liked this","A %(name)s le gustó esto","singular"
  93. .. versionchanged:: 4.3
  94. Added ``encoding`` parameter. Added support for BOM-based encoding
  95. detection, UTF-16, and UTF-8-with-BOM.
  96. """
  97. global _translations
  98. global _supported_locales
  99. _translations = {}
  100. for path in os.listdir(directory):
  101. if not path.endswith(".csv"):
  102. continue
  103. locale, extension = path.split(".")
  104. if not re.match("[a-z]+(_[A-Z]+)?$", locale):
  105. gen_log.error(
  106. "Unrecognized locale %r (path: %s)",
  107. locale,
  108. os.path.join(directory, path),
  109. )
  110. continue
  111. full_path = os.path.join(directory, path)
  112. if encoding is None:
  113. # Try to autodetect encoding based on the BOM.
  114. with open(full_path, "rb") as bf:
  115. data = bf.read(len(codecs.BOM_UTF16_LE))
  116. if data in (codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE):
  117. encoding = "utf-16"
  118. else:
  119. # utf-8-sig is "utf-8 with optional BOM". It's discouraged
  120. # in most cases but is common with CSV files because Excel
  121. # cannot read utf-8 files without a BOM.
  122. encoding = "utf-8-sig"
  123. # python 3: csv.reader requires a file open in text mode.
  124. # Specify an encoding to avoid dependence on $LANG environment variable.
  125. with open(full_path, encoding=encoding) as f:
  126. _translations[locale] = {}
  127. for i, row in enumerate(csv.reader(f)):
  128. if not row or len(row) < 2:
  129. continue
  130. row = [escape.to_unicode(c).strip() for c in row]
  131. english, translation = row[:2]
  132. if len(row) > 2:
  133. plural = row[2] or "unknown"
  134. else:
  135. plural = "unknown"
  136. if plural not in ("plural", "singular", "unknown"):
  137. gen_log.error(
  138. "Unrecognized plural indicator %r in %s line %d",
  139. plural,
  140. path,
  141. i + 1,
  142. )
  143. continue
  144. _translations[locale].setdefault(plural, {})[english] = translation
  145. _supported_locales = frozenset(list(_translations.keys()) + [_default_locale])
  146. gen_log.debug("Supported locales: %s", sorted(_supported_locales))
  147. def load_gettext_translations(directory: str, domain: str) -> None:
  148. """Loads translations from `gettext`'s locale tree
  149. Locale tree is similar to system's ``/usr/share/locale``, like::
  150. {directory}/{lang}/LC_MESSAGES/{domain}.mo
  151. Three steps are required to have your app translated:
  152. 1. Generate POT translation file::
  153. xgettext --language=Python --keyword=_:1,2 -d mydomain file1.py file2.html etc
  154. 2. Merge against existing POT file::
  155. msgmerge old.po mydomain.po > new.po
  156. 3. Compile::
  157. msgfmt mydomain.po -o {directory}/pt_BR/LC_MESSAGES/mydomain.mo
  158. """
  159. global _translations
  160. global _supported_locales
  161. global _use_gettext
  162. _translations = {}
  163. for filename in glob.glob(
  164. os.path.join(directory, "*", "LC_MESSAGES", domain + ".mo")
  165. ):
  166. lang = os.path.basename(os.path.dirname(os.path.dirname(filename)))
  167. try:
  168. _translations[lang] = gettext.translation(
  169. domain, directory, languages=[lang]
  170. )
  171. except Exception as e:
  172. gen_log.error("Cannot load translation for '%s': %s", lang, str(e))
  173. continue
  174. _supported_locales = frozenset(list(_translations.keys()) + [_default_locale])
  175. _use_gettext = True
  176. gen_log.debug("Supported locales: %s", sorted(_supported_locales))
  177. def get_supported_locales() -> Iterable[str]:
  178. """Returns a list of all the supported locale codes."""
  179. return _supported_locales
  180. class Locale:
  181. """Object representing a locale.
  182. After calling one of `load_translations` or `load_gettext_translations`,
  183. call `get` or `get_closest` to get a Locale object.
  184. """
  185. _cache = {} # type: Dict[str, Locale]
  186. @classmethod
  187. def get_closest(cls, *locale_codes: str) -> "Locale":
  188. """Returns the closest match for the given locale code."""
  189. for code in locale_codes:
  190. if not code:
  191. continue
  192. code = code.replace("-", "_")
  193. parts = code.split("_")
  194. if len(parts) > 2:
  195. continue
  196. elif len(parts) == 2:
  197. code = parts[0].lower() + "_" + parts[1].upper()
  198. if code in _supported_locales:
  199. return cls.get(code)
  200. if parts[0].lower() in _supported_locales:
  201. return cls.get(parts[0].lower())
  202. return cls.get(_default_locale)
  203. @classmethod
  204. def get(cls, code: str) -> "Locale":
  205. """Returns the Locale for the given locale code.
  206. If it is not supported, we raise an exception.
  207. """
  208. if code not in cls._cache:
  209. assert code in _supported_locales
  210. translations = _translations.get(code, None)
  211. if translations is None:
  212. locale = CSVLocale(code, {}) # type: Locale
  213. elif _use_gettext:
  214. locale = GettextLocale(code, translations)
  215. else:
  216. locale = CSVLocale(code, translations)
  217. cls._cache[code] = locale
  218. return cls._cache[code]
  219. def __init__(self, code: str) -> None:
  220. self.code = code
  221. self.name = LOCALE_NAMES.get(code, {}).get("name", "Unknown")
  222. self.rtl = False
  223. for prefix in ["fa", "ar", "he"]:
  224. if self.code.startswith(prefix):
  225. self.rtl = True
  226. break
  227. # Initialize strings for date formatting
  228. _ = self.translate
  229. self._months = [
  230. _("January"),
  231. _("February"),
  232. _("March"),
  233. _("April"),
  234. _("May"),
  235. _("June"),
  236. _("July"),
  237. _("August"),
  238. _("September"),
  239. _("October"),
  240. _("November"),
  241. _("December"),
  242. ]
  243. self._weekdays = [
  244. _("Monday"),
  245. _("Tuesday"),
  246. _("Wednesday"),
  247. _("Thursday"),
  248. _("Friday"),
  249. _("Saturday"),
  250. _("Sunday"),
  251. ]
  252. def translate(
  253. self,
  254. message: str,
  255. plural_message: Optional[str] = None,
  256. count: Optional[int] = None,
  257. ) -> str:
  258. """Returns the translation for the given message for this locale.
  259. If ``plural_message`` is given, you must also provide
  260. ``count``. We return ``plural_message`` when ``count != 1``,
  261. and we return the singular form for the given message when
  262. ``count == 1``.
  263. """
  264. raise NotImplementedError()
  265. def pgettext(
  266. self,
  267. context: str,
  268. message: str,
  269. plural_message: Optional[str] = None,
  270. count: Optional[int] = None,
  271. ) -> str:
  272. raise NotImplementedError()
  273. def format_date(
  274. self,
  275. date: Union[int, float, datetime.datetime],
  276. gmt_offset: int = 0,
  277. relative: bool = True,
  278. shorter: bool = False,
  279. full_format: bool = False,
  280. ) -> str:
  281. """Formats the given date.
  282. By default, we return a relative time (e.g., "2 minutes ago"). You
  283. can return an absolute date string with ``relative=False``.
  284. You can force a full format date ("July 10, 1980") with
  285. ``full_format=True``.
  286. This method is primarily intended for dates in the past.
  287. For dates in the future, we fall back to full format.
  288. .. versionchanged:: 6.4
  289. Aware `datetime.datetime` objects are now supported (naive
  290. datetimes are still assumed to be UTC).
  291. """
  292. if isinstance(date, (int, float)):
  293. date = datetime.datetime.fromtimestamp(date, datetime.timezone.utc)
  294. if date.tzinfo is None:
  295. date = date.replace(tzinfo=datetime.timezone.utc)
  296. now = datetime.datetime.now(datetime.timezone.utc)
  297. if date > now:
  298. if relative and (date - now).seconds < 60:
  299. # Due to click skew, things are some things slightly
  300. # in the future. Round timestamps in the immediate
  301. # future down to now in relative mode.
  302. date = now
  303. else:
  304. # Otherwise, future dates always use the full format.
  305. full_format = True
  306. local_date = date - datetime.timedelta(minutes=gmt_offset)
  307. local_now = now - datetime.timedelta(minutes=gmt_offset)
  308. local_yesterday = local_now - datetime.timedelta(hours=24)
  309. difference = now - date
  310. seconds = difference.seconds
  311. days = difference.days
  312. _ = self.translate
  313. format = None
  314. if not full_format:
  315. if relative and days == 0:
  316. if seconds < 50:
  317. return _("1 second ago", "%(seconds)d seconds ago", seconds) % {
  318. "seconds": seconds
  319. }
  320. if seconds < 50 * 60:
  321. minutes = round(seconds / 60.0)
  322. return _("1 minute ago", "%(minutes)d minutes ago", minutes) % {
  323. "minutes": minutes
  324. }
  325. hours = round(seconds / (60.0 * 60))
  326. return _("1 hour ago", "%(hours)d hours ago", hours) % {"hours": hours}
  327. if days == 0:
  328. format = _("%(time)s")
  329. elif days == 1 and local_date.day == local_yesterday.day and relative:
  330. format = _("yesterday") if shorter else _("yesterday at %(time)s")
  331. elif days < 5:
  332. format = _("%(weekday)s") if shorter else _("%(weekday)s at %(time)s")
  333. elif days < 334: # 11mo, since confusing for same month last year
  334. format = (
  335. _("%(month_name)s %(day)s")
  336. if shorter
  337. else _("%(month_name)s %(day)s at %(time)s")
  338. )
  339. if format is None:
  340. format = (
  341. _("%(month_name)s %(day)s, %(year)s")
  342. if shorter
  343. else _("%(month_name)s %(day)s, %(year)s at %(time)s")
  344. )
  345. tfhour_clock = self.code not in ("en", "en_US", "zh_CN")
  346. if tfhour_clock:
  347. str_time = "%d:%02d" % (local_date.hour, local_date.minute)
  348. elif self.code == "zh_CN":
  349. str_time = "%s%d:%02d" % (
  350. ("\u4e0a\u5348", "\u4e0b\u5348")[local_date.hour >= 12],
  351. local_date.hour % 12 or 12,
  352. local_date.minute,
  353. )
  354. else:
  355. str_time = "%d:%02d %s" % (
  356. local_date.hour % 12 or 12,
  357. local_date.minute,
  358. ("am", "pm")[local_date.hour >= 12],
  359. )
  360. return format % {
  361. "month_name": self._months[local_date.month - 1],
  362. "weekday": self._weekdays[local_date.weekday()],
  363. "day": str(local_date.day),
  364. "year": str(local_date.year),
  365. "time": str_time,
  366. }
  367. def format_day(
  368. self, date: datetime.datetime, gmt_offset: int = 0, dow: bool = True
  369. ) -> bool:
  370. """Formats the given date as a day of week.
  371. Example: "Monday, January 22". You can remove the day of week with
  372. ``dow=False``.
  373. """
  374. local_date = date - datetime.timedelta(minutes=gmt_offset)
  375. _ = self.translate
  376. if dow:
  377. return _("%(weekday)s, %(month_name)s %(day)s") % {
  378. "month_name": self._months[local_date.month - 1],
  379. "weekday": self._weekdays[local_date.weekday()],
  380. "day": str(local_date.day),
  381. }
  382. else:
  383. return _("%(month_name)s %(day)s") % {
  384. "month_name": self._months[local_date.month - 1],
  385. "day": str(local_date.day),
  386. }
  387. def list(self, parts: Any) -> str:
  388. """Returns a comma-separated list for the given list of parts.
  389. The format is, e.g., "A, B and C", "A and B" or just "A" for lists
  390. of size 1.
  391. """
  392. _ = self.translate
  393. if len(parts) == 0:
  394. return ""
  395. if len(parts) == 1:
  396. return parts[0]
  397. comma = " \u0648 " if self.code.startswith("fa") else ", "
  398. return _("%(commas)s and %(last)s") % {
  399. "commas": comma.join(parts[:-1]),
  400. "last": parts[len(parts) - 1],
  401. }
  402. def friendly_number(self, value: int) -> str:
  403. """Returns a comma-separated number for the given integer."""
  404. if self.code not in ("en", "en_US"):
  405. return str(value)
  406. s = str(value)
  407. parts = []
  408. while s:
  409. parts.append(s[-3:])
  410. s = s[:-3]
  411. return ",".join(reversed(parts))
  412. class CSVLocale(Locale):
  413. """Locale implementation using tornado's CSV translation format."""
  414. def __init__(self, code: str, translations: Dict[str, Dict[str, str]]) -> None:
  415. self.translations = translations
  416. super().__init__(code)
  417. def translate(
  418. self,
  419. message: str,
  420. plural_message: Optional[str] = None,
  421. count: Optional[int] = None,
  422. ) -> str:
  423. if plural_message is not None:
  424. assert count is not None
  425. if count != 1:
  426. message = plural_message
  427. message_dict = self.translations.get("plural", {})
  428. else:
  429. message_dict = self.translations.get("singular", {})
  430. else:
  431. message_dict = self.translations.get("unknown", {})
  432. return message_dict.get(message, message)
  433. def pgettext(
  434. self,
  435. context: str,
  436. message: str,
  437. plural_message: Optional[str] = None,
  438. count: Optional[int] = None,
  439. ) -> str:
  440. if self.translations:
  441. gen_log.warning("pgettext is not supported by CSVLocale")
  442. return self.translate(message, plural_message, count)
  443. class GettextLocale(Locale):
  444. """Locale implementation using the `gettext` module."""
  445. def __init__(self, code: str, translations: gettext.NullTranslations) -> None:
  446. self.ngettext = translations.ngettext
  447. self.gettext = translations.gettext
  448. # self.gettext must exist before __init__ is called, since it
  449. # calls into self.translate
  450. super().__init__(code)
  451. def translate(
  452. self,
  453. message: str,
  454. plural_message: Optional[str] = None,
  455. count: Optional[int] = None,
  456. ) -> str:
  457. if plural_message is not None:
  458. assert count is not None
  459. return self.ngettext(message, plural_message, count)
  460. else:
  461. return self.gettext(message)
  462. def pgettext(
  463. self,
  464. context: str,
  465. message: str,
  466. plural_message: Optional[str] = None,
  467. count: Optional[int] = None,
  468. ) -> str:
  469. """Allows to set context for translation, accepts plural forms.
  470. Usage example::
  471. pgettext("law", "right")
  472. pgettext("good", "right")
  473. Plural message example::
  474. pgettext("organization", "club", "clubs", len(clubs))
  475. pgettext("stick", "club", "clubs", len(clubs))
  476. To generate POT file with context, add following options to step 1
  477. of `load_gettext_translations` sequence::
  478. xgettext [basic options] --keyword=pgettext:1c,2 --keyword=pgettext:1c,2,3
  479. .. versionadded:: 4.2
  480. """
  481. if plural_message is not None:
  482. assert count is not None
  483. msgs_with_ctxt = (
  484. f"{context}{CONTEXT_SEPARATOR}{message}",
  485. f"{context}{CONTEXT_SEPARATOR}{plural_message}",
  486. count,
  487. )
  488. result = self.ngettext(*msgs_with_ctxt)
  489. if CONTEXT_SEPARATOR in result:
  490. # Translation not found
  491. result = self.ngettext(message, plural_message, count)
  492. return result
  493. else:
  494. msg_with_ctxt = f"{context}{CONTEXT_SEPARATOR}{message}"
  495. result = self.gettext(msg_with_ctxt)
  496. if CONTEXT_SEPARATOR in result:
  497. # Translation not found
  498. result = message
  499. return result