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.
49
_default_locale = "en_US"
51
_supported_locales = frozenset([_default_locale])
54
_log = logging.getLogger('tornado.locale')
56
def get(*locale_codes):
57
"""Returns the closest match for the given locale codes.
59
We iterate over all given locale codes in order. If we have a tight
60
or a loose match for the code (e.g., "en" for "en_US"), we return
61
the locale. Otherwise we move to the next code in the list.
63
By default we return en_US if no translations are found for any of
64
the specified locales. You can change the default locale with
65
set_default_locale() below.
67
return Locale.get_closest(*locale_codes)
70
def set_default_locale(code):
71
"""Sets the default locale, used in get_closest_locale().
73
The default locale is assumed to be the language used for all strings
74
in the system. The translations loaded from disk are mappings from
75
the default locale to the destination locale. Consequently, you don't
76
need to create a translation file for the default locale.
78
global _default_locale
79
global _supported_locales
80
_default_locale = code
81
_supported_locales = frozenset(_translations.keys() + [_default_locale])
84
def load_translations(directory):
85
"""Loads translations from CSV files in a directory.
87
Translations are strings with optional Python-style named placeholders
88
(e.g., "My name is %(name)s") and their associated translations.
90
The directory should have translation files of the form LOCALE.csv,
91
e.g. es_GT.csv. The CSV files should have two or three columns: string,
92
translation, and an optional plural indicator. Plural indicators should
93
be one of "plural" or "singular". A given string can have both singular
94
and plural forms. For example "%(name)s liked this" may have a
95
different verb conjugation depending on whether %(name)s is one
96
name or a list of names. There should be two rows in the CSV file for
97
that string, one with plural indicator "singular", and one "plural".
98
For strings with no verbs that would change on translation, simply
99
use "unknown" or the empty string (or don't include the column at all).
101
Example translation es_LA.csv:
103
"I love you","Te amo"
104
"%(name)s liked this","A %(name)s les gust\xf3 esto","plural"
105
"%(name)s liked this","A %(name)s le gust\xf3 esto","singular"
109
global _supported_locales
111
for path in os.listdir(directory):
112
if not path.endswith(".csv"): continue
113
locale, extension = path.split(".")
114
if locale not in LOCALE_NAMES:
115
_log.error("Unrecognized locale %r (path: %s)", locale,
116
os.path.join(directory, path))
118
f = open(os.path.join(directory, path), "r")
119
_translations[locale] = {}
120
for i, row in enumerate(csv.reader(f)):
121
if not row or len(row) < 2: continue
122
row = [c.decode("utf-8").strip() for c in row]
123
english, translation = row[:2]
125
plural = row[2] or "unknown"
128
if plural not in ("plural", "singular", "unknown"):
129
_log.error("Unrecognized plural indicator %r in %s line %d",
132
_translations[locale].setdefault(plural, {})[english] = translation
134
_supported_locales = frozenset(_translations.keys() + [_default_locale])
135
_log.info("Supported locales: %s", sorted(_supported_locales))
137
def load_gettext_translations(directory, domain):
138
"""Loads translations from gettext's locale tree
140
Locale tree is similar to system's /usr/share/locale, like:
142
{directory}/{lang}/LC_MESSAGES/{domain}.mo
144
Three steps are required to have you app translated:
146
1. Generate POT translation file
147
xgettext --language=Python --keyword=_:1,2 -d cyclone file1.py file2.html etc
149
2. Merge against existing POT file:
150
msgmerge old.po cyclone.po > new.po
153
msgfmt cyclone.po -o {directory}/pt_BR/LC_MESSAGES/cyclone.mo
157
global _supported_locales
160
for lang in os.listdir(directory):
161
if os.path.isfile(os.path.join(directory, lang)): continue
163
os.stat(os.path.join(directory, lang, "LC_MESSAGES", domain+".mo"))
164
_translations[lang] = gettext.translation(domain, directory,
167
logging.error("Cannot load translation for '%s': %s", lang, str(e))
169
_supported_locales = frozenset(_translations.keys() + [_default_locale])
171
_log.info("Supported locales: %s", sorted(_supported_locales))
174
def get_supported_locales(cls):
175
"""Returns a list of all the supported locale codes."""
176
return _supported_locales
179
class Locale(object):
181
def get_closest(cls, *locale_codes):
182
"""Returns the closest match for the given locale code."""
183
for code in locale_codes:
184
if not code: continue
185
code = code.replace("-", "_")
186
parts = code.split("_")
189
elif len(parts) == 2:
190
code = parts[0].lower() + "_" + parts[1].upper()
191
if code in _supported_locales:
193
if parts[0].lower() in _supported_locales:
194
return cls.get(parts[0].lower())
195
return cls.get(_default_locale)
199
"""Returns the Locale for the given locale code.
201
If it is not supported, we raise an exception.
203
if not hasattr(cls, "_cache"):
205
if code not in cls._cache:
206
assert code in _supported_locales
207
translations = _translations.get(code, None)
208
if translations is None:
209
locale = CSVLocale(code, {})
211
locale = GettextLocale(code, translations)
213
locale = CSVLocale(code, translations)
214
cls._cache[code] = locale
215
return cls._cache[code]
217
def __init__(self, code, translations):
219
self.name = LOCALE_NAMES.get(code, {}).get("name", u"Unknown")
221
for prefix in ["fa", "ar", "he"]:
222
if self.code.startswith(prefix):
225
self.translations = translations
227
# Initialize strings for date formatting
230
_("January"), _("February"), _("March"), _("April"),
231
_("May"), _("June"), _("July"), _("August"),
232
_("September"), _("October"), _("November"), _("December")]
234
_("Monday"), _("Tuesday"), _("Wednesday"), _("Thursday"),
235
_("Friday"), _("Saturday"), _("Sunday")]
237
def translate(self, message, plural_message=None, count=None):
238
raise NotImplementedError()
240
def format_date(self, date, gmt_offset=0, relative=True, shorter=False,
242
"""Formats the given date (which should be GMT).
244
By default, we return a relative time (e.g., "2 minutes ago"). You
245
can return an absolute date string with relative=False.
247
You can force a full format date ("July 10, 1980") with
250
if self.code.startswith("ru"):
252
if type(date) in (int, long, float):
253
date = datetime.datetime.utcfromtimestamp(date)
254
now = datetime.datetime.utcnow()
255
# Round down to now. Due to click skew, things are somethings
256
# slightly in the future.
257
if date > now: date = now
258
local_date = date - datetime.timedelta(minutes=gmt_offset)
259
local_now = now - datetime.timedelta(minutes=gmt_offset)
260
local_yesterday = local_now - datetime.timedelta(hours=24)
261
difference = now - date
262
seconds = difference.seconds
263
days = difference.days
268
if relative and days == 0:
270
return _("1 second ago", "%(seconds)d seconds ago",
271
seconds) % { "seconds": seconds }
273
if seconds < 50 * 60:
274
minutes = round(seconds / 60.0)
275
return _("1 minute ago", "%(minutes)d minutes ago",
276
minutes) % { "minutes": minutes }
278
hours = round(seconds / (60.0 * 60))
279
return _("1 hour ago", "%(hours)d hours ago",
280
hours) % { "hours": hours }
283
format = _("%(time)s")
284
elif days == 1 and local_date.day == local_yesterday.day and \
286
format = _("yesterday") if shorter else \
287
_("yesterday at %(time)s")
289
format = _("%(weekday)s") if shorter else \
290
_("%(weekday)s at %(time)s")
291
elif days < 334: # 11mo, since confusing for same month last year
292
format = _("%(month_name)s %(day)s") if shorter else \
293
_("%(month_name)s %(day)s at %(time)s")
296
format = _("%(month_name)s %(day)s, %(year)s") if shorter else \
297
_("%(month_name)s %(day)s, %(year)s at %(time)s")
299
tfhour_clock = self.code not in ("en", "en_US", "zh_CN")
301
str_time = "%d:%02d" % (local_date.hour, local_date.minute)
302
elif self.code == "zh_CN":
303
str_time = "%s%d:%02d" % (
304
(u'\u4e0a\u5348', u'\u4e0b\u5348')[local_date.hour >= 12],
305
local_date.hour % 12 or 12, local_date.minute)
307
str_time = "%d:%02d %s" % (
308
local_date.hour % 12 or 12, local_date.minute,
309
("am", "pm")[local_date.hour >= 12])
312
"month_name": self._months[local_date.month - 1],
313
"weekday": self._weekdays[local_date.weekday()],
314
"day": str(local_date.day),
315
"year": str(local_date.year),
319
def format_day(self, date, gmt_offset=0, dow=True):
320
"""Formats the given date as a day of week.
322
Example: "Monday, January 22". You can remove the day of week with
325
local_date = date - datetime.timedelta(minutes=gmt_offset)
328
return _("%(weekday)s, %(month_name)s %(day)s") % {
329
"month_name": self._months[local_date.month - 1],
330
"weekday": self._weekdays[local_date.weekday()],
331
"day": str(local_date.day),
334
return _("%(month_name)s %(day)s") % {
335
"month_name": self._months[local_date.month - 1],
336
"day": str(local_date.day),
339
def list(self, parts):
340
"""Returns a comma-separated list for the given list of parts.
342
The format is, e.g., "A, B and C", "A and B" or just "A" for lists
346
if len(parts) == 0: return ""
347
if len(parts) == 1: return parts[0]
348
comma = u' \u0648 ' if self.code.startswith("fa") else u", "
349
return _("%(commas)s and %(last)s") % {
350
"commas": comma.join(parts[:-1]),
351
"last": parts[len(parts) - 1],
354
def friendly_number(self, value):
355
"""Returns a comma-separated number for the given integer."""
356
if self.code not in ("en", "en_US"):
361
parts.append(value[-3:])
363
return ",".join(reversed(parts))
365
class CSVLocale(Locale):
366
"""Locale implementation using tornado's CSV translation format."""
367
def translate(self, message, plural_message=None, count=None):
368
"""Returns the translation for the given message for this locale.
370
If plural_message is given, you must also provide count. We return
371
plural_message when count != 1, and we return the singular form
372
for the given message when count == 1.
374
if plural_message is not None:
375
assert count is not None
377
message = plural_message
378
message_dict = self.translations.get("plural", {})
380
message_dict = self.translations.get("singular", {})
382
message_dict = self.translations.get("unknown", {})
383
return message_dict.get(message, message)
385
class GettextLocale(Locale):
386
"""Locale implementation using the gettext module."""
387
def translate(self, message, plural_message=None, count=None):
388
if plural_message is not None:
389
assert count is not None
390
return self.translations.ungettext(message, plural_message, count)
392
return self.translations.ugettext(message)
395
"af_ZA": {"name_en": u"Afrikaans", "name": u"Afrikaans"},
396
"ar_AR": {"name_en": u"Arabic", "name": u"\u0627\u0644\u0639\u0631\u0628\u064a\u0629"},
397
"bg_BG": {"name_en": u"Bulgarian", "name": u"\u0411\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438"},
398
"bn_IN": {"name_en": u"Bengali", "name": u"\u09ac\u09be\u0982\u09b2\u09be"},
399
"bs_BA": {"name_en": u"Bosnian", "name": u"Bosanski"},
400
"ca_ES": {"name_en": u"Catalan", "name": u"Catal\xe0"},
401
"cs_CZ": {"name_en": u"Czech", "name": u"\u010ce\u0161tina"},
402
"cy_GB": {"name_en": u"Welsh", "name": u"Cymraeg"},
403
"da_DK": {"name_en": u"Danish", "name": u"Dansk"},
404
"de_DE": {"name_en": u"German", "name": u"Deutsch"},
405
"el_GR": {"name_en": u"Greek", "name": u"\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac"},
406
"en_GB": {"name_en": u"English (UK)", "name": u"English (UK)"},
407
"en_US": {"name_en": u"English (US)", "name": u"English (US)"},
408
"es_ES": {"name_en": u"Spanish (Spain)", "name": u"Espa\xf1ol (Espa\xf1a)"},
409
"es_LA": {"name_en": u"Spanish", "name": u"Espa\xf1ol"},
410
"et_EE": {"name_en": u"Estonian", "name": u"Eesti"},
411
"eu_ES": {"name_en": u"Basque", "name": u"Euskara"},
412
"fa_IR": {"name_en": u"Persian", "name": u"\u0641\u0627\u0631\u0633\u06cc"},
413
"fi_FI": {"name_en": u"Finnish", "name": u"Suomi"},
414
"fr_CA": {"name_en": u"French (Canada)", "name": u"Fran\xe7ais (Canada)"},
415
"fr_FR": {"name_en": u"French", "name": u"Fran\xe7ais"},
416
"ga_IE": {"name_en": u"Irish", "name": u"Gaeilge"},
417
"gl_ES": {"name_en": u"Galician", "name": u"Galego"},
418
"he_IL": {"name_en": u"Hebrew", "name": u"\u05e2\u05d1\u05e8\u05d9\u05ea"},
419
"hi_IN": {"name_en": u"Hindi", "name": u"\u0939\u093f\u0928\u094d\u0926\u0940"},
420
"hr_HR": {"name_en": u"Croatian", "name": u"Hrvatski"},
421
"hu_HU": {"name_en": u"Hungarian", "name": u"Magyar"},
422
"id_ID": {"name_en": u"Indonesian", "name": u"Bahasa Indonesia"},
423
"is_IS": {"name_en": u"Icelandic", "name": u"\xcdslenska"},
424
"it_IT": {"name_en": u"Italian", "name": u"Italiano"},
425
"ja_JP": {"name_en": u"Japanese", "name": u"\xe6\xe6\xe8"},
426
"ko_KR": {"name_en": u"Korean", "name": u"\xed\xea\xec"},
427
"lt_LT": {"name_en": u"Lithuanian", "name": u"Lietuvi\u0173"},
428
"lv_LV": {"name_en": u"Latvian", "name": u"Latvie\u0161u"},
429
"mk_MK": {"name_en": u"Macedonian", "name": u"\u041c\u0430\u043a\u0435\u0434\u043e\u043d\u0441\u043a\u0438"},
430
"ml_IN": {"name_en": u"Malayalam", "name": u"\u0d2e\u0d32\u0d2f\u0d3e\u0d33\u0d02"},
431
"ms_MY": {"name_en": u"Malay", "name": u"Bahasa Melayu"},
432
"nb_NO": {"name_en": u"Norwegian (bokmal)", "name": u"Norsk (bokm\xe5l)"},
433
"nl_NL": {"name_en": u"Dutch", "name": u"Nederlands"},
434
"nn_NO": {"name_en": u"Norwegian (nynorsk)", "name": u"Norsk (nynorsk)"},
435
"pa_IN": {"name_en": u"Punjabi", "name": u"\u0a2a\u0a70\u0a1c\u0a3e\u0a2c\u0a40"},
436
"pl_PL": {"name_en": u"Polish", "name": u"Polski"},
437
"pt_BR": {"name_en": u"Portuguese (Brazil)", "name": u"Portugu\xeas (Brasil)"},
438
"pt_PT": {"name_en": u"Portuguese (Portugal)", "name": u"Portugu\xeas (Portugal)"},
439
"ro_RO": {"name_en": u"Romanian", "name": u"Rom\xe2n\u0103"},
440
"ru_RU": {"name_en": u"Russian", "name": u"\u0420\u0443\u0441\u0441\u043a\u0438\u0439"},
441
"sk_SK": {"name_en": u"Slovak", "name": u"Sloven\u010dina"},
442
"sl_SI": {"name_en": u"Slovenian", "name": u"Sloven\u0161\u010dina"},
443
"sq_AL": {"name_en": u"Albanian", "name": u"Shqip"},
444
"sr_RS": {"name_en": u"Serbian", "name": u"\u0421\u0440\u043f\u0441\u043a\u0438"},
445
"sv_SE": {"name_en": u"Swedish", "name": u"Svenska"},
446
"sw_KE": {"name_en": u"Swahili", "name": u"Kiswahili"},
447
"ta_IN": {"name_en": u"Tamil", "name": u"\u0ba4\u0bae\u0bbf\u0bb4\u0bcd"},
448
"te_IN": {"name_en": u"Telugu", "name": u"\u0c24\u0c46\u0c32\u0c41\u0c17\u0c41"},
449
"th_TH": {"name_en": u"Thai", "name": u"\u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22"},
450
"tl_PH": {"name_en": u"Filipino", "name": u"Filipino"},
451
"tr_TR": {"name_en": u"Turkish", "name": u"T\xfcrk\xe7e"},
452
"uk_UA": {"name_en": u"Ukraini ", "name": u"\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430"},
453
"vi_VN": {"name_en": u"Vietnamese", "name": u"Ti\u1ebfng Vi\u1ec7t"},
454
"zh_CN": {"name_en": u"Chinese (Simplified)", "name": u"\xe4\xe6(\xe7\xe4)"},
455
"zh_HK": {"name_en": u"Chinese (Hong Kong)", "name": u"\xe4\xe6(\xe9\xe6)"},
456
"zh_TW": {"name_en": u"Chinese (Taiwan)", "name": u"\xe4\xe6(\xe5\xe7)"},