locale.py 20 KB

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