]> arthur.barton.de Git - bup.git/blob - lib/tornado/locale.py
a2d9b2b139eda436f09004e7c811942b526e9e75
[bup.git] / lib / tornado / locale.py
1 #!/usr/bin/env python
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
17 """Translation methods for generating localized strings.
18
19 To load a locale and generate a translated string:
20
21     user_locale = locale.get("es_LA")
22     print user_locale.translate("Sign out")
23
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.:
27
28     people = [...]
29     message = user_locale.translate(
30         "%(list)s is online", "%(list)s are online", len(people))
31     print message % {"list": user_locale.list(people)}
32
33 The first string is chosen if len(people) == 1, otherwise the second
34 string is chosen.
35
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.
40 """
41
42 import csv
43 import datetime
44 import logging
45 import os
46
47 _default_locale = "en_US"
48 _translations = {}
49 _supported_locales = frozenset([_default_locale])
50 _use_gettext = False
51
52 def get(*locale_codes):
53     """Returns the closest match for the given locale codes.
54
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.
58
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.
62     """
63     return Locale.get_closest(*locale_codes)
64
65
66 def set_default_locale(code):
67     """Sets the default locale, used in get_closest_locale().
68
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.
73     """
74     global _default_locale
75     global _supported_locales
76     _default_locale = code
77     _supported_locales = frozenset(_translations.keys() + [_default_locale])
78
79
80 def load_translations(directory):
81     """Loads translations from CSV files in a directory.
82
83     Translations are strings with optional Python-style named placeholders
84     (e.g., "My name is %(name)s") and their associated translations.
85
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).
96
97     Example translation es_LA.csv:
98
99         "I love you","Te amo"
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"
102
103     """
104     global _translations
105     global _supported_locales
106     _translations = {}
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))
113             continue
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]
120             if len(row) > 2:
121                 plural = row[2] or "unknown"
122             else:
123                 plural = "unknown"
124             if plural not in ("plural", "singular", "unknown"):
125                 logging.error("Unrecognized plural indicator %r in %s line %d",
126                               plural, path, i + 1)
127                 continue
128             _translations[locale].setdefault(plural, {})[english] = translation
129         f.close()
130     _supported_locales = frozenset(_translations.keys() + [_default_locale])
131     logging.info("Supported locales: %s", sorted(_supported_locales))
132
133 def load_gettext_translations(directory, domain):
134     """Loads translations from gettext's locale tree
135
136     Locale tree is similar to system's /usr/share/locale, like:
137
138     {directory}/{lang}/LC_MESSAGES/{domain}.mo
139
140     Three steps are required to have you app translated:
141
142     1. Generate POT translation file
143         xgettext --language=Python --keyword=_:1,2 -d cyclone file1.py file2.html etc
144
145     2. Merge against existing POT file:
146         msgmerge old.po cyclone.po > new.po
147
148     3. Compile:
149         msgfmt cyclone.po -o {directory}/pt_BR/LC_MESSAGES/cyclone.mo
150     """
151     import gettext
152     global _translations
153     global _supported_locales
154     global _use_gettext
155     _translations = {}
156     for lang in os.listdir(directory):
157         if os.path.isfile(os.path.join(directory, lang)): continue
158         try:
159             os.stat(os.path.join(directory, lang, "LC_MESSAGES", domain+".mo"))
160             _translations[lang] = gettext.translation(domain, directory,
161                                                       languages=[lang])
162         except Exception, e:
163             logging.error("Cannot load translation for '%s': %s", lang, str(e))
164             continue
165     _supported_locales = frozenset(_translations.keys() + [_default_locale])
166     _use_gettext = True
167     logging.info("Supported locales: %s", sorted(_supported_locales))
168
169
170 def get_supported_locales(cls):
171     """Returns a list of all the supported locale codes."""
172     return _supported_locales
173
174
175 class Locale(object):
176     @classmethod
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("_")
183             if len(parts) > 2:
184                 continue
185             elif len(parts) == 2:
186                 code = parts[0].lower() + "_" + parts[1].upper()
187             if code in _supported_locales:
188                 return cls.get(code)
189             if parts[0].lower() in _supported_locales:
190                 return cls.get(parts[0].lower())
191         return cls.get(_default_locale)
192
193     @classmethod
194     def get(cls, code):
195         """Returns the Locale for the given locale code.
196
197         If it is not supported, we raise an exception.
198         """
199         if not hasattr(cls, "_cache"):
200             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, {})
206             elif _use_gettext:
207                 locale = GettextLocale(code, translations)
208             else:
209                 locale = CSVLocale(code, translations)
210             cls._cache[code] = locale
211         return cls._cache[code]
212
213     def __init__(self, code, translations):
214         self.code = code
215         self.name = LOCALE_NAMES.get(code, {}).get("name", u"Unknown")
216         self.rtl = False
217         for prefix in ["fa", "ar", "he"]:
218             if self.code.startswith(prefix):
219                 self.rtl = True
220                 break
221         self.translations = translations
222
223         # Initialize strings for date formatting
224         _ = self.translate
225         self._months = [
226             _("January"), _("February"), _("March"), _("April"),
227             _("May"), _("June"), _("July"), _("August"),
228             _("September"), _("October"), _("November"), _("December")]
229         self._weekdays = [
230             _("Monday"), _("Tuesday"), _("Wednesday"), _("Thursday"),
231             _("Friday"), _("Saturday"), _("Sunday")]
232
233     def translate(self, message, plural_message=None, count=None):
234         raise NotImplementedError()
235
236     def format_date(self, date, gmt_offset=0, relative=True, shorter=False,
237                     full_format=False):
238         """Formats the given date (which should be GMT).
239
240         By default, we return a relative time (e.g., "2 minutes ago"). You
241         can return an absolute date string with relative=False.
242
243         You can force a full format date ("July 10, 1980") with
244         full_format=True.
245         """
246         if self.code.startswith("ru"):
247             relative = False
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
260
261         _ = self.translate
262         format = None
263         if not full_format:
264             if relative and days == 0:
265                 if seconds < 50:
266                     return _("1 second ago", "%(seconds)d seconds ago",
267                              seconds) % { "seconds": seconds }
268
269                 if seconds < 50 * 60:
270                     minutes = round(seconds / 60.0)
271                     return _("1 minute ago", "%(minutes)d minutes ago",
272                              minutes) % { "minutes": minutes }
273
274                 hours = round(seconds / (60.0 * 60))
275                 return _("1 hour ago", "%(hours)d hours ago",
276                          hours) % { "hours": hours }
277
278             if days == 0:
279                 format = _("%(time)s")
280             elif days == 1 and local_date.day == local_yesterday.day and \
281                  relative:
282                 format = _("yesterday") if shorter else \
283                          _("yesterday at %(time)s")
284             elif days < 5:
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")
290
291         if format is None:
292             format = _("%(month_name)s %(day)s, %(year)s") if shorter else \
293                      _("%(month_name)s %(day)s, %(year)s at %(time)s")
294
295         tfhour_clock = self.code not in ("en", "en_US", "zh_CN")
296         if tfhour_clock:
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)
302         else:
303             str_time = "%d:%02d %s" % (
304                 local_date.hour % 12 or 12, local_date.minute,
305                 ("am", "pm")[local_date.hour >= 12])
306
307         return format % {
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),
312             "time": str_time
313         }
314
315     def format_day(self, date, gmt_offset=0, dow=True):
316         """Formats the given date as a day of week.
317
318         Example: "Monday, January 22". You can remove the day of week with
319         dow=False.
320         """
321         local_date = date - datetime.timedelta(minutes=gmt_offset)
322         _ = self.translate
323         if dow:
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),
328             }
329         else:
330             return _("%(month_name)s %(day)s") % {
331                 "month_name": self._months[local_date.month - 1],
332                 "day": str(local_date.day),
333             }
334
335     def list(self, parts):
336         """Returns a comma-separated list for the given list of parts.
337
338         The format is, e.g., "A, B and C", "A and B" or just "A" for lists
339         of size 1.
340         """
341         _ = self.translate
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],
348         }
349
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"):
353             return str(value)
354         value = str(value)
355         parts = []
356         while value:
357             parts.append(value[-3:])
358             value = value[:-3]
359         return ",".join(reversed(parts))
360
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.
365
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.
369         """
370         if plural_message is not None:
371             assert count is not None
372             if count != 1:
373                 message = plural_message
374                 message_dict = self.translations.get("plural", {})
375             else:
376                 message_dict = self.translations.get("singular", {})
377         else:
378             message_dict = self.translations.get("unknown", {})
379         return message_dict.get(message, message)
380
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)
387         else:
388             return self.translations.ugettext(message)
389
390 LOCALE_NAMES = {
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)"},
453 }