locale.py 20 KB

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