# -*- coding: utf-8 -*- """ webapp2_extras.i18n =================== Internationalization support for webapp2. Several ideas borrowed from tipfy.i18n and Flask-Babel. :copyright: 2011 by tipfy.org. :license: Apache Sotware License, see LICENSE for details. """ import datetime import gettext as gettext_stdlib import babel from babel import dates from babel import numbers from babel import support try: # Monkeypatches pytz for gae. import pytz.gae except ImportError: # pragma: no cover pass import pytz import webapp2 #: Default configuration values for this module. Keys are: #: #: translations_path #: Path to the translations directory. Default is `locale`. #: #: domains #: List of gettext domains to be used. Default is ``['messages']``. #: #: default_locale #: A locale code to be used as fallback. Default is ``'en_US'``. #: #: default_timezone #: The application default timezone according to the Olson #: database. Default is ``'UTC'``. #: #: locale_selector #: A function that receives (store, request) and returns a locale #: to be used for a request. If not defined, uses `default_locale`. #: Can also be a string in dotted notation to be imported. #: #: timezone_selector #: A function that receives (store, request) and returns a timezone #: to be used for a request. If not defined, uses `default_timezone`. #: Can also be a string in dotted notation to be imported. #: #: date_formats #: Default date formats for datetime, date and time. default_config = { 'translations_path': 'locale', 'domains': ['messages'], 'default_locale': 'en_US', 'default_timezone': 'UTC', 'locale_selector': None, 'timezone_selector': None, 'date_formats': { 'time': 'medium', 'date': 'medium', 'datetime': 'medium', 'time.short': None, 'time.medium': None, 'time.full': None, 'time.long': None, 'time.iso': "HH':'mm':'ss", 'date.short': None, 'date.medium': None, 'date.full': None, 'date.long': None, 'date.iso': "yyyy'-'MM'-'dd", 'datetime.short': None, 'datetime.medium': None, 'datetime.full': None, 'datetime.long': None, 'datetime.iso': "yyyy'-'MM'-'dd'T'HH':'mm':'ssZ", }, } NullTranslations = gettext_stdlib.NullTranslations class I18nStore(object): """Internalization store. Caches loaded translations and configuration to be used between requests. """ #: Configuration key. config_key = __name__ #: A dictionary with all loaded translations. translations = None #: Path to where traslations are stored. translations_path = None #: Translation domains to merge. domains = None #: Default locale code. default_locale = None #: Default timezone code. default_timezone = None #: Dictionary of default date formats. date_formats = None #: A callable that returns the locale for a request. locale_selector = None #: A callable that returns the timezone for a request. timezone_selector = None def __init__(self, app, config=None): """Initializes the i18n store. :param app: A :class:`webapp2.WSGIApplication` instance. :param config: A dictionary of configuration values to be overridden. See the available keys in :data:`default_config`. """ config = app.config.load_config(self.config_key, default_values=default_config, user_values=config, required_keys=None) self.translations = {} self.translations_path = config['translations_path'] self.domains = config['domains'] self.default_locale = config['default_locale'] self.default_timezone = config['default_timezone'] self.date_formats = config['date_formats'] self.set_locale_selector(config['locale_selector']) self.set_timezone_selector(config['timezone_selector']) def set_locale_selector(self, func): """Sets the function that defines the locale for a request. :param func: A callable that receives (store, request) and returns the locale for a request. """ if func is None: self.locale_selector = self.default_locale_selector else: if isinstance(func, basestring): func = webapp2.import_string(func) # Functions are descriptors, so bind it to this instance with # __get__. self.locale_selector = func.__get__(self, self.__class__) def set_timezone_selector(self, func): """Sets the function that defines the timezone for a request. :param func: A callable that receives (store, request) and returns the timezone for a request. """ if func is None: self.timezone_selector = self.default_timezone_selector else: if isinstance(func, basestring): func = webapp2.import_string(func) self.timezone_selector = func.__get__(self, self.__class__) def default_locale_selector(self, request): return self.default_locale def default_timezone_selector(self, request): return self.default_timezone def get_translations(self, locale): """Returns a translation catalog for a locale. :param locale: A locale code. :returns: A ``babel.support.Translations`` instance, or ``gettext.NullTranslations`` if none was found. """ trans = self.translations.get(locale) if not trans: locales = (locale, self.default_locale) trans = self.load_translations(self.translations_path, locales, self.domains) if not webapp2.get_app().debug: self.translations[locale] = trans return trans def load_translations(self, dirname, locales, domains): """Loads a translation catalog. :param dirname: Path to where translations are stored. :param locales: A list of locale codes. :param domains: A list of domains to be merged. :returns: A ``babel.support.Translations`` instance, or ``gettext.NullTranslations`` if none was found. """ trans = None trans_null = None for domain in domains: _trans = support.Translations.load(dirname, locales, domain) if isinstance(_trans, NullTranslations): trans_null = _trans continue elif trans is None: trans = _trans else: trans.merge(_trans) return trans or trans_null or NullTranslations() class I18n(object): """Internalization provider for a single request.""" #: A reference to :class:`I18nStore`. store = None #: The current locale code. locale = None #: The current translations. translations = None #: The current timezone code. timezone = None #: The current tzinfo object. tzinfo = None def __init__(self, request): """Initializes the i18n provider for a request. :param request: A :class:`webapp2.Request` instance. """ self.store = store = get_store(app=request.app) self.set_locale(store.locale_selector(request)) self.set_timezone(store.timezone_selector(request)) def set_locale(self, locale): """Sets the locale code for this request. :param locale: A locale code. """ self.locale = locale self.translations = self.store.get_translations(locale) def set_timezone(self, timezone): """Sets the timezone code for this request. :param timezone: A timezone code. """ self.timezone = timezone self.tzinfo = pytz.timezone(timezone) def gettext(self, string, **variables): """Translates a given string according to the current locale. :param string: The string to be translated. :param variables: Variables to format the returned string. :returns: The translated string. """ if variables: return self.translations.ugettext(string) % variables return self.translations.ugettext(string) def ngettext(self, singular, plural, n, **variables): """Translates a possible pluralized string according to the current locale. :param singular: The singular for of the string to be translated. :param plural: The plural for of the string to be translated. :param n: An integer indicating if this is a singular or plural. If greater than 1, it is a plural. :param variables: Variables to format the returned string. :returns: The translated string. """ if variables: return self.translations.ungettext(singular, plural, n) % variables return self.translations.ungettext(singular, plural, n) def to_local_timezone(self, datetime): """Returns a datetime object converted to the local timezone. :param datetime: A ``datetime`` object. :returns: A ``datetime`` object normalized to a timezone. """ if datetime.tzinfo is None: datetime = datetime.replace(tzinfo=pytz.UTC) return self.tzinfo.normalize(datetime.astimezone(self.tzinfo)) def to_utc(self, datetime): """Returns a datetime object converted to UTC and without tzinfo. :param datetime: A ``datetime`` object. :returns: A naive ``datetime`` object (no timezone), converted to UTC. """ if datetime.tzinfo is None: datetime = self.tzinfo.localize(datetime) return datetime.astimezone(pytz.UTC).replace(tzinfo=None) def _get_format(self, key, format): """A helper for the datetime formatting functions. Returns a format name or pattern to be used by Babel date format functions. :param key: A format key to be get from config. Valid values are "date", "datetime" or "time". :param format: The format to be returned. Valid values are "short", "medium", "long", "full" or a custom date/time pattern. :returns: A format name or pattern to be used by Babel date format functions. """ if format is None: format = self.store.date_formats.get(key) if format in ('short', 'medium', 'full', 'long', 'iso'): rv = self.store.date_formats.get('%s.%s' % (key, format)) if rv is not None: format = rv return format def format_date(self, date=None, format=None, rebase=True): """Returns a date formatted according to the given pattern and following the current locale. :param date: A ``date`` or ``datetime`` object. If None, the current date in UTC is used. :param format: The format to be returned. Valid values are "short", "medium", "long", "full" or a custom date/time pattern. Example outputs: - short: 11/10/09 - medium: Nov 10, 2009 - long: November 10, 2009 - full: Tuesday, November 10, 2009 :param rebase: If True, converts the date to the current :attr:`timezone`. :returns: A formatted date in unicode. """ format = self._get_format('date', format) if rebase and isinstance(date, datetime.datetime): date = self.to_local_timezone(date) return dates.format_date(date, format, locale=self.locale) def format_datetime(self, datetime=None, format=None, rebase=True): """Returns a date and time formatted according to the given pattern and following the current locale and timezone. :param datetime: A ``datetime`` object. If None, the current date and time in UTC is used. :param format: The format to be returned. Valid values are "short", "medium", "long", "full" or a custom date/time pattern. Example outputs: - short: 11/10/09 4:36 PM - medium: Nov 10, 2009 4:36:05 PM - long: November 10, 2009 4:36:05 PM +0000 - full: Tuesday, November 10, 2009 4:36:05 PM World (GMT) Time :param rebase: If True, converts the datetime to the current :attr:`timezone`. :returns: A formatted date and time in unicode. """ format = self._get_format('datetime', format) kwargs = {} if rebase: kwargs['tzinfo'] = self.tzinfo return dates.format_datetime(datetime, format, locale=self.locale, **kwargs) def format_time(self, time=None, format=None, rebase=True): """Returns a time formatted according to the given pattern and following the current locale and timezone. :param time: A ``time`` or ``datetime`` object. If None, the current time in UTC is used. :param format: The format to be returned. Valid values are "short", "medium", "long", "full" or a custom date/time pattern. Example outputs: - short: 4:36 PM - medium: 4:36:05 PM - long: 4:36:05 PM +0000 - full: 4:36:05 PM World (GMT) Time :param rebase: If True, converts the time to the current :attr:`timezone`. :returns: A formatted time in unicode. """ format = self._get_format('time', format) kwargs = {} if rebase: kwargs['tzinfo'] = self.tzinfo return dates.format_time(time, format, locale=self.locale, **kwargs) def format_timedelta(self, datetime_or_timedelta, granularity='second', threshold=.85): """Formats the elapsed time from the given date to now or the given timedelta. This currently requires an unreleased development version of Babel. :param datetime_or_timedelta: A ``timedelta`` object representing the time difference to format, or a ``datetime`` object in UTC. :param granularity: Determines the smallest unit that should be displayed, the value can be one of "year", "month", "week", "day", "hour", "minute" or "second". :param threshold: Factor that determines at which point the presentation switches to the next higher unit. :returns: A string with the elapsed time. """ if isinstance(datetime_or_timedelta, datetime.datetime): datetime_or_timedelta = datetime.datetime.utcnow() - \ datetime_or_timedelta return dates.format_timedelta(datetime_or_timedelta, granularity, threshold=threshold, locale=self.locale) def format_number(self, number): """Returns the given number formatted for the current locale. Example:: >>> format_number(1099, locale='en_US') u'1,099' :param number: The number to format. :returns: The formatted number. """ return numbers.format_number(number, locale=self.locale) def format_decimal(self, number, format=None): """Returns the given decimal number formatted for the current locale. Example:: >>> format_decimal(1.2345, locale='en_US') u'1.234' >>> format_decimal(1.2346, locale='en_US') u'1.235' >>> format_decimal(-1.2346, locale='en_US') u'-1.235' >>> format_decimal(1.2345, locale='sv_SE') u'1,234' >>> format_decimal(12345, locale='de') u'12.345' The appropriate thousands grouping and the decimal separator are used for each locale:: >>> format_decimal(12345.5, locale='en_US') u'12,345.5' :param number: The number to format. :param format: Notation format. :returns: The formatted decimal number. """ return numbers.format_decimal(number, format=format, locale=self.locale) def format_currency(self, number, currency, format=None): """Returns a formatted currency value. Example:: >>> format_currency(1099.98, 'USD', locale='en_US') u'$1,099.98' >>> format_currency(1099.98, 'USD', locale='es_CO') u'US$\\xa01.099,98' >>> format_currency(1099.98, 'EUR', locale='de_DE') u'1.099,98\\xa0\\u20ac' The pattern can also be specified explicitly:: >>> format_currency(1099.98, 'EUR', u'\\xa4\\xa4 #,##0.00', ... locale='en_US') u'EUR 1,099.98' :param number: The number to format. :param currency: The currency code. :param format: Notation format. :returns: The formatted currency value. """ return numbers.format_currency(number, currency, format=format, locale=self.locale) def format_percent(self, number, format=None): """Returns formatted percent value for the current locale. Example:: >>> format_percent(0.34, locale='en_US') u'34%' >>> format_percent(25.1234, locale='en_US') u'2,512%' >>> format_percent(25.1234, locale='sv_SE') u'2\\xa0512\\xa0%' The format pattern can also be specified explicitly:: >>> format_percent(25.1234, u'#,##0\u2030', locale='en_US') u'25,123\u2030' :param number: The percent number to format :param format: Notation format. :returns: The formatted percent number. """ return numbers.format_percent(number, format=format, locale=self.locale) def format_scientific(self, number, format=None): """Returns value formatted in scientific notation for the current locale. Example:: >>> format_scientific(10000, locale='en_US') u'1E4' The format pattern can also be specified explicitly:: >>> format_scientific(1234567, u'##0E00', locale='en_US') u'1.23E06' :param number: The number to format. :param format: Notation format. :returns: Value formatted in scientific notation. """ return numbers.format_scientific(number, format=format, locale=self.locale) def parse_date(self, string): """Parses a date from a string. This function uses the date format for the locale as a hint to determine the order in which the date fields appear in the string. Example:: >>> parse_date('4/1/04', locale='en_US') datetime.date(2004, 4, 1) >>> parse_date('01.04.2004', locale='de_DE') datetime.date(2004, 4, 1) :param string: The string containing the date. :returns: The parsed date object. """ return dates.parse_date(string, locale=self.locale) def parse_datetime(self, string): """Parses a date and time from a string. This function uses the date and time formats for the locale as a hint to determine the order in which the time fields appear in the string. :param string: The string containing the date and time. :returns: The parsed datetime object. """ return dates.parse_datetime(string, locale=self.locale) def parse_time(self, string): """Parses a time from a string. This function uses the time format for the locale as a hint to determine the order in which the time fields appear in the string. Example:: >>> parse_time('15:30:00', locale='en_US') datetime.time(15, 30) :param string: The string containing the time. :returns: The parsed time object. """ return dates.parse_time(string, locale=self.locale) def parse_number(self, string): """Parses localized number string into a long integer. Example:: >>> parse_number('1,099', locale='en_US') 1099L >>> parse_number('1.099', locale='de_DE') 1099L When the given string cannot be parsed, an exception is raised:: >>> parse_number('1.099,98', locale='de') Traceback (most recent call last): ... NumberFormatError: '1.099,98' is not a valid number :param string: The string to parse. :returns: The parsed number. :raises: ``NumberFormatError`` if the string can not be converted to a number. """ return numbers.parse_number(string, locale=self.locale) def parse_decimal(self, string): """Parses localized decimal string into a float. Example:: >>> parse_decimal('1,099.98', locale='en_US') 1099.98 >>> parse_decimal('1.099,98', locale='de') 1099.98 When the given string cannot be parsed, an exception is raised:: >>> parse_decimal('2,109,998', locale='de') Traceback (most recent call last): ... NumberFormatError: '2,109,998' is not a valid decimal number :param string: The string to parse. :returns: The parsed decimal number. :raises: ``NumberFormatError`` if the string can not be converted to a decimal number. """ return numbers.parse_decimal(string, locale=self.locale) def get_timezone_location(self, dt_or_tzinfo): """Returns a representation of the given timezone using "location format". The result depends on both the local display name of the country and the city assocaited with the time zone:: >>> from pytz import timezone >>> tz = timezone('America/St_Johns') >>> get_timezone_location(tz, locale='de_DE') u"Kanada (St. John's)" >>> tz = timezone('America/Mexico_City') >>> get_timezone_location(tz, locale='de_DE') u'Mexiko (Mexiko-Stadt)' If the timezone is associated with a country that uses only a single timezone, just the localized country name is returned:: >>> tz = timezone('Europe/Berlin') >>> get_timezone_name(tz, locale='de_DE') u'Deutschland' :param dt_or_tzinfo: The ``datetime`` or ``tzinfo`` object that determines the timezone; if None, the current date and time in UTC is assumed. :returns: The localized timezone name using location format. """ return dates.get_timezone_name(dt_or_tzinfo, locale=self.locale) def gettext(string, **variables): """See :meth:`I18n.gettext`.""" return get_i18n().gettext(string, **variables) def ngettext(singular, plural, n, **variables): """See :meth:`I18n.ngettext`.""" return get_i18n().ngettext(singular, plural, n, **variables) def to_local_timezone(datetime): """See :meth:`I18n.to_local_timezone`.""" return get_i18n().to_local_timezone(datetime) def to_utc(datetime): """See :meth:`I18n.to_utc`.""" return get_i18n().to_utc(datetime) def format_date(date=None, format=None, rebase=True): """See :meth:`I18n.format_date`.""" return get_i18n().format_date(date, format, rebase) def format_datetime(datetime=None, format=None, rebase=True): """See :meth:`I18n.format_datetime`.""" return get_i18n().format_datetime(datetime, format, rebase) def format_time(time=None, format=None, rebase=True): """See :meth:`I18n.format_time`.""" return get_i18n().format_time(time, format, rebase) def format_timedelta(datetime_or_timedelta, granularity='second', threshold=.85): """See :meth:`I18n.format_timedelta`.""" return get_i18n().format_timedelta(datetime_or_timedelta, granularity, threshold) def format_number(number): """See :meth:`I18n.format_number`.""" return get_i18n().format_number(number) def format_decimal(number, format=None): """See :meth:`I18n.format_decimal`.""" return get_i18n().format_decimal(number, format) def format_currency(number, currency, format=None): """See :meth:`I18n.format_currency`.""" return get_i18n().format_currency(number, currency, format) def format_percent(number, format=None): """See :meth:`I18n.format_percent`.""" return get_i18n().format_percent(number, format) def format_scientific(number, format=None): """See :meth:`I18n.format_scientific`.""" return get_i18n().format_scientific(number, format) def parse_date(string): """See :meth:`I18n.parse_date`""" return get_i18n().parse_date(string) def parse_datetime(string): """See :meth:`I18n.parse_datetime`.""" return get_i18n().parse_datetime(string) def parse_time(string): """See :meth:`I18n.parse_time`.""" return get_i18n().parse_time(string) def parse_number(string): """See :meth:`I18n.parse_number`.""" return get_i18n().parse_number(string) def parse_decimal(string): """See :meth:`I18n.parse_decimal`.""" return get_i18n().parse_decimal(string) def get_timezone_location(dt_or_tzinfo): """See :meth:`I18n.get_timezone_location`.""" return get_i18n().get_timezone_location(dt_or_tzinfo) def lazy_gettext(string, **variables): """A lazy version of :func:`gettext`. :param string: The string to be translated. :param variables: Variables to format the returned string. :returns: A ``babel.support.LazyProxy`` object that when accessed translates the string. """ return support.LazyProxy(gettext, string, **variables) # Aliases. _ = gettext _lazy = lazy_gettext # Factories ------------------------------------------------------------------- #: Key used to store :class:`I18nStore` in the app registry. _store_registry_key = 'webapp2_extras.i18n.I18nStore' #: Key used to store :class:`I18n` in the request registry. _i18n_registry_key = 'webapp2_extras.i18n.I18n' def get_store(factory=I18nStore, key=_store_registry_key, app=None): """Returns an instance of :class:`I18nStore` from the app registry. It'll try to get it from the current app registry, and if it is not registered it'll be instantiated and registered. A second call to this function will return the same instance. :param factory: The callable used to build and register the instance if it is not yet registered. The default is the class :class:`I18nStore` itself. :param key: The key used to store the instance in the registry. A default is used if it is not set. :param app: A :class:`webapp2.WSGIApplication` instance used to store the instance. The active app is used if it is not set. """ app = app or webapp2.get_app() store = app.registry.get(key) if not store: store = app.registry[key] = factory(app) return store def set_store(store, key=_store_registry_key, app=None): """Sets an instance of :class:`I18nStore` in the app registry. :param store: An instance of :class:`I18nStore`. :param key: The key used to retrieve the instance from the registry. A default is used if it is not set. :param request: A :class:`webapp2.WSGIApplication` instance used to retrieve the instance. The active app is used if it is not set. """ app = app or webapp2.get_app() app.registry[key] = store def get_i18n(factory=I18n, key=_i18n_registry_key, request=None): """Returns an instance of :class:`I18n` from the request registry. It'll try to get it from the current request registry, and if it is not registered it'll be instantiated and registered. A second call to this function will return the same instance. :param factory: The callable used to build and register the instance if it is not yet registered. The default is the class :class:`I18n` itself. :param key: The key used to store the instance in the registry. A default is used if it is not set. :param request: A :class:`webapp2.Request` instance used to store the instance. The active request is used if it is not set. """ request = request or webapp2.get_request() i18n = request.registry.get(key) if not i18n: i18n = request.registry[key] = factory(request) return i18n def set_i18n(i18n, key=_i18n_registry_key, request=None): """Sets an instance of :class:`I18n` in the request registry. :param store: An instance of :class:`I18n`. :param key: The key used to retrieve the instance from the registry. A default is used if it is not set. :param request: A :class:`webapp2.Request` instance used to retrieve the instance. The active request is used if it is not set. """ request = request or webapp2.get_request() request.registry[key] = i18n