3 # Copyright 2009 Facebook
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
9 # http://www.apache.org/licenses/LICENSE-2.0
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
17 """Translation methods for generating localized strings.
19 To load a locale and generate a translated string:
21 user_locale = locale.get("es_LA")
22 print user_locale.translate("Sign out")
24 locale.get() returns the closest matching locale, not necessarily the
25 specific locale you requested. You can support pluralization with
26 additional arguments to translate(), e.g.:
29 message = user_locale.translate(
30 "%(list)s is online", "%(list)s are online", len(people))
31 print message % {"list": user_locale.list(people)}
33 The first string is chosen if len(people) == 1, otherwise the second
36 Applications should call one of load_translations (which uses a simple
37 CSV format) or load_gettext_translations (which uses the .mo format
38 supported by gettext and related tools). If neither method is called,
39 the locale.translate method will simply return the original string.
47 _default_locale = "en_US"
49 _supported_locales = frozenset([_default_locale])
52 def get(*locale_codes):
53 """Returns the closest match for the given locale codes.
55 We iterate over all given locale codes in order. If we have a tight
56 or a loose match for the code (e.g., "en" for "en_US"), we return
57 the locale. Otherwise we move to the next code in the list.
59 By default we return en_US if no translations are found for any of
60 the specified locales. You can change the default locale with
61 set_default_locale() below.
63 return Locale.get_closest(*locale_codes)
66 def set_default_locale(code):
67 """Sets the default locale, used in get_closest_locale().
69 The default locale is assumed to be the language used for all strings
70 in the system. The translations loaded from disk are mappings from
71 the default locale to the destination locale. Consequently, you don't
72 need to create a translation file for the default locale.
74 global _default_locale
75 global _supported_locales
76 _default_locale = code
77 _supported_locales = frozenset(_translations.keys() + [_default_locale])
80 def load_translations(directory):
81 """Loads translations from CSV files in a directory.
83 Translations are strings with optional Python-style named placeholders
84 (e.g., "My name is %(name)s") and their associated translations.
86 The directory should have translation files of the form LOCALE.csv,
87 e.g. es_GT.csv. The CSV files should have two or three columns: string,
88 translation, and an optional plural indicator. Plural indicators should
89 be one of "plural" or "singular". A given string can have both singular
90 and plural forms. For example "%(name)s liked this" may have a
91 different verb conjugation depending on whether %(name)s is one
92 name or a list of names. There should be two rows in the CSV file for
93 that string, one with plural indicator "singular", and one "plural".
94 For strings with no verbs that would change on translation, simply
95 use "unknown" or the empty string (or don't include the column at all).
97 Example translation es_LA.csv:
100 "%(name)s liked this","A %(name)s les gust\xf3 esto","plural"
101 "%(name)s liked this","A %(name)s le gust\xf3 esto","singular"
105 global _supported_locales
107 for path in os.listdir(directory):
108 if not path.endswith(".csv"): continue
109 locale, extension = path.split(".")
110 if locale not in LOCALE_NAMES:
111 logging.error("Unrecognized locale %r (path: %s)", locale,
112 os.path.join(directory, path))
114 f = open(os.path.join(directory, path), "r")
115 _translations[locale] = {}
116 for i, row in enumerate(csv.reader(f)):
117 if not row or len(row) < 2: continue
118 row = [c.decode("utf-8").strip() for c in row]
119 english, translation = row[:2]
121 plural = row[2] or "unknown"
124 if plural not in ("plural", "singular", "unknown"):
125 logging.error("Unrecognized plural indicator %r in %s line %d",
128 _translations[locale].setdefault(plural, {})[english] = translation
130 _supported_locales = frozenset(_translations.keys() + [_default_locale])
131 logging.info("Supported locales: %s", sorted(_supported_locales))
133 def load_gettext_translations(directory, domain):
134 """Loads translations from gettext's locale tree
136 Locale tree is similar to system's /usr/share/locale, like:
138 {directory}/{lang}/LC_MESSAGES/{domain}.mo
140 Three steps are required to have you app translated:
142 1. Generate POT translation file
143 xgettext --language=Python --keyword=_:1,2 -d cyclone file1.py file2.html etc
145 2. Merge against existing POT file:
146 msgmerge old.po cyclone.po > new.po
149 msgfmt cyclone.po -o {directory}/pt_BR/LC_MESSAGES/cyclone.mo
153 global _supported_locales
156 for lang in os.listdir(directory):
157 if os.path.isfile(os.path.join(directory, lang)): continue
159 os.stat(os.path.join(directory, lang, "LC_MESSAGES", domain+".mo"))
160 _translations[lang] = gettext.translation(domain, directory,
163 logging.error("Cannot load translation for '%s': %s", lang, str(e))
165 _supported_locales = frozenset(_translations.keys() + [_default_locale])
167 logging.info("Supported locales: %s", sorted(_supported_locales))
170 def get_supported_locales(cls):
171 """Returns a list of all the supported locale codes."""
172 return _supported_locales
175 class Locale(object):
177 def get_closest(cls, *locale_codes):
178 """Returns the closest match for the given locale code."""
179 for code in locale_codes:
180 if not code: continue
181 code = code.replace("-", "_")
182 parts = code.split("_")
185 elif len(parts) == 2:
186 code = parts[0].lower() + "_" + parts[1].upper()
187 if code in _supported_locales:
189 if parts[0].lower() in _supported_locales:
190 return cls.get(parts[0].lower())
191 return cls.get(_default_locale)
195 """Returns the Locale for the given locale code.
197 If it is not supported, we raise an exception.
199 if not hasattr(cls, "_cache"):
201 if code not in cls._cache:
202 assert code in _supported_locales
203 translations = _translations.get(code, None)
204 if translations is None:
205 locale = CSVLocale(code, {})
207 locale = GettextLocale(code, translations)
209 locale = CSVLocale(code, translations)
210 cls._cache[code] = locale
211 return cls._cache[code]
213 def __init__(self, code, translations):
215 self.name = LOCALE_NAMES.get(code, {}).get("name", u"Unknown")
217 for prefix in ["fa", "ar", "he"]:
218 if self.code.startswith(prefix):
221 self.translations = translations
223 # Initialize strings for date formatting
226 _("January"), _("February"), _("March"), _("April"),
227 _("May"), _("June"), _("July"), _("August"),
228 _("September"), _("October"), _("November"), _("December")]
230 _("Monday"), _("Tuesday"), _("Wednesday"), _("Thursday"),
231 _("Friday"), _("Saturday"), _("Sunday")]
233 def translate(self, message, plural_message=None, count=None):
234 raise NotImplementedError()
236 def format_date(self, date, gmt_offset=0, relative=True, shorter=False,
238 """Formats the given date (which should be GMT).
240 By default, we return a relative time (e.g., "2 minutes ago"). You
241 can return an absolute date string with relative=False.
243 You can force a full format date ("July 10, 1980") with
246 if self.code.startswith("ru"):
248 if type(date) in (int, long, float):
249 date = datetime.datetime.utcfromtimestamp(date)
250 now = datetime.datetime.utcnow()
251 # Round down to now. Due to click skew, things are somethings
252 # slightly in the future.
253 if date > now: date = now
254 local_date = date - datetime.timedelta(minutes=gmt_offset)
255 local_now = now - datetime.timedelta(minutes=gmt_offset)
256 local_yesterday = local_now - datetime.timedelta(hours=24)
257 difference = now - date
258 seconds = difference.seconds
259 days = difference.days
264 if relative and days == 0:
266 return _("1 second ago", "%(seconds)d seconds ago",
267 seconds) % { "seconds": seconds }
269 if seconds < 50 * 60:
270 minutes = round(seconds / 60.0)
271 return _("1 minute ago", "%(minutes)d minutes ago",
272 minutes) % { "minutes": minutes }
274 hours = round(seconds / (60.0 * 60))
275 return _("1 hour ago", "%(hours)d hours ago",
276 hours) % { "hours": hours }
279 format = _("%(time)s")
280 elif days == 1 and local_date.day == local_yesterday.day and \
282 format = _("yesterday") if shorter else \
283 _("yesterday at %(time)s")
285 format = _("%(weekday)s") if shorter else \
286 _("%(weekday)s at %(time)s")
287 elif days < 334: # 11mo, since confusing for same month last year
288 format = _("%(month_name)s %(day)s") if shorter else \
289 _("%(month_name)s %(day)s at %(time)s")
292 format = _("%(month_name)s %(day)s, %(year)s") if shorter else \
293 _("%(month_name)s %(day)s, %(year)s at %(time)s")
295 tfhour_clock = self.code not in ("en", "en_US", "zh_CN")
297 str_time = "%d:%02d" % (local_date.hour, local_date.minute)
298 elif self.code == "zh_CN":
299 str_time = "%s%d:%02d" % (
300 (u'\u4e0a\u5348', u'\u4e0b\u5348')[local_date.hour >= 12],
301 local_date.hour % 12 or 12, local_date.minute)
303 str_time = "%d:%02d %s" % (
304 local_date.hour % 12 or 12, local_date.minute,
305 ("am", "pm")[local_date.hour >= 12])
308 "month_name": self._months[local_date.month - 1],
309 "weekday": self._weekdays[local_date.weekday()],
310 "day": str(local_date.day),
311 "year": str(local_date.year),
315 def format_day(self, date, gmt_offset=0, dow=True):
316 """Formats the given date as a day of week.
318 Example: "Monday, January 22". You can remove the day of week with
321 local_date = date - datetime.timedelta(minutes=gmt_offset)
324 return _("%(weekday)s, %(month_name)s %(day)s") % {
325 "month_name": self._months[local_date.month - 1],
326 "weekday": self._weekdays[local_date.weekday()],
327 "day": str(local_date.day),
330 return _("%(month_name)s %(day)s") % {
331 "month_name": self._months[local_date.month - 1],
332 "day": str(local_date.day),
335 def list(self, parts):
336 """Returns a comma-separated list for the given list of parts.
338 The format is, e.g., "A, B and C", "A and B" or just "A" for lists
342 if len(parts) == 0: return ""
343 if len(parts) == 1: return parts[0]
344 comma = u' \u0648 ' if self.code.startswith("fa") else u", "
345 return _("%(commas)s and %(last)s") % {
346 "commas": comma.join(parts[:-1]),
347 "last": parts[len(parts) - 1],
350 def friendly_number(self, value):
351 """Returns a comma-separated number for the given integer."""
352 if self.code not in ("en", "en_US"):
357 parts.append(value[-3:])
359 return ",".join(reversed(parts))
361 class CSVLocale(Locale):
362 """Locale implementation using tornado's CSV translation format."""
363 def translate(self, message, plural_message=None, count=None):
364 """Returns the translation for the given message for this locale.
366 If plural_message is given, you must also provide count. We return
367 plural_message when count != 1, and we return the singular form
368 for the given message when count == 1.
370 if plural_message is not None:
371 assert count is not None
373 message = plural_message
374 message_dict = self.translations.get("plural", {})
376 message_dict = self.translations.get("singular", {})
378 message_dict = self.translations.get("unknown", {})
379 return message_dict.get(message, message)
381 class GettextLocale(Locale):
382 """Locale implementation using the gettext module."""
383 def translate(self, message, plural_message=None, count=None):
384 if plural_message is not None:
385 assert count is not None
386 return self.translations.ungettext(message, plural_message, count)
388 return self.translations.ugettext(message)
391 "af_ZA": {"name_en": u"Afrikaans", "name": u"Afrikaans"},
392 "ar_AR": {"name_en": u"Arabic", "name": u"\u0627\u0644\u0639\u0631\u0628\u064a\u0629"},
393 "bg_BG": {"name_en": u"Bulgarian", "name": u"\u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438"},
394 "bn_IN": {"name_en": u"Bengali", "name": u"\u09ac\u09be\u0982\u09b2\u09be"},
395 "bs_BA": {"name_en": u"Bosnian", "name": u"Bosanski"},
396 "ca_ES": {"name_en": u"Catalan", "name": u"Catal\xe0"},
397 "cs_CZ": {"name_en": u"Czech", "name": u"\u010ce\u0161tina"},
398 "cy_GB": {"name_en": u"Welsh", "name": u"Cymraeg"},
399 "da_DK": {"name_en": u"Danish", "name": u"Dansk"},
400 "de_DE": {"name_en": u"German", "name": u"Deutsch"},
401 "el_GR": {"name_en": u"Greek", "name": u"\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac"},
402 "en_GB": {"name_en": u"English (UK)", "name": u"English (UK)"},
403 "en_US": {"name_en": u"English (US)", "name": u"English (US)"},
404 "es_ES": {"name_en": u"Spanish (Spain)", "name": u"Espa\xf1ol (Espa\xf1a)"},
405 "es_LA": {"name_en": u"Spanish", "name": u"Espa\xf1ol"},
406 "et_EE": {"name_en": u"Estonian", "name": u"Eesti"},
407 "eu_ES": {"name_en": u"Basque", "name": u"Euskara"},
408 "fa_IR": {"name_en": u"Persian", "name": u"\u0641\u0627\u0631\u0633\u06cc"},
409 "fi_FI": {"name_en": u"Finnish", "name": u"Suomi"},
410 "fr_CA": {"name_en": u"French (Canada)", "name": u"Fran\xe7ais (Canada)"},
411 "fr_FR": {"name_en": u"French", "name": u"Fran\xe7ais"},
412 "ga_IE": {"name_en": u"Irish", "name": u"Gaeilge"},
413 "gl_ES": {"name_en": u"Galician", "name": u"Galego"},
414 "he_IL": {"name_en": u"Hebrew", "name": u"\u05e2\u05d1\u05e8\u05d9\u05ea"},
415 "hi_IN": {"name_en": u"Hindi", "name": u"\u0939\u093f\u0928\u094d\u0926\u0940"},
416 "hr_HR": {"name_en": u"Croatian", "name": u"Hrvatski"},
417 "hu_HU": {"name_en": u"Hungarian", "name": u"Magyar"},
418 "id_ID": {"name_en": u"Indonesian", "name": u"Bahasa Indonesia"},
419 "is_IS": {"name_en": u"Icelandic", "name": u"\xcdslenska"},
420 "it_IT": {"name_en": u"Italian", "name": u"Italiano"},
421 "ja_JP": {"name_en": u"Japanese", "name": u"\xe6\xe6\xe8"},
422 "ko_KR": {"name_en": u"Korean", "name": u"\xed\xea\xec"},
423 "lt_LT": {"name_en": u"Lithuanian", "name": u"Lietuvi\u0173"},
424 "lv_LV": {"name_en": u"Latvian", "name": u"Latvie\u0161u"},
425 "mk_MK": {"name_en": u"Macedonian", "name": u"\u041c\u0430\u043a\u0435\u0434\u043e\u043d\u0441\u043a\u0438"},
426 "ml_IN": {"name_en": u"Malayalam", "name": u"\u0d2e\u0d32\u0d2f\u0d3e\u0d33\u0d02"},
427 "ms_MY": {"name_en": u"Malay", "name": u"Bahasa Melayu"},
428 "nb_NO": {"name_en": u"Norwegian (bokmal)", "name": u"Norsk (bokm\xe5l)"},
429 "nl_NL": {"name_en": u"Dutch", "name": u"Nederlands"},
430 "nn_NO": {"name_en": u"Norwegian (nynorsk)", "name": u"Norsk (nynorsk)"},
431 "pa_IN": {"name_en": u"Punjabi", "name": u"\u0a2a\u0a70\u0a1c\u0a3e\u0a2c\u0a40"},
432 "pl_PL": {"name_en": u"Polish", "name": u"Polski"},
433 "pt_BR": {"name_en": u"Portuguese (Brazil)", "name": u"Portugu\xeas (Brasil)"},
434 "pt_PT": {"name_en": u"Portuguese (Portugal)", "name": u"Portugu\xeas (Portugal)"},
435 "ro_RO": {"name_en": u"Romanian", "name": u"Rom\xe2n\u0103"},
436 "ru_RU": {"name_en": u"Russian", "name": u"\u0420\u0443\u0441\u0441\u043a\u0438\u0439"},
437 "sk_SK": {"name_en": u"Slovak", "name": u"Sloven\u010dina"},
438 "sl_SI": {"name_en": u"Slovenian", "name": u"Sloven\u0161\u010dina"},
439 "sq_AL": {"name_en": u"Albanian", "name": u"Shqip"},
440 "sr_RS": {"name_en": u"Serbian", "name": u"\u0421\u0440\u043f\u0441\u043a\u0438"},
441 "sv_SE": {"name_en": u"Swedish", "name": u"Svenska"},
442 "sw_KE": {"name_en": u"Swahili", "name": u"Kiswahili"},
443 "ta_IN": {"name_en": u"Tamil", "name": u"\u0ba4\u0bae\u0bbf\u0bb4\u0bcd"},
444 "te_IN": {"name_en": u"Telugu", "name": u"\u0c24\u0c46\u0c32\u0c41\u0c17\u0c41"},
445 "th_TH": {"name_en": u"Thai", "name": u"\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22"},
446 "tl_PH": {"name_en": u"Filipino", "name": u"Filipino"},
447 "tr_TR": {"name_en": u"Turkish", "name": u"T\xfcrk\xe7e"},
448 "uk_UA": {"name_en": u"Ukraini ", "name": u"\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430"},
449 "vi_VN": {"name_en": u"Vietnamese", "name": u"Ti\u1ebfng Vi\u1ec7t"},
450 "zh_CN": {"name_en": u"Chinese (Simplified)", "name": u"\xe4\xe6(\xe7\xe4)"},
451 "zh_HK": {"name_en": u"Chinese (Hong Kong)", "name": u"\xe4\xe6(\xe9\xe6)"},
452 "zh_TW": {"name_en": u"Chinese (Taiwan)", "name": u"\xe4\xe6(\xe5\xe7)"},