# HG changeset patch # User cmlenz # Date 1182334147 0 # Node ID 4a7af44e6695369ed3760c4357a53ffaec4e4ec3 # Parent bf36ec5f5e50fec4d87733ba3a3bb9c0ac0c9d21 Create branch for 0.8.x releases. diff --git a/0.8.x/COPYING b/0.8.x/COPYING new file mode 100644 --- /dev/null +++ b/0.8.x/COPYING @@ -0,0 +1,28 @@ +Copyright (C) 2007 Edgewall Software +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS +OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER +IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR +OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN +IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/0.8.x/ChangeLog b/0.8.x/ChangeLog new file mode 100644 --- /dev/null +++ b/0.8.x/ChangeLog @@ -0,0 +1,5 @@ +Version 0.8 +http://svn.edgewall.org/repos/babel/tags/0.8.0/ +(Jun 20 2007, from branches/stable/0.8.x) + + * First public release diff --git a/0.8.x/INSTALL.txt b/0.8.x/INSTALL.txt new file mode 100644 --- /dev/null +++ b/0.8.x/INSTALL.txt @@ -0,0 +1,38 @@ +Installing Babel +================ + +Prerequisites +------------- + + * Python 2.3 or later (2.4 or later is recommended) + * Optional: setuptools 0.6b1 or later + * Optional: pytz (strongly recommended for real time-zone support) + + +Installation +------------ + +Once you've downloaded and unpacked a Babel source release, enter the +directory where the archive was unpacked, and run: + + $ python setup.py install + +Note that you may need administrator/root privileges for this step, as +this command will by default attempt to install Babel to the Python +site-packages directory on your system. + +For advanced options, please refer to the easy_install and/or the distutils +documentation: + + http://peak.telecommunity.com/DevCenter/EasyInstall + http://docs.python.org/inst/inst.html + + +Support +------- + +If you encounter any problems with Babel, please don't hesitate to ask +questions on the Babel mailing list or IRC channel: + + http://babel.edgewall.org/wiki/MailingList + http://babel.edgewall.org/wiki/IrcChannel diff --git a/0.8.x/MANIFEST.in b/0.8.x/MANIFEST.in new file mode 100644 --- /dev/null +++ b/0.8.x/MANIFEST.in @@ -0,0 +1,3 @@ +include babel/localedata/*.dat +include doc/api/*.* +include doc/*.html diff --git a/0.8.x/README.txt b/0.8.x/README.txt new file mode 100644 --- /dev/null +++ b/0.8.x/README.txt @@ -0,0 +1,12 @@ +About Babel +=========== + +Babel is a Python library that provides an integrated collection of +utilities that assist with internationalizing and localizing Python +applications (in particular web-based applications.) + +Details can be found in the HTML files in the `doc` folder. + +For more information please visit the Babel web site: + + diff --git a/0.8.x/babel/__init__.py b/0.8.x/babel/__init__.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/__init__.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Integrated collection of utilities that assist in internationalizing and +localizing applications. + +This package is basically composed of two major parts: + + * tools to build and work with ``gettext`` message catalogs + * a Python interface to the CLDR (Common Locale Data Repository), providing + access to various locale display names, localized number and date + formatting, etc. + +:see: http://www.gnu.org/software/gettext/ +:see: http://docs.python.org/lib/module-gettext.html +:see: http://www.unicode.org/cldr/ +""" + +from babel.core import * + +__docformat__ = 'restructuredtext en' +try: + __version__ = __import__('pkg_resources').get_distribution('Babel').version +except ImportError: + pass diff --git a/0.8.x/babel/core.py b/0.8.x/babel/core.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/core.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Core locale representation and locale data access.""" + +import os + +from babel import localedata + +__all__ = ['UnknownLocaleError', 'Locale', 'default_locale', 'negotiate_locale', + 'parse_locale'] +__docformat__ = 'restructuredtext en' + + +class UnknownLocaleError(Exception): + """Exception thrown when a locale is requested for which no locale data + is available. + """ + + def __init__(self, identifier): + """Create the exception. + + :param identifier: the identifier string of the unsupported locale + """ + Exception.__init__(self, 'unknown locale %r' % identifier) + self.identifier = identifier + + +class Locale(object): + """Representation of a specific locale. + + >>> locale = Locale('en', territory='US') + >>> repr(locale) + '' + >>> locale.display_name + u'English (United States)' + + A `Locale` object can also be instantiated from a raw locale string: + + >>> locale = Locale.parse('en-US', sep='-') + >>> repr(locale) + '' + + `Locale` objects provide access to a collection of locale data, such as + territory and language names, number and date format patterns, and more: + + >>> locale.number_symbols['decimal'] + u'.' + + If a locale is requested for which no locale data is available, an + `UnknownLocaleError` is raised: + + >>> Locale.parse('en_DE') + Traceback (most recent call last): + ... + UnknownLocaleError: unknown locale 'en_DE' + + :see: `IETF RFC 3066 `_ + """ + + def __init__(self, language, territory=None, variant=None): + """Initialize the locale object from the given identifier components. + + >>> locale = Locale('en', 'US') + >>> locale.language + 'en' + >>> locale.territory + 'US' + + :param language: the language code + :param territory: the territory (country or region) code + :param variant: the variant code + :raise `UnknownLocaleError`: if no locale data is available for the + requested locale + """ + self.language = language + self.territory = territory + self.variant = variant + self.__data = None + + identifier = str(self) + if not localedata.exists(identifier): + raise UnknownLocaleError(identifier) + + def default(cls, category=None): + """Return the system default locale for the specified category. + + >>> for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE']: + ... os.environ[name] = '' + >>> os.environ['LANG'] = 'fr_FR.UTF-8' + >>> Locale.default('LC_MESSAGES') + + + :param category: one of the ``LC_XXX`` environment variable names + :return: the value of the variable, or any of the fallbacks + (``LANGUAGE``, ``LC_ALL``, ``LC_CTYPE``, and ``LANG``) + :rtype: `Locale` + """ + return cls(default_locale(category)) + default = classmethod(default) + + def negotiate(cls, preferred, available, sep='_'): + """Find the best match between available and requested locale strings. + + >>> Locale.negotiate(['de_DE', 'en_US'], ['de_DE', 'de_AT']) + + >>> Locale.negotiate(['de_DE', 'en_US'], ['en', 'de']) + + >>> Locale.negotiate(['de_DE', 'de'], ['en_US']) + + :param preferred: the list of locale identifers preferred by the user + :param available: the list of locale identifiers available + :return: the `Locale` object for the best match, or `None` if no match + was found + :rtype: `Locale` + """ + identifier = negotiate_locale(preferred, available, sep=sep) + if identifier: + return Locale.parse(identifier) + negotiate = classmethod(negotiate) + + def parse(cls, identifier, sep='_'): + """Create a `Locale` instance for the given locale identifier. + + >>> l = Locale.parse('de-DE', sep='-') + >>> l.display_name + u'Deutsch (Deutschland)' + + If the `identifier` parameter is not a string, but actually a `Locale` + object, that object is returned: + + >>> Locale.parse(l) + + + :param identifier: the locale identifier string + :param sep: optional component separator + :return: a corresponding `Locale` instance + :rtype: `Locale` + :raise `ValueError`: if the string does not appear to be a valid locale + identifier + :raise `UnknownLocaleError`: if no locale data is available for the + requested locale + """ + if type(identifier) is cls: + return identifier + return cls(*parse_locale(identifier, sep=sep)) + parse = classmethod(parse) + + def __eq__(self, other): + return str(self) == str(other) + + def __repr__(self): + return '' % str(self) + + def __str__(self): + return '_'.join(filter(None, [self.language, self.territory, + self.variant])) + + def _data(self): + if self.__data is None: + self.__data = localedata.load(str(self)) + return self.__data + _data = property(_data) + + def display_name(self): + retval = self.languages.get(self.language) + if self.territory: + variant = '' + if self.variant: + variant = ', %s' % self.variants.get(self.variant) + retval += ' (%s%s)' % (self.territories.get(self.territory), + variant) + return retval + display_name = property(display_name, doc="""\ + The localized display name of the locale. + + >>> Locale('en').display_name + u'English' + >>> Locale('en', 'US').display_name + u'English (United States)' + >>> Locale('sv').display_name + u'svenska' + + :type: `unicode` + """) + + def english_name(self): + en = Locale('en') + retval = en.languages.get(self.language) + if self.territory: + variant = '' + if self.variant: + variant = ', %s' % en.variants.get(self.variant) + retval += ' (%s%s)' % (en.territories.get(self.territory), + variant) + return retval + english_name = property(english_name, doc="""\ + The english display name of the locale. + + >>> Locale('de').english_name + u'German' + >>> Locale('de', 'DE').english_name + u'German (Germany)' + + :type: `unicode` + """) + + #{ General Locale Display Names + + def languages(self): + return self._data['languages'] + languages = property(languages, doc="""\ + Mapping of language codes to translated language names. + + >>> Locale('de', 'DE').languages['ja'] + u'Japanisch' + + :type: `dict` + :see: `ISO 639 `_ + """) + + def scripts(self): + return self._data['scripts'] + scripts = property(scripts, doc="""\ + Mapping of script codes to translated script names. + + >>> Locale('en', 'US').scripts['Hira'] + u'Hiragana' + + :type: `dict` + :see: `ISO 15924 `_ + """) + + def territories(self): + return self._data['territories'] + territories = property(territories, doc="""\ + Mapping of script codes to translated script names. + + >>> Locale('es', 'CO').territories['DE'] + u'Alemania' + + :type: `dict` + :see: `ISO 3166 `_ + """) + + def variants(self): + return self._data['variants'] + variants = property(variants, doc="""\ + Mapping of script codes to translated script names. + + >>> Locale('de', 'DE').variants['1901'] + u'alte deutsche Rechtschreibung' + + :type: `dict` + """) + + #{ Number Formatting + + def currencies(self): + return self._data['currency_names'] + currencies = property(currencies, doc="""\ + Mapping of currency codes to translated currency names. + + >>> Locale('en').currencies['COP'] + u'Colombian Peso' + >>> Locale('de', 'DE').currencies['COP'] + u'Kolumbianischer Peso' + + :type: `dict` + """) + + def currency_symbols(self): + return self._data['currency_symbols'] + currency_symbols = property(currency_symbols, doc="""\ + Mapping of currency codes to symbols. + + >>> Locale('en').currency_symbols['USD'] + u'US$' + >>> Locale('en', 'US').currency_symbols['USD'] + u'$' + + :type: `dict` + """) + + def number_symbols(self): + return self._data['number_symbols'] + number_symbols = property(number_symbols, doc="""\ + Symbols used in number formatting. + + >>> Locale('fr', 'FR').number_symbols['decimal'] + u',' + + :type: `dict` + """) + + def decimal_formats(self): + return self._data['decimal_formats'] + decimal_formats = property(decimal_formats, doc="""\ + Locale patterns for decimal number formatting. + + >>> Locale('en', 'US').decimal_formats[None] + + + :type: `dict` + """) + + def currency_formats(self): + return self._data['currency_formats'] + currency_formats = property(currency_formats, doc=r"""\ + Locale patterns for currency number formatting. + + >>> print Locale('en', 'US').currency_formats[None] + + + :type: `dict` + """) + + def percent_formats(self): + return self._data['percent_formats'] + percent_formats = property(percent_formats, doc="""\ + Locale patterns for percent number formatting. + + >>> Locale('en', 'US').percent_formats[None] + + + :type: `dict` + """) + + def scientific_formats(self): + return self._data['scientific_formats'] + scientific_formats = property(scientific_formats, doc="""\ + Locale patterns for scientific number formatting. + + >>> Locale('en', 'US').scientific_formats[None] + + + :type: `dict` + """) + + #{ Calendar Information and Date Formatting + + def periods(self): + return self._data['periods'] + periods = property(periods, doc="""\ + Locale display names for day periods (AM/PM). + + >>> Locale('en', 'US').periods['am'] + u'AM' + + :type: `dict` + """) + + def days(self): + return self._data['days'] + days = property(days, doc="""\ + Locale display names for weekdays. + + >>> Locale('de', 'DE').days['format']['wide'][3] + u'Donnerstag' + + :type: `dict` + """) + + def months(self): + return self._data['months'] + months = property(months, doc="""\ + Locale display names for months. + + >>> Locale('de', 'DE').months['format']['wide'][10] + u'Oktober' + + :type: `dict` + """) + + def quarters(self): + return self._data['quarters'] + quarters = property(quarters, doc="""\ + Locale display names for quarters. + + >>> Locale('de', 'DE').quarters['format']['wide'][1] + u'1. Quartal' + + :type: `dict` + """) + + def eras(self): + return self._data['eras'] + eras = property(eras, doc="""\ + Locale display names for eras. + + >>> Locale('en', 'US').eras['wide'][1] + u'Anno Domini' + >>> Locale('en', 'US').eras['abbreviated'][0] + u'BC' + + :type: `dict` + """) + + def time_zones(self): + return self._data['time_zones'] + time_zones = property(time_zones, doc="""\ + Locale display names for time zones. + + >>> Locale('en', 'US').time_zones['America/Los_Angeles']['long']['standard'] + u'Pacific Standard Time' + >>> Locale('en', 'US').time_zones['Europe/Dublin']['city'] + u'Dublin' + + :type: `dict` + """) + + def zone_aliases(self): + return self._data['zone_aliases'] + zone_aliases = property(zone_aliases, doc="""\ + Mapping of time zone aliases to their respective canonical identifer. + + >>> Locale('en').zone_aliases['UTC'] + 'Etc/GMT' + + :type: `dict` + :note: this doesn't really belong here, as it does not change between + locales + """) + + def first_week_day(self): + return self._data['week_data']['first_day'] + first_week_day = property(first_week_day, doc="""\ + The first day of a week. + + >>> Locale('de', 'DE').first_week_day + 0 + >>> Locale('en', 'US').first_week_day + 6 + + :type: `int` + """) + + def weekend_start(self): + return self._data['week_data']['weekend_start'] + weekend_start = property(weekend_start, doc="""\ + The day the weekend starts. + + >>> Locale('de', 'DE').weekend_start + 5 + + :type: `int` + """) + + def weekend_end(self): + return self._data['week_data']['weekend_end'] + weekend_end = property(weekend_end, doc="""\ + The day the weekend ends. + + >>> Locale('de', 'DE').weekend_end + 6 + + :type: `int` + """) + + def min_week_days(self): + return self._data['week_data']['min_days'] + min_week_days = property(min_week_days, doc="""\ + The minimum number of days in a week so that the week is counted as the + first week of a year or month. + + >>> Locale('de', 'DE').min_week_days + 4 + + :type: `int` + """) + + def date_formats(self): + return self._data['date_formats'] + date_formats = property(date_formats, doc="""\ + Locale patterns for date formatting. + + >>> Locale('en', 'US').date_formats['short'] + + >>> Locale('fr', 'FR').date_formats['long'] + + + :type: `dict` + """) + + def time_formats(self): + return self._data['time_formats'] + time_formats = property(time_formats, doc="""\ + Locale patterns for time formatting. + + >>> Locale('en', 'US').time_formats['short'] + + >>> Locale('fr', 'FR').time_formats['long'] + + + :type: `dict` + """) + + def datetime_formats(self): + return self._data['datetime_formats'] + datetime_formats = property(datetime_formats, doc="""\ + Locale patterns for datetime formatting. + + >>> Locale('en').datetime_formats[None] + u'{1} {0}' + >>> Locale('th').datetime_formats[None] + u'{1}, {0}' + + :type: `dict` + """) + + +def default_locale(category=None): + """Returns the system default locale for a given category, based on + environment variables. + + >>> for name in ['LANGUAGE', 'LC_ALL', 'LC_CTYPE']: + ... os.environ[name] = '' + >>> os.environ['LANG'] = 'fr_FR.UTF-8' + >>> default_locale('LC_MESSAGES') + 'fr_FR' + + :param category: one of the ``LC_XXX`` environment variable names + :return: the value of the variable, or any of the fallbacks (``LANGUAGE``, + ``LC_ALL``, ``LC_CTYPE``, and ``LANG``) + + :rtype: `str` + """ + varnames = (category, 'LANGUAGE', 'LC_ALL', 'LC_CTYPE', 'LANG') + for name in filter(None, varnames): + locale = os.getenv(name) + if locale: + return '_'.join(filter(None, parse_locale(locale))) + +def negotiate_locale(preferred, available, sep='_'): + """Find the best match between available and requested locale strings. + + >>> negotiate_locale(['de_DE', 'en_US'], ['de_DE', 'de_AT']) + 'de_DE' + >>> negotiate_locale(['de_DE', 'en_US'], ['en', 'de']) + 'de' + + :param preferred: the list of locale strings preferred by the user + :param available: the list of locale strings available + :param sep: character that separates the different parts of the locale + strings + :return: the locale identifier for the best match, or `None` if no match + was found + :rtype: `str` + """ + for locale in preferred: + if locale in available: + return locale + parts = locale.split(sep) + if len(parts) > 1 and parts[0] in available: + return parts[0] + return None + +def parse_locale(identifier, sep='_'): + """Parse a locale identifier into a ``(language, territory, variant)`` + tuple. + + >>> parse_locale('zh_CN') + ('zh', 'CN', None) + + The default component separator is "_", but a different separator can be + specified using the `sep` parameter: + + >>> parse_locale('zh-CN', sep='-') + ('zh', 'CN', None) + + :param identifier: the locale identifier string + :param sep: character that separates the different parts of the locale + string + :return: the ``(language, territory, variant)`` tuple + :rtype: `tuple` + :raise `ValueError`: if the string does not appear to be a valid locale + identifier + + :see: `IETF RFC 3066 `_ + """ + if '.' in identifier: + # this is probably the charset/encoding, which we don't care about + identifier = identifier.split('.', 1)[0] + parts = identifier.split(sep) + lang, territory, variant = parts[0].lower(), None, None + if not lang.isalpha(): + raise ValueError('expected only letters, got %r' % lang) + if len(parts) > 1: + territory = parts[1].upper().split('.', 1)[0] + if not territory.isalpha(): + raise ValueError('expected only letters, got %r' % territory) + if len(parts) > 2: + variant = parts[2].upper().split('.', 1)[0] + return lang, territory, variant diff --git a/0.8.x/babel/dates.py b/0.8.x/babel/dates.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/dates.py @@ -0,0 +1,643 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Locale dependent formatting and parsing of dates and times. + +The default locale for the functions in this module is determined by the +following environment variables, in that order: + + * ``LC_TIME``, + * ``LC_ALL``, and + * ``LANG`` +""" + +from datetime import date, datetime, time, timedelta, tzinfo +import re + +from babel.core import default_locale, Locale +from babel.util import UTC + +__all__ = ['format_date', 'format_datetime', 'format_time', 'parse_date', + 'parse_datetime', 'parse_time'] +__docformat__ = 'restructuredtext en' + +LC_TIME = default_locale('LC_TIME') + +# Aliases for use in scopes where the modules are shadowed by local variables +date_ = date +datetime_ = datetime +time_ = time + +def get_period_names(locale=LC_TIME): + """Return the names for day periods (AM/PM) used by the locale. + + >>> get_period_names(locale='en_US')['am'] + u'AM' + + :param locale: the `Locale` object, or a locale string + :return: the dictionary of period names + :rtype: `dict` + """ + return Locale.parse(locale).periods + +def get_day_names(width='wide', context='format', locale=LC_TIME): + """Return the day names used by the locale for the specified format. + + >>> get_day_names('wide', locale='en_US')[1] + u'Tuesday' + >>> get_day_names('abbreviated', locale='es')[1] + u'mar' + >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1] + u'D' + + :param width: the width to use, one of "wide", "abbreviated", or "narrow" + :param context: the context, either "format" or "stand-alone" + :param locale: the `Locale` object, or a locale string + :return: the dictionary of day names + :rtype: `dict` + """ + return Locale.parse(locale).days[context][width] + +def get_month_names(width='wide', context='format', locale=LC_TIME): + """Return the month names used by the locale for the specified format. + + >>> get_month_names('wide', locale='en_US')[1] + u'January' + >>> get_month_names('abbreviated', locale='es')[1] + u'ene' + >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1] + u'J' + + :param width: the width to use, one of "wide", "abbreviated", or "narrow" + :param context: the context, either "format" or "stand-alone" + :param locale: the `Locale` object, or a locale string + :return: the dictionary of month names + :rtype: `dict` + """ + return Locale.parse(locale).months[context][width] + +def get_quarter_names(width='wide', context='format', locale=LC_TIME): + """Return the quarter names used by the locale for the specified format. + + >>> get_quarter_names('wide', locale='en_US')[1] + u'1st quarter' + >>> get_quarter_names('abbreviated', locale='de_DE')[1] + u'Q1' + + :param width: the width to use, one of "wide", "abbreviated", or "narrow" + :param context: the context, either "format" or "stand-alone" + :param locale: the `Locale` object, or a locale string + :return: the dictionary of quarter names + :rtype: `dict` + """ + return Locale.parse(locale).quarters[context][width] + +def get_era_names(width='wide', locale=LC_TIME): + """Return the era names used by the locale for the specified format. + + >>> get_era_names('wide', locale='en_US')[1] + u'Anno Domini' + >>> get_era_names('abbreviated', locale='de_DE')[1] + u'n. Chr.' + + :param width: the width to use, either "wide" or "abbreviated" + :param locale: the `Locale` object, or a locale string + :return: the dictionary of era names + :rtype: `dict` + """ + return Locale.parse(locale).eras[width] + +def get_date_format(format='medium', locale=LC_TIME): + """Return the date formatting patterns used by the locale for the specified + format. + + >>> get_date_format(locale='en_US') + + >>> get_date_format('full', locale='de_DE') + + + :param format: the format to use, one of "full", "long", "medium", or + "short" + :param locale: the `Locale` object, or a locale string + :return: the date format pattern + :rtype: `DateTimePattern` + """ + return Locale.parse(locale).date_formats[format] + +def get_datetime_format(format='medium', locale=LC_TIME): + """Return the datetime formatting patterns used by the locale for the + specified format. + + >>> get_datetime_format(locale='en_US') + u'{1} {0}' + + :param format: the format to use, one of "full", "long", "medium", or + "short" + :param locale: the `Locale` object, or a locale string + :return: the datetime format pattern + :rtype: `unicode` + """ + patterns = Locale.parse(locale).datetime_formats + if format not in patterns: + format = None + return patterns[format] + +def get_time_format(format='medium', locale=LC_TIME): + """Return the time formatting patterns used by the locale for the specified + format. + + >>> get_time_format(locale='en_US') + + >>> get_time_format('full', locale='de_DE') + + + :param format: the format to use, one of "full", "long", "medium", or + "short" + :param locale: the `Locale` object, or a locale string + :return: the time format pattern + :rtype: `DateTimePattern` + """ + return Locale.parse(locale).time_formats[format] + +def format_date(date=None, format='medium', locale=LC_TIME): + """Return a date formatted according to the given pattern. + + >>> d = date(2007, 04, 01) + >>> format_date(d, locale='en_US') + u'Apr 1, 2007' + >>> format_date(d, format='full', locale='de_DE') + u'Sonntag, 1. April 2007' + + If you don't want to use the locale default formats, you can specify a + custom date pattern: + + >>> format_date(d, "EEE, MMM d, ''yy", locale='en') + u"Sun, Apr 1, '07" + + :param date: the ``date`` or ``datetime`` object; if `None`, the current + date is used + :param format: one of "full", "long", "medium", or "short", or a custom + date/time pattern + :param locale: a `Locale` object or a locale identifier + :rtype: `unicode` + + :note: If the pattern contains time fields, an `AttributeError` will be + raised when trying to apply the formatting. This is also true if + the value of ``date`` parameter is actually a ``datetime`` object, + as this function automatically converts that to a ``date``. + """ + if date is None: + date = date_.today() + elif isinstance(date, datetime): + date = date.date() + + locale = Locale.parse(locale) + if format in ('full', 'long', 'medium', 'short'): + format = get_date_format(format, locale=locale) + pattern = parse_pattern(format) + return parse_pattern(format).apply(date, locale) + +def format_datetime(datetime=None, format='medium', tzinfo=None, + locale=LC_TIME): + """Return a date formatted according to the given pattern. + + >>> dt = datetime(2007, 04, 01, 15, 30) + >>> format_datetime(dt, locale='en_US') + u'Apr 1, 2007 3:30:00 PM' + + For any pattern requiring the display of the time-zone, the third-party + ``pytz`` package is needed to explicitly specify the time-zone: + + >>> from pytz import timezone + >>> format_datetime(dt, 'full', tzinfo=timezone('Europe/Berlin'), + ... locale='de_DE') + u'Sonntag, 1. April 2007 17:30 Uhr MESZ' + >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", + ... tzinfo=timezone('US/Eastern'), locale='en') + u'2007.04.01 AD at 11:30:00 EDT' + + :param datetime: the `datetime` object; if `None`, the current date and + time is used + :param format: one of "full", "long", "medium", or "short", or a custom + date/time pattern + :param tzinfo: the timezone to apply to the time for display + :param locale: a `Locale` object or a locale identifier + :rtype: `unicode` + """ + if datetime is None: + datetime = datetime_.now() + elif isinstance(datetime, (int, long)): + datetime = datetime.fromtimestamp(datetime) + elif isinstance(datetime, time): + datetime = datetime_.combine(date.today(), datetime) + if datetime.tzinfo is None: + datetime = datetime.replace(tzinfo=UTC) + if tzinfo is not None: + datetime = datetime.astimezone(tzinfo) + if hasattr(tzinfo, 'normalize'): # pytz + datetime = tzinfo.normalize(datetime) + + locale = Locale.parse(locale) + if format in ('full', 'long', 'medium', 'short'): + return get_datetime_format(format, locale=locale) \ + .replace('{0}', format_time(datetime, format, tzinfo=None, + locale=locale)) \ + .replace('{1}', format_date(datetime, format, locale=locale)) + else: + return parse_pattern(format).apply(datetime, locale) + +def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): + """Return a time formatted according to the given pattern. + + >>> t = time(15, 30) + >>> format_time(t, locale='en_US') + u'3:30:00 PM' + >>> format_time(t, format='short', locale='de_DE') + u'15:30' + + If you don't want to use the locale default formats, you can specify a + custom time pattern: + + >>> format_time(t, "hh 'o''clock' a", locale='en') + u"03 o'clock PM" + + For any pattern requiring the display of the time-zone, the third-party + ``pytz`` package is needed to explicitly specify the time-zone: + + >>> from pytz import timezone + >>> t = time(15, 30) + >>> format_time(t, format='full', tzinfo=timezone('Europe/Berlin'), + ... locale='de_DE') + u'17:30 Uhr MESZ' + >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=timezone('US/Eastern'), + ... locale='en') + u"11 o'clock AM, Eastern Daylight Time" + + :param time: the ``time`` or ``datetime`` object; if `None`, the current + time is used + :param format: one of "full", "long", "medium", or "short", or a custom + date/time pattern + :param tzinfo: the time-zone to apply to the time for display + :param locale: a `Locale` object or a locale identifier + :rtype: `unicode` + + :note: If the pattern contains date fields, an `AttributeError` will be + raised when trying to apply the formatting. This is also true if + the value of ``time`` parameter is actually a ``datetime`` object, + as this function automatically converts that to a ``time``. + """ + if time is None: + time = datetime.now().time() + elif isinstance(time, (int, long)): + time = datetime.fromtimestamp(time).time() + elif isinstance(time, datetime): + time = time.timetz() + if time.tzinfo is None: + time = time.replace(tzinfo=UTC) + if tzinfo is not None: + dt = datetime.combine(date.today(), time).astimezone(tzinfo) + if hasattr(tzinfo, 'normalize'): # pytz + dt = tzinfo.normalize(dt) + time = dt.timetz() + + locale = Locale.parse(locale) + if format in ('full', 'long', 'medium', 'short'): + format = get_time_format(format, locale=locale) + return parse_pattern(format).apply(time, locale) + +def parse_date(string, locale=LC_TIME): + """Parse 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. + + >>> 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 + :param locale: a `Locale` object or a locale identifier + :return: the parsed date + :rtype: `date` + """ + # TODO: try ISO format first? + format = get_date_format(locale=locale).pattern.lower() + year_idx = format.index('y') + month_idx = format.index('m') + if month_idx < 0: + month_idx = format.index('l') + day_idx = format.index('d') + + indexes = [(year_idx, 'Y'), (month_idx, 'M'), (day_idx, 'D')] + indexes.sort() + indexes = dict([(item[1], idx) for idx, item in enumerate(indexes)]) + + # FIXME: this currently only supports numbers, but should also support month + # names, both in the requested locale, and english + + numbers = re.findall('(\d+)', string) + year = numbers[indexes['Y']] + if len(year) == 2: + year = 2000 + int(year) + else: + year = int(year) + month = int(numbers[indexes['M']]) + day = int(numbers[indexes['D']]) + if month > 12: + month, day = day, month + return date(year, month, day) + +def parse_datetime(string, locale=LC_TIME): + """Parse 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 + :param locale: a `Locale` object or a locale identifier + :return: the parsed date/time + :rtype: `datetime` + """ + raise NotImplementedError + +def parse_time(string, locale=LC_TIME): + """Parse 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. + + >>> parse_time('15:30:00', locale='en_US') + datetime.time(15, 30) + + :param string: the string containing the time + :param locale: a `Locale` object or a locale identifier + :return: the parsed time + :rtype: `time` + """ + # TODO: try ISO format first? + format = get_time_format(locale=locale).pattern.lower() + hour_idx = format.index('h') + if hour_idx < 0: + hour_idx = format.index('k') + min_idx = format.index('m') + sec_idx = format.index('s') + + indexes = [(hour_idx, 'H'), (min_idx, 'M'), (sec_idx, 'S')] + indexes.sort() + indexes = dict([(item[1], idx) for idx, item in enumerate(indexes)]) + + # FIXME: support 12 hour clock, and 0-based hour specification + # and seconds should be optional, maybe minutes too + # oh, and time-zones, of course + + numbers = re.findall('(\d+)', string) + hour = int(numbers[indexes['H']]) + minute = int(numbers[indexes['M']]) + second = int(numbers[indexes['S']]) + return time(hour, minute, second) + + +class DateTimePattern(object): + + def __init__(self, pattern, format): + self.pattern = pattern + self.format = format + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.pattern) + + def __unicode__(self): + return self.pattern + + def __mod__(self, other): + assert type(other) is DateTimeFormat + return self.format % other + + def apply(self, datetime, locale): + return self % DateTimeFormat(datetime, locale) + + +class DateTimeFormat(object): + + def __init__(self, value, locale): + assert isinstance(value, (date, datetime, time)) + if isinstance(value, (datetime, time)) and value.tzinfo is None: + value = value.replace(tzinfo=UTC) + self.value = value + self.locale = Locale.parse(locale) + + def __getitem__(self, name): + # TODO: a number of fields missing here + char = name[0] + num = len(name) + if char == 'G': + return self.format_era(char, num) + elif char in ('y', 'Y'): + return self.format_year(char, num) + elif char in ('Q', 'q'): + return self.format_quarter(char, num) + elif char in ('M', 'L'): + return self.format_month(char, num) + elif char == 'd': + return self.format(self.value.day, num) + elif char in ('E', 'e', 'c'): + return self.format_weekday(char, num) + elif char == 'a': + return self.format_period(char) + elif char == 'h': + return self.format(self.value.hour % 12, num) + elif char == 'H': + return self.format(self.value.hour, num) + elif char == 'K': + return self.format(self.value.hour % 12 - 1, num) + elif char == 'k': + return self.format(self.value.hour + 1, num) + elif char == 'm': + return self.format(self.value.minute, num) + elif char == 's': + return self.format(self.value.second, num) + elif char in ('z', 'Z', 'v'): + return self.format_timezone(char, num) + else: + raise KeyError('Unsupported date/time field %r' % char) + + def format_era(self, char, num): + width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)] + era = int(self.value.year >= 0) + return get_era_names(width, self.locale)[era] + + def format_year(self, char, num): + if char.islower(): + value = self.value.year + else: + value = self.value.isocalendar()[0] + year = self.format(value, num) + if num == 2: + year = year[-2:] + return year + + def format_month(self, char, num): + if num <= 2: + return ('%%0%dd' % num) % self.value.month + width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num] + context = {3: 'format', 4: 'format', 5: 'stand-alone'}[num] + return get_month_names(width, context, self.locale)[self.value.month] + + def format_weekday(self, char, num): + if num < 3: + if char.islower(): + value = 7 - self.locale.first_week_day + self.value.weekday() + return self.format(value % 7 + 1, num) + num = 3 + weekday = self.value.weekday() + width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[num] + context = {3: 'format', 4: 'format', 5: 'stand-alone'}[num] + return get_day_names(width, context, self.locale)[weekday] + + def format_period(self, char): + period = {0: 'am', 1: 'pm'}[int(self.value.hour > 12)] + return get_period_names(locale=self.locale)[period] + + def format_timezone(self, char, num): + if char in ('z', 'v'): + if hasattr(self.value.tzinfo, 'zone'): + zone = self.value.tzinfo.zone + else: + zone = self.value.tzinfo.tzname(self.value) + + # Get the canonical time-zone code + zone = self.locale.zone_aliases.get(zone, zone) + + # Try explicitly translated zone names first + display = self.locale.time_zones.get(zone) + if display: + if 'long' in display: + width = {3: 'short', 4: 'long'}[max(3, num)] + if char == 'v': + dst = 'generic' + else: + dst = self.value.dst() and 'daylight' or 'standard' + return display[width][dst] + elif 'city' in display: + return display['city'] + + else: + return zone.split('/', 1)[1] + + elif char == 'Z': + offset = self.value.utcoffset() + seconds = offset.days * 24 * 60 * 60 + offset.seconds + hours, seconds = divmod(seconds, 3600) + pattern = {3: '%+03d%02d', 4: 'GMT %+03d:%02d'}[max(3, num)] + return pattern % (hours, seconds // 60) + + + def format(self, value, length): + return ('%%0%dd' % length) % value + + +PATTERN_CHARS = { + 'G': [1, 2, 3, 4, 5], # era + 'y': None, 'Y': None, 'u': None, # year + 'Q': [1, 2, 3, 4], 'q': [1, 2, 3, 4], # quarter + 'M': [1, 2, 3, 4, 5], 'L': [1, 2, 3, 4, 5], # month + 'w': [1, 2], 'W': [1], # week + 'd': [1, 2], 'D': [1, 2, 3], 'F': [1], 'g': None, # day + 'E': [1, 2, 3, 4, 5], 'e': [1, 2, 3, 4, 5], 'c': [1, 3, 4, 5], # week day + 'a': [1], # period + 'h': [1, 2], 'H': [1, 2], 'K': [1, 2], 'k': [1, 2], # hour + 'm': [1, 2], # minute + 's': [1, 2], 'S': None, 'A': None, # second + 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4], 'v': [1, 4] # zone +} + +def parse_pattern(pattern): + """Parse date, time, and datetime format patterns. + + >>> parse_pattern("MMMMd").format + u'%(MMMM)s%(d)s' + >>> parse_pattern("MMM d, yyyy").format + u'%(MMM)s %(d)s, %(yyyy)s' + + Pattern can contain literal strings in single quotes: + + >>> parse_pattern("H:mm' Uhr 'z").format + u'%(H)s:%(mm)s Uhr %(z)s' + + An actual single quote can be used by using two adjacent single quote + characters: + + >>> parse_pattern("hh' o''clock'").format + u"%(hh)s o'clock" + + :param pattern: the formatting pattern to parse + """ + if type(pattern) is DateTimePattern: + return pattern + + result = [] + quotebuf = None + charbuf = [] + fieldchar = [''] + fieldnum = [0] + + def append_chars(): + result.append(''.join(charbuf).replace('%', '%%')) + del charbuf[:] + + def append_field(): + limit = PATTERN_CHARS[fieldchar[0]] + if limit and fieldnum[0] not in limit: + raise ValueError('Invalid length for field: %r' + % (fieldchar[0] * fieldnum[0])) + result.append('%%(%s)s' % (fieldchar[0] * fieldnum[0])) + fieldchar[0] = '' + fieldnum[0] = 0 + + for idx, char in enumerate(pattern.replace("''", '\0')): + if quotebuf is None: + if char == "'": # quote started + if fieldchar[0]: + append_field() + elif charbuf: + append_chars() + quotebuf = [] + elif char in PATTERN_CHARS: + if charbuf: + append_chars() + if char == fieldchar[0]: + fieldnum[0] += 1 + else: + if fieldchar[0]: + append_field() + fieldchar[0] = char + fieldnum[0] = 1 + else: + if fieldchar[0]: + append_field() + charbuf.append(char) + + elif quotebuf is not None: + if char == "'": # end of quote + charbuf.extend(quotebuf) + quotebuf = None + else: # inside quote + quotebuf.append(char) + + if fieldchar[0]: + append_field() + elif charbuf: + append_chars() + + return DateTimePattern(pattern, u''.join(result).replace('\0', "'")) diff --git a/0.8.x/babel/localedata.py b/0.8.x/babel/localedata.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/localedata.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Low-level locale data access. + +:note: The `Locale` class, which uses this module under the hood, provides a + more convenient interface for accessing the locale data. +""" + +import os +import pickle +try: + import threading +except ImportError: + import dummy_threading as threading + +__all__ = ['exists', 'load'] +__docformat__ = 'restructuredtext en' + +_cache = {} +_cache_lock = threading.RLock() +_dirname = os.path.join(os.path.dirname(__file__), 'localedata') + +def exists(name): + """Check whether locale data is available for the given locale. + + :param name: the locale identifier string + :return: `True` if the locale data exists, `False` otherwise + :rtype: `bool` + """ + if name in _cache: + return True + return os.path.exists(os.path.join(_dirname, '%s.dat' % name)) + +def load(name): + """Load the locale data for the given locale. + + The locale data is a dictionary that contains much of the data defined by + the Common Locale Data Repository (CLDR). This data is stored as a + collection of pickle files inside the ``babel`` package. + + >>> d = load('en_US') + >>> d['languages']['sv'] + u'Swedish' + + Note that the results are cached, and subsequent requests for the same + locale return the same dictionary: + + >>> d1 = load('en_US') + >>> d2 = load('en_US') + >>> d1 is d2 + True + + :param name: the locale identifier string (or "root") + :return: the locale data + :rtype: `dict` + :raise `IOError`: if no locale data file is found for the given locale + identifer, or one of the locales it inherits from + """ + _cache_lock.acquire() + try: + data = _cache.get(name) + if not data: + # Load inherited data + if name == 'root': + data = {} + else: + parts = name.split('_') + if len(parts) == 1: + parent = 'root' + else: + parent = '_'.join(parts[:-1]) + data = load(parent).copy() + filename = os.path.join(_dirname, '%s.dat' % name) + fileobj = open(filename, 'rb') + try: + if name != 'root': + merge(data, pickle.load(fileobj)) + else: + data = pickle.load(fileobj) + _cache[name] = data + finally: + fileobj.close() + return data + finally: + _cache_lock.release() + +def merge(dict1, dict2): + """Merge the data from `dict2` into the `dict1` dictionary, making copies + of nested dictionaries. + + :param dict1: the dictionary to merge into + :param dict2: the dictionary containing the data that should be merged + """ + for key, value in dict2.items(): + if value: + if type(value) is dict: + dict1[key] = dict1.get(key, {}).copy() + merge(dict1[key], value) + else: + dict1[key] = value diff --git a/0.8.x/babel/messages/__init__.py b/0.8.x/babel/messages/__init__.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Support for ``gettext`` message catalogs.""" + +from babel.messages.catalog import * diff --git a/0.8.x/babel/messages/catalog.py b/0.8.x/babel/messages/catalog.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/catalog.py @@ -0,0 +1,476 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Data structures for message catalogs.""" + +from datetime import datetime +from email import message_from_string +import re +try: + set +except NameError: + from sets import Set as set +import time + +from babel import __version__ as VERSION +from babel.core import Locale +from babel.dates import format_datetime +from babel.messages.plurals import PLURALS +from babel.util import odict, LOCALTZ, UTC, FixedOffsetTimezone + +__all__ = ['Message', 'Catalog'] +__docformat__ = 'restructuredtext en' + +PYTHON_FORMAT = re.compile(r'\%(\([\w]+\))?[diouxXeEfFgGcrs]').search + + +class Message(object): + """Representation of a single message in a catalog.""" + + def __init__(self, id, string='', locations=(), flags=(), auto_comments=(), + user_comments=()): + """Create the message object. + + :param id: the message ID, or a ``(singular, plural)`` tuple for + pluralizable messages + :param string: the translated message string, or a + ``(singular, plural)`` tuple for pluralizable messages + :param locations: a sequence of ``(filenname, lineno)`` tuples + :param flags: a set or sequence of flags + :param auto_comments: a sequence of automatic comments for the message + :param user_comments: a sequence of user comments for the message + """ + self.id = id #: The message ID + if not string and self.pluralizable: + string = (u'', u'') + self.string = string #: The message translation + self.locations = list(locations) + self.flags = set(flags) + if id and self.python_format: + self.flags.add('python-format') + else: + self.flags.discard('python-format') + self.auto_comments = list(auto_comments) + self.user_comments = list(user_comments) + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.id) + + def fuzzy(self): + return 'fuzzy' in self.flags + fuzzy = property(fuzzy, doc="""\ + Whether the translation is fuzzy. + + >>> Message('foo').fuzzy + False + >>> Message('foo', 'foo', flags=['fuzzy']).fuzzy + True + + :type: `bool` + """) + + def pluralizable(self): + return isinstance(self.id, (list, tuple)) + pluralizable = property(pluralizable, doc="""\ + Whether the message is plurizable. + + >>> Message('foo').pluralizable + False + >>> Message(('foo', 'bar')).pluralizable + True + + :type: `bool` + """) + + def python_format(self): + ids = self.id + if not isinstance(ids, (list, tuple)): + ids = [ids] + return bool(filter(None, [PYTHON_FORMAT(id) for id in ids])) + python_format = property(python_format, doc="""\ + Whether the message contains Python-style parameters. + + >>> Message('foo %(name)s bar').python_format + True + >>> Message(('foo %(name)s', 'foo %(name)s')).python_format + True + + :type: `bool` + """) + + +DEFAULT_HEADER = u"""\ +# Translations template for PROJECT. +# Copyright (C) YEAR ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , YEAR. +#""" + +class Catalog(object): + """Representation of a message catalog.""" + + def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, + project=None, version=None, copyright_holder=None, + msgid_bugs_address=None, creation_date=None, + revision_date=None, last_translator=None, charset='utf-8'): + """Initialize the catalog object. + + :param locale: the locale identifier or `Locale` object, or `None` + if the catalog is not bound to a locale (which basically + means it's a template) + :param domain: the message domain + :param header_comment: the header comment as string, or `None` for the + default header + :param project: the project's name + :param version: the project's version + :param copyright_holder: the copyright holder of the catalog + :param msgid_bugs_address: the email address or URL to submit bug + reports to + :param creation_date: the date the catalog was created + :param revision_date: the date the catalog was revised + :param last_translator: the name and email of the last translator + :param charset: the encoding to use in the output + """ + self.domain = domain #: The message domain + if locale: + locale = Locale.parse(locale) + self.locale = locale #: The locale or `None` + self._header_comment = header_comment + self._messages = odict() + + self.project = project or 'PROJECT' #: The project name + self.version = version or 'VERSION' #: The project version + self.copyright_holder = copyright_holder or 'ORGANIZATION' + self.msgid_bugs_address = msgid_bugs_address or 'EMAIL@ADDRESS' + + self.last_translator = last_translator or 'FULL NAME ' + """Name and email address of the last translator.""" + + self.charset = charset or 'utf-8' + + if creation_date is None: + creation_date = datetime.now(LOCALTZ) + elif isinstance(creation_date, datetime) and not creation_date.tzinfo: + creation_date = creation_date.replace(tzinfo=LOCALTZ) + self.creation_date = creation_date #: Creation date of the template + if revision_date is None: + revision_date = datetime.now(LOCALTZ) + elif isinstance(revision_date, datetime) and not revision_date.tzinfo: + revision_date = revision_date.replace(tzinfo=LOCALTZ) + self.revision_date = revision_date #: Last revision date of the catalog + + def _get_header_comment(self): + comment = self._header_comment + comment = comment.replace('PROJECT', self.project) \ + .replace('VERSION', self.version) \ + .replace('YEAR', self.revision_date.strftime('%Y')) \ + .replace('ORGANIZATION', self.copyright_holder) + if self.locale: + comment = comment.replace('Translations template', '%s translations' + % self.locale.english_name) + return comment + + def _set_header_comment(self, string): + self._header_comment = string + + header_comment = property(_get_header_comment, _set_header_comment, doc="""\ + The header comment for the catalog. + + >>> catalog = Catalog(project='Foobar', version='1.0', + ... copyright_holder='Foo Company') + >>> print catalog.header_comment + # Translations template for Foobar. + # Copyright (C) 2007 Foo Company + # This file is distributed under the same license as the Foobar project. + # FIRST AUTHOR , 2007. + # + + The header can also be set from a string. Any known upper-case variables + will be replaced when the header is retrieved again: + + >>> catalog = Catalog(project='Foobar', version='1.0', + ... copyright_holder='Foo Company') + >>> catalog.header_comment = '''\\ + ... # The POT for my really cool PROJECT project. + ... # Copyright (C) 1990-2003 ORGANIZATION + ... # This file is distributed under the same license as the PROJECT + ... # project. + ... #''' + >>> print catalog.header_comment + # The POT for my really cool Foobar project. + # Copyright (C) 1990-2003 Foo Company + # This file is distributed under the same license as the Foobar + # project. + # + + :type: `unicode` + """) + + def _get_mime_headers(self): + headers = [] + headers.append(('Project-Id-Version', + '%s %s' % (self.project, self.version))) + headers.append(('Report-Msgid-Bugs-To', self.msgid_bugs_address)) + headers.append(('POT-Creation-Date', + format_datetime(self.creation_date, 'yyyy-MM-dd HH:mmZ', + locale='en'))) + if self.locale is None: + headers.append(('PO-Revision-Date', 'YEAR-MO-DA HO:MI+ZONE')) + headers.append(('Last-Translator', 'FULL NAME ')) + headers.append(('Language-Team', 'LANGUAGE ')) + else: + headers.append(('PO-Revision-Date', + format_datetime(self.revision_date, + 'yyyy-MM-dd HH:mmZ', locale='en'))) + headers.append(('Last-Translator', self.last_translator)) + headers.append(('Language-Team', '%s ' % self.locale)) + headers.append(('Plural-Forms', self.plural_forms)) + headers.append(('MIME-Version', '1.0')) + headers.append(('Content-Type', + 'text/plain; charset=%s' % self.charset)) + headers.append(('Content-Transfer-Encoding', '8bit')) + headers.append(('Generated-By', 'Babel %s\n' % VERSION)) + return headers + + def _set_mime_headers(self, headers): + for name, value in headers: + name = name.lower() + if name == 'project-id-version': + parts = value.split(' ') + self.project = ' '.join(parts[:-1]) + self.version = parts[-1] + elif name == 'report-msgid-bugs-to': + self.msgid_bugs_address = value + elif name == 'last-translator': + self.last_translator = value + elif name == 'pot-creation-date': + # FIXME: this should use dates.parse_datetime as soon as that + # is ready + value, tzoffset, _ = re.split('[+-](\d{4})$', value, 1) + tt = time.strptime(value, '%Y-%m-%d %H:%M') + ts = time.mktime(tt) + tzoffset = FixedOffsetTimezone(int(tzoffset[:2]) * 60 + + int(tzoffset[2:])) + dt = datetime.fromtimestamp(ts) + self.creation_date = dt.replace(tzinfo=tzoffset) + + mime_headers = property(_get_mime_headers, _set_mime_headers, doc="""\ + The MIME headers of the catalog, used for the special ``msgid ""`` entry. + + The behavior of this property changes slightly depending on whether a locale + is set or not, the latter indicating that the catalog is actually a template + for actual translations. + + Here's an example of the output for such a catalog template: + + >>> created = datetime(1990, 4, 1, 15, 30, tzinfo=UTC) + >>> catalog = Catalog(project='Foobar', version='1.0', + ... creation_date=created) + >>> for name, value in catalog.mime_headers: + ... print '%s: %s' % (name, value) + Project-Id-Version: Foobar 1.0 + Report-Msgid-Bugs-To: EMAIL@ADDRESS + POT-Creation-Date: 1990-04-01 15:30+0000 + PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE + Last-Translator: FULL NAME + Language-Team: LANGUAGE + MIME-Version: 1.0 + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Generated-By: Babel ... + + And here's an example of the output when the locale is set: + + >>> revised = datetime(1990, 8, 3, 12, 0, tzinfo=UTC) + >>> catalog = Catalog(locale='de_DE', project='Foobar', version='1.0', + ... creation_date=created, revision_date=revised, + ... last_translator='John Doe ') + >>> for name, value in catalog.mime_headers: + ... print '%s: %s' % (name, value) + Project-Id-Version: Foobar 1.0 + Report-Msgid-Bugs-To: EMAIL@ADDRESS + POT-Creation-Date: 1990-04-01 15:30+0000 + PO-Revision-Date: 1990-08-03 12:00+0000 + Last-Translator: John Doe + Language-Team: de_DE + Plural-Forms: nplurals=2; plural=(n != 1) + MIME-Version: 1.0 + Content-Type: text/plain; charset=utf-8 + Content-Transfer-Encoding: 8bit + Generated-By: Babel ... + + :type: `list` + """) + + def num_plurals(self): + num = 2 + if self.locale: + if str(self.locale) in PLURALS: + num = PLURALS[str(self.locale)][0] + elif self.locale.language in PLURALS: + num = PLURALS[self.locale.language][0] + return num + num_plurals = property(num_plurals, doc="""\ + The number of plurals used by the locale. + + >>> Catalog(locale='en').num_plurals + 2 + >>> Catalog(locale='cs_CZ').num_plurals + 3 + + :type: `int` + """) + + def plural_forms(self): + num, expr = ('INTEGER', 'EXPRESSION') + if self.locale: + if str(self.locale) in PLURALS: + num, expr = PLURALS[str(self.locale)] + elif self.locale.language in PLURALS: + num, expr = PLURALS[self.locale.language] + return 'nplurals=%s; plural=%s' % (num, expr) + plural_forms = property(plural_forms, doc="""\ + Return the plural forms declaration for the locale. + + >>> Catalog(locale='en').plural_forms + 'nplurals=2; plural=(n != 1)' + >>> Catalog(locale='pt_BR').plural_forms + 'nplurals=2; plural=(n > 1)' + + :type: `str` + """) + + def __contains__(self, id): + """Return whether the catalog has a message with the specified ID.""" + return self._key_for(id) in self._messages + + def __len__(self): + """The number of messages in the catalog. + + This does not include the special ``msgid ""`` entry. + """ + return len(self._messages) + + def __iter__(self): + """Iterates through all the entries in the catalog, in the order they + were added, yielding a `Message` object for every entry. + + :rtype: ``iterator`` + """ + buf = [] + for name, value in self.mime_headers: + buf.append('%s: %s' % (name, value)) + yield Message('', '\n'.join(buf), flags=set(['fuzzy'])) + for key in self._messages: + yield self._messages[key] + + def __repr__(self): + locale = '' + if self.locale: + locale = ' %s' % self.locale + return '<%s %r%s>' % (type(self).__name__, self.domain, locale) + + def __delitem__(self, id): + """Delete the message with the specified ID.""" + key = self._key_for(id) + if key in self._messages: + del self._messages[key] + + def __getitem__(self, id): + """Return the message with the specified ID. + + :param id: the message ID + :return: the message with the specified ID, or `None` if no such message + is in the catalog + :rtype: `Message` + """ + return self._messages.get(self._key_for(id)) + + def __setitem__(self, id, message): + """Add or update the message with the specified ID. + + >>> catalog = Catalog() + >>> catalog[u'foo'] = Message(u'foo') + >>> catalog[u'foo'] + + + If a message with that ID is already in the catalog, it is updated + to include the locations and flags of the new message. + + >>> catalog = Catalog() + >>> catalog[u'foo'] = Message(u'foo', locations=[('main.py', 1)]) + >>> catalog[u'foo'].locations + [('main.py', 1)] + >>> catalog[u'foo'] = Message(u'foo', locations=[('utils.py', 5)]) + >>> catalog[u'foo'].locations + [('main.py', 1), ('utils.py', 5)] + + :param id: the message ID + :param message: the `Message` object + """ + assert isinstance(message, Message), 'expected a Message object' + key = self._key_for(id) + current = self._messages.get(key) + if current: + if message.pluralizable and not current.pluralizable: + # The new message adds pluralization + current.id = message.id + current.string = message.string + current.locations.extend(message.locations) + current.auto_comments.extend(message.auto_comments) + current.user_comments.extend(message.user_comments) + current.flags |= message.flags + message = current + elif id == '': + # special treatment for the header message + headers = message_from_string(message.string.encode(self.charset)) + self.mime_headers = headers.items() + self.header_comment = '\n'.join(['# %s' % comment for comment + in message.user_comments]) + else: + if isinstance(id, (list, tuple)): + assert isinstance(message.string, (list, tuple)) + self._messages[key] = message + + def add(self, id, string=None, locations=(), flags=(), auto_comments=(), + user_comments=()): + """Add or update the message with the specified ID. + + >>> catalog = Catalog() + >>> catalog.add(u'foo') + >>> catalog[u'foo'] + + + This method simply constructs a `Message` object with the given + arguments and invokes `__setitem__` with that object. + + :param id: the message ID, or a ``(singular, plural)`` tuple for + pluralizable messages + :param string: the translated message string, or a + ``(singular, plural)`` tuple for pluralizable messages + :param locations: a sequence of ``(filenname, lineno)`` tuples + :param flags: a set or sequence of flags + :param auto_comments: a sequence of automatic comments + :param user_comments: a sequence of user comments + """ + self[id] = Message(id, string, list(locations), flags, auto_comments, + user_comments) + + def _key_for(self, id): + """The key for a message is just the singular ID even for pluralizable + messages. + """ + key = id + if isinstance(key, (list, tuple)): + key = id[0] + return key diff --git a/0.8.x/babel/messages/extract.py b/0.8.x/babel/messages/extract.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/extract.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Basic infrastructure for extracting localizable messages from source files. + +This module defines an extensible system for collecting localizable message +strings from a variety of sources. A native extractor for Python source files +is builtin, extractors for other sources can be added using very simple plugins. + +The main entry points into the extraction functionality are the functions +`extract_from_dir` and `extract_from_file`. +""" + +import os +try: + set +except NameError: + from sets import Set as set +import sys +from tokenize import generate_tokens, NAME, OP, STRING, COMMENT + +from babel.util import pathmatch, relpath + +__all__ = ['extract', 'extract_from_dir', 'extract_from_file'] +__docformat__ = 'restructuredtext en' + +GROUP_NAME = 'babel.extractors' + +DEFAULT_KEYWORDS = { + '_': None, + 'gettext': None, + 'ngettext': (1, 2), + 'ugettext': None, + 'ungettext': (1, 2), + 'dgettext': (2,), + 'dngettext': (2, 3), +} + +DEFAULT_MAPPING = [('**.py', 'python')] + +def extract_from_dir(dirname=os.getcwd(), method_map=DEFAULT_MAPPING, + options_map=None, keywords=DEFAULT_KEYWORDS, + comment_tags=(), callback=None): + """Extract messages from any source files found in the given directory. + + This function generates tuples of the form: + + ``(filename, lineno, message, comments)`` + + Which extraction method is used per file is determined by the `method_map` + parameter, which maps extended glob patterns to extraction method names. + For example, the following is the default mapping: + + >>> method_map = [ + ... ('**.py', 'python') + ... ] + + This basically says that files with the filename extension ".py" at any + level inside the directory should be processed by the "python" extraction + method. Files that don't match any of the mapping patterns are ignored. See + the documentation of the `pathmatch` function for details on the pattern + syntax. + + The following extended mapping would also use the "genshi" extraction + method on any file in "templates" subdirectory: + + >>> method_map = [ + ... ('**/templates/**.*', 'genshi'), + ... ('**.py', 'python') + ... ] + + The dictionary provided by the optional `options_map` parameter augments + these mappings. It uses extended glob patterns as keys, and the values are + dictionaries mapping options names to option values (both strings). + + The glob patterns of the `options_map` do not necessarily need to be the + same as those used in the method mapping. For example, while all files in + the ``templates`` folders in an application may be Genshi applications, the + options for those files may differ based on extension: + + >>> options_map = { + ... '**/templates/**.txt': { + ... 'template_class': 'genshi.template.text.TextTemplate', + ... 'encoding': 'latin-1' + ... }, + ... '**/templates/**.html': { + ... 'include_attrs': '' + ... } + ... } + + :param dirname: the path to the directory to extract messages from + :param method_map: a list of ``(pattern, method)`` tuples that maps of + extraction method names to extended glob patterns + :param options_map: a dictionary of additional options (optional) + :param keywords: a dictionary mapping keywords (i.e. names of functions + that should be recognized as translation functions) to + tuples that specify which of their arguments contain + localizable strings + :param comment_tags: a list of tags of translator comments to search for + and include in the results + :param callback: a function that is called for every file that message are + extracted from, just before the extraction itself is + performed; the function is passed the filename, the name + of the extraction method and and the options dictionary as + positional arguments, in that order + :return: an iterator over ``(filename, lineno, funcname, message)`` tuples + :rtype: ``iterator`` + :see: `pathmatch` + """ + if options_map is None: + options_map = {} + + absname = os.path.abspath(dirname) + for root, dirnames, filenames in os.walk(absname): + for subdir in dirnames: + if subdir.startswith('.') or subdir.startswith('_'): + dirnames.remove(subdir) + for filename in filenames: + filename = relpath( + os.path.join(root, filename).replace(os.sep, '/'), + dirname + ) + for pattern, method in method_map: + if pathmatch(pattern, filename): + filepath = os.path.join(absname, filename) + options = {} + for opattern, odict in options_map.items(): + if pathmatch(opattern, filename): + options = odict + if callback: + callback(filename, method, options) + for lineno, message, comments in \ + extract_from_file(method, filepath, + keywords=keywords, + comment_tags=comment_tags, + options=options): + yield filename, lineno, message, comments + break + +def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS, + comment_tags=(), options=None): + """Extract messages from a specific file. + + This function returns a list of tuples of the form: + + ``(lineno, funcname, message)`` + + :param filename: the path to the file to extract messages from + :param method: a string specifying the extraction method (.e.g. "python") + :param keywords: a dictionary mapping keywords (i.e. names of functions + that should be recognized as translation functions) to + tuples that specify which of their arguments contain + localizable strings + :param comment_tags: a list of translator tags to search for and include + in the results + :param options: a dictionary of additional options (optional) + :return: the list of extracted messages + :rtype: `list` + """ + fileobj = open(filename, 'U') + try: + return list(extract(method, fileobj, keywords, comment_tags, options)) + finally: + fileobj.close() + +def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), + options=None): + """Extract messages from the given file-like object using the specified + extraction method. + + This function returns a list of tuples of the form: + + ``(lineno, message, comments)`` + + The implementation dispatches the actual extraction to plugins, based on the + value of the ``method`` parameter. + + >>> source = '''# foo module + ... def run(argv): + ... print _('Hello, world!') + ... ''' + + >>> from StringIO import StringIO + >>> for message in extract('python', StringIO(source)): + ... print message + (3, 'Hello, world!', []) + + :param method: a string specifying the extraction method (.e.g. "python") + :param fileobj: the file-like object the messages should be extracted from + :param keywords: a dictionary mapping keywords (i.e. names of functions + that should be recognized as translation functions) to + tuples that specify which of their arguments contain + localizable strings + :param comment_tags: a list of translator tags to search for and include + in the results + :param options: a dictionary of additional options (optional) + :return: the list of extracted messages + :rtype: `list` + :raise ValueError: if the extraction method is not registered + """ + from pkg_resources import working_set + + for entry_point in working_set.iter_entry_points(GROUP_NAME, method): + func = entry_point.load(require=True) + results = func(fileobj, keywords.keys(), comment_tags, + options=options or {}) + for lineno, funcname, messages, comments in results: + if isinstance(messages, (list, tuple)): + msgs = [] + for index in keywords[funcname]: + msgs.append(messages[index - 1]) + messages = tuple(msgs) + if len(messages) == 1: + messages = messages[0] + yield lineno, messages, comments + return + + raise ValueError('Unknown extraction method %r' % method) + +def extract_nothing(fileobj, keywords, comment_tags, options): + """Pseudo extractor that does not actually extract anything, but simply + returns an empty list. + """ + return [] + +def extract_python(fileobj, keywords, comment_tags, options): + """Extract messages from Python source code. + + :param fileobj: the file-like object the messages should be extracted from + :param keywords: a list of keywords (i.e. function names) that should be + recognized as translation functions + :param comment_tags: a list of translator tags to search for and include + in the results + :param options: a dictionary of additional options (optional) + :return: an iterator over ``(lineno, funcname, message, comments)`` tuples + :rtype: ``iterator`` + """ + funcname = None + lineno = None + buf = [] + messages = [] + translator_comments = [] + in_args = False + in_translator_comments = False + + tokens = generate_tokens(fileobj.readline) + for tok, value, (lineno, _), _, _ in tokens: + if funcname and tok == OP and value == '(': + in_args = True + elif tok == COMMENT: + # Strip the comment token from the line + value = value[1:].strip() + if in_translator_comments is True and \ + translator_comments[-1][0] == lineno - 1: + # We're already inside a translator comment, continue appending + # XXX: Should we check if the programmer keeps adding the + # comment_tag for every comment line??? probably not! + translator_comments.append((lineno, value)) + continue + # If execution reaches this point, let's see if comment line + # starts with one of the comment tags + for comment_tag in comment_tags: + if value.startswith(comment_tag): + if in_translator_comments is not True: + in_translator_comments = True + comment = value[len(comment_tag):].strip() + translator_comments.append((lineno, comment)) + break + elif funcname and in_args: + if tok == OP and value == ')': + in_args = in_translator_comments = False + if buf: + messages.append(''.join(buf)) + del buf[:] + if filter(None, messages): + if len(messages) > 1: + messages = tuple(messages) + else: + messages = messages[0] + # Comments don't apply unless they immediately preceed the + # message + if translator_comments and \ + translator_comments[-1][0] < lineno - 1: + translator_comments = [] + + yield (lineno, funcname, messages, + [comment[1] for comment in translator_comments]) + funcname = lineno = None + messages = [] + translator_comments = [] + elif tok == STRING: + # Unwrap quotes in a safe manner + buf.append(eval(value, {'__builtins__':{}}, {})) + elif tok == OP and value == ',': + messages.append(''.join(buf)) + del buf[:] + elif funcname: + funcname = None + elif tok == NAME and value in keywords: + funcname = value diff --git a/0.8.x/babel/messages/frontend.py b/0.8.x/babel/messages/frontend.py new file mode 100755 --- /dev/null +++ b/0.8.x/babel/messages/frontend.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Frontends for the message extraction functionality.""" + +from ConfigParser import RawConfigParser +from datetime import datetime +from distutils import log +from distutils.cmd import Command +from distutils.errors import DistutilsOptionError, DistutilsSetupError +from optparse import OptionParser +import os +import re +from StringIO import StringIO +import sys + +from babel import __version__ as VERSION +from babel import Locale +from babel.core import UnknownLocaleError +from babel.messages.catalog import Catalog +from babel.messages.extract import extract_from_dir, DEFAULT_KEYWORDS, \ + DEFAULT_MAPPING +from babel.messages.pofile import read_po, write_po +from babel.messages.plurals import PLURALS +from babel.util import odict, LOCALTZ + +__all__ = ['CommandLineInterface', 'extract_messages', + 'check_message_extractors', 'main'] +__docformat__ = 'restructuredtext en' + + +class extract_messages(Command): + """Message extraction command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.frontend import extract_messages + + setup( + ... + cmdclass = {'extract_messages': extract_messages} + ) + + :see: `Integrating new distutils commands `_ + :see: `setuptools `_ + """ + + description = 'extract localizable strings from the project code' + user_options = [ + ('charset=', None, + 'charset to use in the output file'), + ('keywords=', 'k', + 'space-separated list of keywords to look for in addition to the ' + 'defaults'), + ('no-default-keywords', None, + 'do not include the default keywords'), + ('mapping-file=', 'F', + 'path to the mapping configuration file'), + ('no-location', None, + 'do not include location comments with filename and line number'), + ('omit-header', None, + 'do not include msgid "" entry in header'), + ('output-file=', 'o', + 'name of the output file'), + ('width=', 'w', + 'set output line width (default 76)'), + ('no-wrap', None, + 'do not break long message lines, longer than the output line width, ' + 'into several lines'), + ('sort-output', None, + 'generate sorted output (default False)'), + ('sort-by-file', None, + 'sort output by file location (default False)'), + ('msgid-bugs-address=', None, + 'set report address for msgid'), + ('copyright-holder=', None, + 'set copyright holder in output'), + ('add-comments=', 'c', + 'place comment block with TAG (or those preceding keyword lines) in ' + 'output file. Seperate multiple TAGs with commas(,)'), + ('input-dirs=', None, + 'directories that should be scanned for messages'), + ] + boolean_options = [ + 'no-default-keywords', 'no-location', 'omit-header', 'no-wrap', + 'sort-output', 'sort-by-file' + ] + + def initialize_options(self): + self.charset = 'utf-8' + self.keywords = '' + self._keywords = DEFAULT_KEYWORDS.copy() + self.no_default_keywords = False + self.mapping_file = None + self.no_location = False + self.omit_header = False + self.output_file = None + self.input_dirs = None + self.width = 76 + self.no_wrap = False + self.sort_output = False + self.sort_by_file = False + self.msgid_bugs_address = None + self.copyright_holder = None + self.add_comments = None + self._add_comments = [] + + def finalize_options(self): + if self.no_default_keywords and not self.keywords: + raise DistutilsOptionError('you must specify new keywords if you ' + 'disable the default ones') + if self.no_default_keywords: + self._keywords = {} + if self.keywords: + self._keywords.update(parse_keywords(self.keywords.split())) + + if not self.output_file: + raise DistutilsOptionError('no output file specified') + if self.no_wrap and self.width: + raise DistutilsOptionError("'--no-wrap' and '--width' are mutually " + "exclusive") + if self.no_wrap: + self.width = None + else: + self.width = int(self.width) + + if self.sort_output and self.sort_by_file: + raise DistutilsOptionError("'--sort-output' and '--sort-by-file' " + "are mutually exclusive") + + if not self.input_dirs: + self.input_dirs = dict.fromkeys([k.split('.',1)[0] + for k in self.distribution.packages + ]).keys() + + if self.add_comments: + self._add_comments = self.add_comments.split(',') + + def run(self): + mappings = self._get_mappings() + outfile = open(self.output_file, 'w') + try: + catalog = Catalog(project=self.distribution.get_name(), + version=self.distribution.get_version(), + msgid_bugs_address=self.msgid_bugs_address, + copyright_holder=self.copyright_holder, + charset=self.charset) + + for dirname, (method_map, options_map) in mappings.items(): + def callback(filename, method, options): + if method == 'ignore': + return + filepath = os.path.normpath(os.path.join(dirname, filename)) + optstr = '' + if options: + optstr = ' (%s)' % ', '.join(['%s="%s"' % (k, v) for + k, v in options.items()]) + log.info('extracting messages from %s%s' + % (filepath, optstr)) + + extracted = extract_from_dir(dirname, method_map, options_map, + keywords=self._keywords, + comment_tags=self._add_comments, + callback=callback) + for filename, lineno, message, comments in extracted: + filepath = os.path.normpath(os.path.join(dirname, filename)) + catalog.add(message, None, [(filepath, lineno)], + auto_comments=comments) + + log.info('writing PO template file to %s' % self.output_file) + write_po(outfile, catalog, width=self.width, + no_location=self.no_location, + omit_header=self.omit_header, + sort_output=self.sort_output, + sort_by_file=self.sort_by_file) + finally: + outfile.close() + + def _get_mappings(self): + mappings = {} + + if self.mapping_file: + fileobj = open(self.mapping_file, 'U') + try: + method_map, options_map = parse_mapping(fileobj) + for dirname in self.input_dirs: + mappings[dirname] = method_map, options_map + finally: + fileobj.close() + + elif getattr(self.distribution, 'message_extractors', None): + message_extractors = self.distribution.message_extractors + for dirname, mapping in message_extractors.items(): + if isinstance(mapping, basestring): + method_map, options_map = parse_mapping(StringIO(mapping)) + else: + method_map, options_map = [], {} + for pattern, method, options in mapping: + method_map.append((pattern, method)) + options_map[pattern] = options or {} + mappings[dirname] = method_map, options_map + + else: + for dirname in self.input_dirs: + mappings[dirname] = DEFAULT_MAPPING, {} + + return mappings + + +def check_message_extractors(dist, name, value): + """Validate the ``message_extractors`` keyword argument to ``setup()``. + + :param dist: the distutils/setuptools ``Distribution`` object + :param name: the name of the keyword argument (should always be + "message_extractors") + :param value: the value of the keyword argument + :raise `DistutilsSetupError`: if the value is not valid + :see: `Adding setup() arguments + `_ + """ + assert name == 'message_extractors' + if not isinstance(value, dict): + raise DistutilsSetupError('the value of the "message_extractors" ' + 'parameter must be a dictionary') + + +class new_catalog(Command): + """New catalog command for use in ``setup.py`` scripts. + + If correctly installed, this command is available to Setuptools-using + setup scripts automatically. For projects using plain old ``distutils``, + the command needs to be registered explicitly in ``setup.py``:: + + from babel.messages.frontend import new_catalog + + setup( + ... + cmdclass = {'new_catalog': new_catalog} + ) + + :see: `Integrating new distutils commands `_ + :see: `setuptools `_ + """ + + description = 'create new catalogs based on a catalog template' + user_options = [ + ('domain=', 'D', + "domain of PO file (default 'messages')"), + ('input-file=', 'i', + 'name of the input file'), + ('output-dir=', 'd', + 'path to output directory'), + ('output-file=', 'o', + "name of the output file (default " + "'//LC_MESSAGES/.po')"), + ('locale=', 'l', + 'locale for the new localized catalog'), + ] + + def initialize_options(self): + self.output_dir = None + self.output_file = None + self.input_file = None + self.locale = None + self.domain = 'messages' + + def finalize_options(self): + if not self.input_file: + raise DistutilsOptionError('you must specify the input file') + + if not self.locale: + raise DistutilsOptionError('you must provide a locale for the ' + 'new catalog') + try: + self._locale = Locale.parse(self.locale) + except UnknownLocaleError, e: + raise DistutilsOptionError(e) + + if not self.output_file and not self.output_dir: + raise DistutilsOptionError('you must specify the output directory') + if not self.output_file: + self.output_file = os.path.join(self.output_dir, self.locale, + 'LC_MESSAGES', self.domain + '.po') + + if not os.path.exists(os.path.dirname(self.output_file)): + os.makedirs(os.path.dirname(self.output_file)) + + def run(self): + log.info('creating catalog %r based on %r', self.output_file, + self.input_file) + + infile = open(self.input_file, 'r') + try: + catalog = read_po(infile) + finally: + infile.close() + + catalog.locale = self._locale + + outfile = open(self.output_file, 'w') + try: + write_po(outfile, catalog) + finally: + outfile.close() + + +class CommandLineInterface(object): + """Command-line interface. + + This class provides a simple command-line interface to the message + extraction and PO file generation functionality. + """ + + usage = '%%prog %s [options] %s' + version = '%%prog %s' % VERSION + commands = ['extract', 'init'] + command_descriptions = { + 'extract': 'extract messages from source files and generate a POT file', + 'init': 'create new message catalogs from a template' + } + + def run(self, argv=sys.argv): + """Main entry point of the command-line interface. + + :param argv: list of arguments passed on the command-line + """ + self.parser = OptionParser(usage=self.usage % ('command', '[args]'), + version=self.version) + self.parser.disable_interspersed_args() + self.parser.print_help = self._help + options, args = self.parser.parse_args(argv[1:]) + if not args: + self.parser.error('incorrect number of arguments') + + cmdname = args[0] + if cmdname not in self.commands: + self.parser.error('unknown command "%s"' % cmdname) + + getattr(self, cmdname)(args[1:]) + + def _help(self): + print self.parser.format_help() + print "commands:" + longest = max([len(command) for command in self.commands]) + format = " %%-%ds %%s" % max(11, longest) + self.commands.sort() + for command in self.commands: + print format % (command, self.command_descriptions[command]) + + def extract(self, argv): + """Subcommand for extracting messages from source files and generating + a POT file. + + :param argv: the command arguments + """ + parser = OptionParser(usage=self.usage % ('extract', 'dir1 ...'), + description=self.command_descriptions['extract']) + parser.add_option('--charset', dest='charset', + help='charset to use in the output') + parser.add_option('-k', '--keyword', dest='keywords', action='append', + help='keywords to look for in addition to the ' + 'defaults. You can specify multiple -k flags on ' + 'the command line.') + parser.add_option('--no-default-keywords', dest='no_default_keywords', + action='store_true', + help="do not include the default keywords") + parser.add_option('--mapping', '-F', dest='mapping_file', + help='path to the extraction mapping file') + parser.add_option('--no-location', dest='no_location', + action='store_true', + help='do not include location comments with filename ' + 'and line number') + parser.add_option('--omit-header', dest='omit_header', + action='store_true', + help='do not include msgid "" entry in header') + parser.add_option('-o', '--output', dest='output', + help='path to the output POT file') + parser.add_option('-w', '--width', dest='width', type='int', + help="set output line width (default %default)") + parser.add_option('--no-wrap', dest='no_wrap', action = 'store_true', + help='do not break long message lines, longer than ' + 'the output line width, into several lines') + parser.add_option('--sort-output', dest='sort_output', + action='store_true', + help='generate sorted output (default False)') + parser.add_option('--sort-by-file', dest='sort_by_file', + action='store_true', + help='sort output by file location (default False)') + parser.add_option('--msgid-bugs-address', dest='msgid_bugs_address', + metavar='EMAIL@ADDRESS', + help='set report address for msgid') + parser.add_option('--copyright-holder', dest='copyright_holder', + help='set copyright holder in output') + parser.add_option('--add-comments', '-c', dest='comment_tags', + metavar='TAG', action='append', + help='place comment block with TAG (or those ' + 'preceding keyword lines) in output file. One ' + 'TAG per argument call') + + parser.set_defaults(charset='utf-8', keywords=[], + no_default_keywords=False, no_location=False, + omit_header = False, width=76, no_wrap=False, + sort_output=False, sort_by_file=False, + comment_tags=[]) + options, args = parser.parse_args(argv) + if not args: + parser.error('incorrect number of arguments') + + if options.output not in (None, '-'): + outfile = open(options.output, 'w') + else: + outfile = sys.stdout + + keywords = DEFAULT_KEYWORDS.copy() + if options.no_default_keywords: + if not options.keywords: + parser.error('you must specify new keywords if you disable the ' + 'default ones') + keywords = {} + if options.keywords: + keywords.update(parse_keywords(options.keywords)) + + if options.mapping_file: + fileobj = open(options.mapping_file, 'U') + try: + method_map, options_map = parse_mapping(fileobj) + finally: + fileobj.close() + else: + method_map = DEFAULT_MAPPING + options_map = {} + + if options.width and options.no_wrap: + parser.error("'--no-wrap' and '--width' are mutually exclusive.") + elif not options.width and not options.no_wrap: + options.width = 76 + elif not options.width and options.no_wrap: + options.width = 0 + + if options.sort_output and options.sort_by_file: + parser.error("'--sort-output' and '--sort-by-file' are mutually " + "exclusive") + + try: + catalog = Catalog(msgid_bugs_address=options.msgid_bugs_address, + copyright_holder=options.copyright_holder, + charset=options.charset) + + for dirname in args: + if not os.path.isdir(dirname): + parser.error('%r is not a directory' % dirname) + extracted = extract_from_dir(dirname, method_map, options_map, + keywords, options.comment_tags) + for filename, lineno, message, comments in extracted: + filepath = os.path.normpath(os.path.join(dirname, filename)) + catalog.add(message, None, [(filepath, lineno)], + auto_comments=comments) + + write_po(outfile, catalog, width=options.width, + no_location=options.no_location, + omit_header=options.omit_header, + sort_output=options.sort_output, + sort_by_file=options.sort_by_file) + finally: + if options.output: + outfile.close() + + def init(self, argv): + """Subcommand for creating new message catalogs from a template. + + :param argv: the command arguments + """ + parser = OptionParser(usage=self.usage % ('init',''), + description=self.command_descriptions['init']) + parser.add_option('--domain', '-D', dest='domain', + help="domain of PO file (default '%default')") + parser.add_option('--input-file', '-i', dest='input_file', + metavar='FILE', help='name of the input file') + parser.add_option('--output-dir', '-d', dest='output_dir', + metavar='DIR', help='path to output directory') + parser.add_option('--output-file', '-o', dest='output_file', + metavar='FILE', + help="name of the output file (default " + "'//LC_MESSAGES/" + ".po')") + parser.add_option('--locale', '-l', dest='locale', metavar='LOCALE', + help='locale for the new localized catalog') + + parser.set_defaults(domain='messages') + options, args = parser.parse_args(argv) + + if not options.locale: + parser.error('you must provide a locale for the new catalog') + try: + locale = Locale.parse(options.locale) + except UnknownLocaleError, e: + parser.error(e) + + if not options.input_file: + parser.error('you must specify the input file') + + if not options.output_file and not options.output_dir: + parser.error('you must specify the output file or directory') + + if not options.output_file: + options.output_file = os.path.join(options.output_dir, + options.locale, 'LC_MESSAGES', + options.domain + '.po') + if not os.path.exists(os.path.dirname(options.output_file)): + os.makedirs(os.path.dirname(options.output_file)) + + infile = open(options.input_file, 'r') + try: + catalog = read_po(infile) + finally: + infile.close() + + catalog.locale = locale + catalog.revision_date = datetime.now(LOCALTZ) + + print 'creating catalog %r based on %r' % (options.output_file, + options.input_file) + + outfile = open(options.output_file, 'w') + try: + write_po(outfile, catalog) + finally: + outfile.close() + +def main(): + CommandLineInterface().run(sys.argv) + +def parse_mapping(fileobj, filename=None): + """Parse an extraction method mapping from a file-like object. + + >>> buf = StringIO(''' + ... # Python source files + ... [python: **.py] + ... + ... # Genshi templates + ... [genshi: **/templates/**.html] + ... include_attrs = + ... [genshi: **/templates/**.txt] + ... template_class = genshi.template.text.TextTemplate + ... encoding = latin-1 + ... ''') + + >>> method_map, options_map = parse_mapping(buf) + + >>> method_map[0] + ('**.py', 'python') + >>> options_map['**.py'] + {} + >>> method_map[1] + ('**/templates/**.html', 'genshi') + >>> options_map['**/templates/**.html']['include_attrs'] + '' + >>> method_map[2] + ('**/templates/**.txt', 'genshi') + >>> options_map['**/templates/**.txt']['template_class'] + 'genshi.template.text.TextTemplate' + >>> options_map['**/templates/**.txt']['encoding'] + 'latin-1' + + :param fileobj: a readable file-like object containing the configuration + text to parse + :return: a `(method_map, options_map)` tuple + :rtype: `tuple` + :see: `extract_from_directory` + """ + method_map = [] + options_map = {} + + parser = RawConfigParser() + parser._sections = odict(parser._sections) # We need ordered sections + parser.readfp(fileobj, filename) + for section in parser.sections(): + method, pattern = [part.strip() for part in section.split(':', 1)] + method_map.append((pattern, method)) + options_map[pattern] = dict(parser.items(section)) + + return (method_map, options_map) + +def parse_keywords(strings=[]): + """Parse keywords specifications from the given list of strings. + + >>> kw = parse_keywords(['_', 'dgettext:2', 'dngettext:2,3']) + >>> for keyword, indices in sorted(kw.items()): + ... print (keyword, indices) + ('_', None) + ('dgettext', (2,)) + ('dngettext', (2, 3)) + """ + keywords = {} + for string in strings: + if ':' in string: + funcname, indices = string.split(':') + else: + funcname, indices = string, None + if funcname not in keywords: + if indices: + indices = tuple([(int(x)) for x in indices.split(',')]) + keywords[funcname] = indices + return keywords + + +if __name__ == '__main__': + main() diff --git a/0.8.x/babel/messages/plurals.py b/0.8.x/babel/messages/plurals.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/plurals.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Plural form definitions.""" + +PLURALS = { + # Afrikaans - From Pootle's PO's + 'af': (2, '(n != 1)'), + # Arabic - From Pootle's PO's + 'ar': (6, '(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n>=3 && n<=10 ? 3 : n>=11 && n<=99 ? 4 : 5)'), + # Bulgarian - From Pootle's PO's + 'bg': (2, '(n != 1)'), + # Bengali - From Pootle's PO's + 'bn': (2, '(n != 1)'), + # Catalan - From Pootle's PO's + 'ca': (2, '(n != 1)'), + # Czech + 'cs': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Danish + 'da': (2, '(n != 1)'), + # German + 'de': (2, '(n != 1)'), + # Greek + 'el': (2, '(n != 1)'), + # English + 'en': (2, '(n != 1)'), + # Esperanto + 'eo': (2, '(n != 1)'), + # Spanish + 'es': (2, '(n != 1)'), + # Estonian + 'et': (2, '(n != 1)'), + # Basque - From Pootle's PO's + 'eu': (2, '(n != 1)'), + # Persian - From Pootle's PO's + 'fa': (1, '0'), + # Finnish + 'fi': (2, '(n != 1)'), + # French + 'fr': (2, '(n > 1)'), + # Furlan - From Pootle's PO's + 'fur': (2, '(n > 1)'), + # Irish + 'ga': (3, 'n==1 ? 0 : n==2 ? 1 : 2'), + # Galego - From Pootle's PO's + 'gl': (2, '(n != 1)'), + # Hausa - From Pootle's PO's + 'ha': (2, '(n != 1)'), + # Hebrew + 'he': (2, '(n != 1)'), + # Hindi - From Pootle's PO's + 'hi': (2, '(n != 1)'), + # Croatian + 'hr': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Hungarian + 'hu': (1, '0'), + # Armenian - From Pootle's PO's + 'hy': (1, '0'), + # Icelandic - From Pootle's PO's + 'is': (2, '(n != 1)'), + # Italian + 'it': (2, '(n != 1)'), + # Japanese + 'ja': (1, '0'), + # Georgian - From Pootle's PO's + 'ka': (1, '0'), + # Kongo - From Pootle's PO's + 'kg': (2, '(n != 1)'), + # Khmer - From Pootle's PO's + 'km': (1, '0'), + # Korean + 'ko': (1, '0'), + # Kurdî - From Pootle's PO's + 'ku': (2, '(n != 1)'), + # Lithuanian + 'lt': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Latvian + 'lv': (3, '(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2)'), + # Maltese - From Pootle's PO's + 'mt': (4, '(n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3)'), + # Norwegian Bokmal + 'nb': (2, '(n != 1)'), + # Dutch + 'nl': (2, '(n != 1)'), + # Norwegian Nynorsk + 'nn': (2, '(n != 1)'), + # Norwegian + 'no': (2, '(n != 1)'), + # Punjabi - From Pootle's PO's + 'pa': (2, '(n != 1)'), + # Polish + 'pl': (3, '(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Portuguese + 'pt': (2, '(n != 1)'), + # Brazilian + 'pt_BR': (2, '(n > 1)'), + # Romanian - From Pootle's PO's + 'ro': (3, '(n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2)'), + # Russian + 'ru': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Slovak + 'sk': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Slovenian + 'sl': (4, '(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3)'), + # Serbian - From Pootle's PO's + 'sr': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10< =4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Sesotho - From Pootle's PO's + 'st': (2, '(n != 1)'), + # Swedish + 'sv': (2, '(n != 1)'), + # Turkish + 'tr': (1, '0'), + # Ukrainian + 'uk': (3, '(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)'), + # Venda - From Pootle's PO's + 've': (2, '(n != 1)'), + # Vietnamese - From Pootle's PO's + 'vi': (1, '0'), + # Xhosa - From Pootle's PO's + 'xh': (2, '(n != 1)'), + # Chinese - From Pootle's PO's + 'zh_CN': (1, '0'), + 'zh_HK': (1, '0'), + 'zh_TW': (1, '0'), +} diff --git a/0.8.x/babel/messages/pofile.py b/0.8.x/babel/messages/pofile.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/pofile.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Reading and writing of files in the ``gettext`` PO (portable object) +format. + +:see: `The Format of PO Files + `_ +""" + +from datetime import date, datetime +import os +import re +try: + set +except NameError: + from sets import Set as set +from textwrap import wrap + +from babel import __version__ as VERSION +from babel.messages.catalog import Catalog +from babel.util import LOCALTZ + +__all__ = ['escape', 'normalize', 'read_po', 'write_po'] + +def read_po(fileobj): + """Read messages from a ``gettext`` PO (portable object) file from the given + file-like object and return a `Catalog`. + + >>> from StringIO import StringIO + >>> buf = StringIO(''' + ... #: main.py:1 + ... #, fuzzy, python-format + ... msgid "foo %(name)s" + ... msgstr "" + ... + ... # A user comment + ... #. An auto comment + ... #: main.py:3 + ... msgid "bar" + ... msgid_plural "baz" + ... msgstr[0] "" + ... msgstr[1] "" + ... ''') + >>> catalog = read_po(buf) + >>> catalog.revision_date = datetime(2007, 04, 01) + + >>> for message in catalog: + ... if message.id: + ... print (message.id, message.string) + ... print ' ', (message.locations, message.flags) + ... print ' ', (message.user_comments, message.auto_comments) + ('foo %(name)s', '') + ([('main.py', 1)], set(['fuzzy', 'python-format'])) + ([], []) + (('bar', 'baz'), ('', '')) + ([('main.py', 3)], set([])) + (['A user comment'], ['An auto comment']) + + :param fileobj: the file-like object to read the PO file from + :return: an iterator over ``(message, translation, location)`` tuples + :rtype: ``iterator`` + """ + catalog = Catalog() + + messages = [] + translations = [] + locations = [] + flags = [] + user_comments = [] + auto_comments = [] + in_msgid = in_msgstr = False + + def _add_message(): + translations.sort() + if len(messages) > 1: + msgid = tuple([denormalize(m) for m in messages]) + else: + msgid = denormalize(messages[0]) + if len(translations) > 1: + string = tuple([denormalize(t[1]) for t in translations]) + else: + string = denormalize(translations[0][1]) + catalog.add(msgid, string, list(locations), set(flags), + list(auto_comments), list(user_comments)) + del messages[:]; del translations[:]; del locations[:]; + del flags[:]; del auto_comments[:]; del user_comments[:] + + for line in fileobj.readlines(): + line = line.strip() + if line.startswith('#'): + in_msgid = in_msgstr = False + if messages: + _add_message() + if line[1:].startswith(':'): + for location in line[2:].lstrip().split(): + filename, lineno = location.split(':', 1) + locations.append((filename, int(lineno))) + elif line[1:].startswith(','): + for flag in line[2:].lstrip().split(','): + flags.append(flag.strip()) + elif line[1:].startswith('.'): + # These are called auto-comments + comment = line[2:].strip() + if comment: + # Just check that we're not adding empty comments + auto_comments.append(comment) + else: + # These are called user comments + user_comments.append(line[1:].strip()) + else: + if line.startswith('msgid_plural'): + in_msgid = True + msg = line[12:].lstrip() + messages.append(msg) + elif line.startswith('msgid'): + in_msgid = True + if messages: + _add_message() + messages.append(line[5:].lstrip()) + elif line.startswith('msgstr'): + in_msgid = False + in_msgstr = True + msg = line[6:].lstrip() + if msg.startswith('['): + idx, msg = msg[1:].split(']') + translations.append([int(idx), msg.lstrip()]) + else: + translations.append([0, msg]) + elif line.startswith('"'): + if in_msgid: + messages[-1] += u'\n' + line.rstrip() + elif in_msgstr: + translations[-1][1] += u'\n' + line.rstrip() + + if messages: + _add_message() + return catalog + +WORD_SEP = re.compile('(' + r'\s+|' # any whitespace + r'[^\s\w]*\w+[a-zA-Z]-(?=\w+[a-zA-Z])|' # hyphenated words + r'(?<=[\w\!\"\'\&\.\,\?])-{2,}(?=\w)' # em-dash +')') + +def escape(string): + r"""Escape the given string so that it can be included in double-quoted + strings in ``PO`` files. + + >>> escape('''Say: + ... "hello, world!" + ... ''') + '"Say:\\n \\"hello, world!\\"\\n"' + + :param string: the string to escape + :return: the escaped string + :rtype: `str` or `unicode` + """ + return '"%s"' % string.replace('\\', '\\\\') \ + .replace('\t', '\\t') \ + .replace('\r', '\\r') \ + .replace('\n', '\\n') \ + .replace('\"', '\\"') + +def unescape(string): + r"""Reverse escape the given string. + + >>> print unescape('"Say:\\n \\"hello, world!\\"\\n"') + Say: + "hello, world!" + + + :param string: the string to unescape + :return: the unescaped string + :rtype: `str` or `unicode` + """ + return string[1:-1].replace('\\\\', '\\') \ + .replace('\\t', '\t') \ + .replace('\\r', '\r') \ + .replace('\\n', '\n') \ + .replace('\\"', '\"') + +def normalize(string, width=76): + r"""Convert a string into a format that is appropriate for .po files. + + >>> print normalize('''Say: + ... "hello, world!" + ... ''', width=None) + "" + "Say:\n" + " \"hello, world!\"\n" + + >>> print normalize('''Say: + ... "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " + ... ''', width=32) + "" + "Say:\n" + " \"Lorem ipsum dolor sit " + "amet, consectetur adipisicing" + " elit, \"\n" + + :param string: the string to normalize + :param width: the maximum line width; use `None`, 0, or a negative number + to completely disable line wrapping + :return: the normalized string + :rtype: `unicode` + """ + if width and width > 0: + lines = [] + for idx, line in enumerate(string.splitlines(True)): + if len(escape(line)) > width: + chunks = WORD_SEP.split(line) + chunks.reverse() + while chunks: + buf = [] + size = 2 + while chunks: + l = len(escape(chunks[-1])) - 2 + if size + l < width: + buf.append(chunks.pop()) + size += l + else: + if not buf: + # handle long chunks by putting them on a + # separate line + buf.append(chunks.pop()) + break + lines.append(u''.join(buf)) + else: + lines.append(line) + else: + lines = string.splitlines(True) + + if len(lines) <= 1: + return escape(string) + + # Remove empty trailing line + if lines and not lines[-1]: + del lines[-1] + lines[-1] += '\n' + return u'""\n' + u'\n'.join([escape(l) for l in lines]) + +def denormalize(string): + r"""Reverse the normalization done by the `normalize` function. + + >>> print denormalize(r'''"" + ... "Say:\n" + ... " \"hello, world!\"\n"''') + Say: + "hello, world!" + + + >>> print denormalize(r'''"" + ... "Say:\n" + ... " \"Lorem ipsum dolor sit " + ... "amet, consectetur adipisicing" + ... " elit, \"\n"''') + Say: + "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " + + + :param string: the string to denormalize + :return: the denormalized string + :rtype: `unicode` or `str` + """ + if string.startswith('""'): + lines = [] + for line in string.splitlines()[1:]: + lines.append(unescape(line)) + return ''.join(lines) + else: + return unescape(string) + +def write_po(fileobj, catalog, width=76, no_location=False, omit_header=False, + sort_output=False, sort_by_file=False): + r"""Write a ``gettext`` PO (portable object) template file for a given + message catalog to the provided file-like object. + + >>> catalog = Catalog() + >>> catalog.add(u'foo %(name)s', locations=[('main.py', 1)], + ... flags=('fuzzy',)) + >>> catalog.add((u'bar', u'baz'), locations=[('main.py', 3)]) + >>> from StringIO import StringIO + >>> buf = StringIO() + >>> write_po(buf, catalog, omit_header=True) + >>> print buf.getvalue() + #: main.py:1 + #, fuzzy, python-format + msgid "foo %(name)s" + msgstr "" + + #: main.py:3 + msgid "bar" + msgid_plural "baz" + msgstr[0] "" + msgstr[1] "" + + + + :param fileobj: the file-like object to write to + :param catalog: the `Catalog` instance + :param width: the maximum line width for the generated output; use `None`, + 0, or a negative number to completely disable line wrapping + :param no_location: do not emit a location comment for every message + :param omit_header: do not include the ``msgid ""`` entry at the top of the + output + """ + def _normalize(key): + return normalize(key, width=width).encode(catalog.charset, + 'backslashreplace') + + def _write(text): + if isinstance(text, unicode): + text = text.encode(catalog.charset) + fileobj.write(text) + + messages = list(catalog) + if sort_output: + messages.sort(lambda x,y: cmp(x.id, y.id)) + elif sort_by_file: + messages.sort(lambda x,y: cmp(x.locations, y.locations)) + + for message in messages: + if not message.id: # This is the header "message" + if omit_header: + continue + comment_header = catalog.header_comment + if width and width > 0: + lines = [] + for line in comment_header.splitlines(): + lines += wrap(line, width=width, subsequent_indent='# ', + break_long_words=False) + comment_header = u'\n'.join(lines) + u'\n' + _write(comment_header) + + if message.user_comments: + for comment in message.user_comments: + for line in wrap(comment, width, break_long_words=False): + _write('# %s\n' % line.strip()) + + if message.auto_comments: + for comment in message.auto_comments: + for line in wrap(comment, width, break_long_words=False): + _write('#. %s\n' % line.strip()) + + if not no_location: + locs = u' '.join([u'%s:%d' % (filename.replace(os.sep, '/'), lineno) + for filename, lineno in message.locations]) + if width and width > 0: + locs = wrap(locs, width, break_long_words=False) + for line in locs: + _write('#: %s\n' % line.strip()) + if message.flags: + _write('#%s\n' % ', '.join([''] + list(message.flags))) + + if isinstance(message.id, (list, tuple)): + _write('msgid %s\n' % _normalize(message.id[0])) + _write('msgid_plural %s\n' % _normalize(message.id[1])) + for i, string in enumerate(message.string): + _write('msgstr[%d] %s\n' % (i, _normalize(message.string[i]))) + else: + _write('msgid %s\n' % _normalize(message.id)) + _write('msgstr %s\n' % _normalize(message.string or '')) + _write('\n') diff --git a/0.8.x/babel/messages/tests/__init__.py b/0.8.x/babel/messages/tests/__init__.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import unittest + +def suite(): + from babel.messages.tests import catalog, extract, frontend, pofile + suite = unittest.TestSuite() + suite.addTest(catalog.suite()) + suite.addTest(extract.suite()) + suite.addTest(frontend.suite()) + suite.addTest(pofile.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/messages/tests/catalog.py b/0.8.x/babel/messages/tests/catalog.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/catalog.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel.messages import catalog + + +class MessageTestCase(unittest.TestCase): + + def test_python_format(self): + assert catalog.PYTHON_FORMAT('foo %d bar') + assert catalog.PYTHON_FORMAT('foo %s bar') + assert catalog.PYTHON_FORMAT('foo %r bar') + + def test_translator_comments(self): + mess = catalog.Message('foo', user_comments=['Comment About `foo`']) + self.assertEqual(mess.user_comments, ['Comment About `foo`']) + mess = catalog.Message('foo', + auto_comments=['Comment 1 About `foo`', + 'Comment 2 About `foo`']) + self.assertEqual(mess.auto_comments, ['Comment 1 About `foo`', + 'Comment 2 About `foo`']) + + +class CatalogTestCase(unittest.TestCase): + + def test_two_messages_with_same_singular(self): + cat = catalog.Catalog() + cat.add('foo') + cat.add(('foo', 'foos')) + self.assertEqual(1, len(cat)) + + def test_update_message_updates_comments(self): + cat = catalog.Catalog() + cat[u'foo'] = catalog.Message('foo', locations=[('main.py', 5)]) + self.assertEqual(cat[u'foo'].auto_comments, []) + self.assertEqual(cat[u'foo'].user_comments, []) + # Update cat[u'foo'] with a new location and a comment + cat[u'foo'] = catalog.Message('foo', locations=[('main.py', 7)], + user_comments=['Foo Bar comment 1']) + self.assertEqual(cat[u'foo'].user_comments, ['Foo Bar comment 1']) + # now add yet another location with another comment + cat[u'foo'] = catalog.Message('foo', locations=[('main.py', 9)], + auto_comments=['Foo Bar comment 2']) + self.assertEqual(cat[u'foo'].auto_comments, ['Foo Bar comment 2']) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(catalog, optionflags=doctest.ELLIPSIS)) + suite.addTest(unittest.makeSuite(MessageTestCase)) + suite.addTest(unittest.makeSuite(CatalogTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/messages/tests/data/mapping.cfg b/0.8.x/babel/messages/tests/data/mapping.cfg new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/mapping.cfg @@ -0,0 +1,5 @@ +# Ignore CVS Dirs +[ignore: **/CVS/**.*] + +# Extraction from Python source files +[python: **.py] diff --git a/0.8.x/babel/messages/tests/data/project/CVS/a_test_file.txt b/0.8.x/babel/messages/tests/data/project/CVS/a_test_file.txt new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/project/CVS/a_test_file.txt @@ -0,0 +1,1 @@ +Just a test file. diff --git a/0.8.x/babel/messages/tests/data/project/CVS/an_example.txt b/0.8.x/babel/messages/tests/data/project/CVS/an_example.txt new file mode 100644 diff --git a/0.8.x/babel/messages/tests/data/project/CVS/this_wont_normally_be_here.py b/0.8.x/babel/messages/tests/data/project/CVS/this_wont_normally_be_here.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/project/CVS/this_wont_normally_be_here.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- + +# This file won't normally be in this directory. +# It IS only for tests + +from gettext import ngettext + +def foo(): + # Note: This will have the TRANSLATOR: tag but shouldn't + # be included on the extracted stuff + print ngettext('FooBar', 'FooBars', 1) diff --git a/0.8.x/babel/messages/tests/data/project/__init__.py b/0.8.x/babel/messages/tests/data/project/__init__.py new file mode 100644 diff --git a/0.8.x/babel/messages/tests/data/project/file1.py b/0.8.x/babel/messages/tests/data/project/file1.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/project/file1.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# file1.py for tests + +from gettext import gettext as _ +def foo(): + # TRANSLATOR: This will be a translator coment, + # that will include several lines + print _('bar') diff --git a/0.8.x/babel/messages/tests/data/project/file2.py b/0.8.x/babel/messages/tests/data/project/file2.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/project/file2.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# file2.py for tests + +from gettext import ngettext + +def foo(): + # Note: This will have the TRANSLATOR: tag but shouldn't + # be included on the extracted stuff + print ngettext('foobar', 'foobars', 1) diff --git a/0.8.x/babel/messages/tests/data/project/i18n/messages.pot b/0.8.x/babel/messages/tests/data/project/i18n/messages.pot new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/project/i18n/messages.pot @@ -0,0 +1,32 @@ +# Translations template for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 0.1\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + diff --git a/0.8.x/babel/messages/tests/data/setup.cfg b/0.8.x/babel/messages/tests/data/setup.cfg new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/setup.cfg @@ -0,0 +1,5 @@ +[extract_messages] +msgid_bugs_address = bugs.address@email.tld +copyright_holder = FooBar, TM +add_comments = TRANSLATOR:,TRANSLATORS: +output_file = project/i18n/project.pot diff --git a/0.8.x/babel/messages/tests/data/setup.py b/0.8.x/babel/messages/tests/data/setup.py new file mode 100755 --- /dev/null +++ b/0.8.x/babel/messages/tests/data/setup.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# vim: sw=4 ts=4 fenc=utf-8 +# ============================================================================= +# $Id$ +# ============================================================================= +# $URL$ +# $LastChangedDate$ +# $Rev$ +# $LastChangedBy$ +# ============================================================================= +# Copyright (C) 2006 Ufsoft.org - Pedro Algarvio +# +# Please view LICENSE for additional licensing information. +# ============================================================================= + +# THIS IS A BOGUS PROJECT + +from setuptools import setup, find_packages + +setup( + name = 'TestProject', + version = '0.1', + license = 'BSD', + author = 'Foo Bar', + author_email = 'foo@bar.tld', + packages = find_packages(), +) diff --git a/0.8.x/babel/messages/tests/extract.py b/0.8.x/babel/messages/tests/extract.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/extract.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +from StringIO import StringIO +import unittest + +from babel.messages import extract + + +class ExtractPythonTestCase(unittest.TestCase): + + def test_unicode_string_arg(self): + buf = StringIO("msg = _(u'Foo Bar')") + messages = list(extract.extract_python(buf, ('_',), [], {})) + self.assertEqual('Foo Bar', messages[0][2]) + + def test_comment_tag(self): + buf = StringIO(""" +# NOTE: A translation comment +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual(['A translation comment'], messages[0][3]) + + def test_comment_tag_multiline(self): + buf = StringIO(""" +# NOTE: A translation comment +# with a second line +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual(['A translation comment', 'with a second line'], + messages[0][3]) + + def test_translator_comments_with_previous_non_translator_comments(self): + buf = StringIO(""" +# This shouldn't be in the output +# because it didn't start with a comment tag +# NOTE: A translation comment +# with a second line +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual(['A translation comment', 'with a second line'], + messages[0][3]) + + def test_comment_tags_not_on_start_of_comment(self): + buf = StringIO(""" +# This shouldn't be in the output +# because it didn't start with a comment tag +# do NOTE: this will no be a translation comment +# NOTE: This one will be +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual(['This one will be'], messages[0][3]) + + def test_multiple_comment_tags(self): + buf = StringIO(""" +# NOTE1: A translation comment for tag1 +# with a second line +msg = _(u'Foo Bar1') + +# NOTE2: A translation comment for tag2 +msg = _(u'Foo Bar2') +""") + messages = list(extract.extract_python(buf, ('_',), + ['NOTE1:', 'NOTE2:'], {})) + self.assertEqual('Foo Bar1', messages[0][2]) + self.assertEqual(['A translation comment for tag1', + 'with a second line'], messages[0][3]) + self.assertEqual('Foo Bar2', messages[1][2]) + self.assertEqual(['A translation comment for tag2'], messages[1][3]) + + def test_two_succeeding_comments(self): + buf = StringIO(""" +# NOTE: one +# NOTE: two +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual(['one', 'NOTE: two'], messages[0][3]) + + def test_invalid_translator_comments(self): + buf = StringIO(""" +# NOTE: this shouldn't apply to any messages +hello = 'there' + +msg = _(u'Foo Bar') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Foo Bar', messages[0][2]) + self.assertEqual([], messages[0][3]) + + def test_invalid_translator_comments2(self): + buf = StringIO(""" +# NOTE: Hi! +hithere = _('Hi there!') + +# NOTE: you should not be seeing this in the .po +rows = [[v for v in range(0,10)] for row in range(0,10)] + +# this (NOTE:) should not show up either +hello = _('Hello') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Hi there!', messages[0][2]) + self.assertEqual(['Hi!'], messages[0][3]) + self.assertEqual('Hello', messages[1][2]) + self.assertEqual([], messages[1][3]) + + def test_invalid_translator_comments3(self): + buf = StringIO(""" +# NOTE: Hi, + +# there! +hithere = _('Hi there!') +""") + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual('Hi there!', messages[0][2]) + self.assertEqual([], messages[0][3]) + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(extract)) + suite.addTest(unittest.makeSuite(ExtractPythonTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/messages/tests/frontend.py b/0.8.x/babel/messages/tests/frontend.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/frontend.py @@ -0,0 +1,536 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +from datetime import datetime +from distutils.dist import Distribution +from distutils.errors import DistutilsOptionError, DistutilsSetupError +from distutils.log import _global_log +import doctest +import os +import shutil +from StringIO import StringIO +import sys +import time +import unittest + +from babel import __version__ as VERSION +from babel.dates import format_datetime +from babel.messages import frontend +from babel.util import LOCALTZ + + +class ExtractMessagesTestCase(unittest.TestCase): + + def setUp(self): + self.olddir = os.getcwd() + self.datadir = os.path.join(os.path.dirname(__file__), 'data') + os.chdir(self.datadir) + _global_log.threshold = 5 # shut up distutils logging + + self.dist = Distribution(dict( + name='TestProject', + version='0.1', + packages=['project'] + )) + self.cmd = frontend.extract_messages(self.dist) + self.cmd.initialize_options() + + def tearDown(self): + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + if os.path.isfile(pot_file): + os.unlink(pot_file) + + os.chdir(self.olddir) + + def test_neither_default_nor_custom_keywords(self): + self.cmd.output_file = 'dummy' + self.cmd.no_default_keywords = True + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_no_output_file_specified(self): + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_both_sort_output_and_sort_by_file(self): + self.cmd.output_file = 'dummy' + self.cmd.sort_output = True + self.cmd.sort_by_file = True + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_extraction_with_default_mapping(self): + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + assert os.path.isfile(pot_file) + + self.assertEqual( +r"""# Translations template for TestProject. +# Copyright (C) %(year)s FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , %(year)s. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: %(date)s\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +#: project/CVS/this_wont_normally_be_here.py:11 +msgid "FooBar" +msgid_plural "FooBars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'year': time.strftime('%Y'), + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(pot_file, 'U').read()) + + def test_extraction_with_mapping_file(self): + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.mapping_file = 'mapping.cfg' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + assert os.path.isfile(pot_file) + + self.assertEqual( +r"""# Translations template for TestProject. +# Copyright (C) %(year)s FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , %(year)s. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: %(date)s\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'year': time.strftime('%Y'), + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(pot_file, 'U').read()) + + def test_extraction_with_mapping_dict(self): + self.dist.message_extractors = { + 'project': [ + ('**/CVS/**.*', 'ignore', None), + ('**.py', 'python', None), + ] + } + self.cmd.copyright_holder = 'FooBar, Inc.' + self.cmd.msgid_bugs_address = 'bugs.address@email.tld' + self.cmd.output_file = 'project/i18n/temp.pot' + self.cmd.add_comments = 'TRANSLATOR:,TRANSLATORS:' + + self.cmd.finalize_options() + self.cmd.run() + + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + assert os.path.isfile(pot_file) + + self.assertEqual( +r"""# Translations template for TestProject. +# Copyright (C) %(year)s FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , %(year)s. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: %(date)s\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'year': time.strftime('%Y'), + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(pot_file, 'U').read()) + + +class NewCatalogTestCase(unittest.TestCase): + + def setUp(self): + self.olddir = os.getcwd() + self.datadir = os.path.join(os.path.dirname(__file__), 'data') + os.chdir(self.datadir) + _global_log.threshold = 5 # shut up distutils logging + + self.dist = Distribution(dict( + name='TestProject', + version='0.1', + packages=['project'] + )) + self.cmd = frontend.new_catalog(self.dist) + self.cmd.initialize_options() + + def tearDown(self): + locale_dir = os.path.join(self.datadir, 'project', 'i18n', 'en_US') + if os.path.isdir(locale_dir): + shutil.rmtree(locale_dir) + + os.chdir(self.olddir) + + def test_no_input_file(self): + self.cmd.locale = 'en_US' + self.cmd.output_file = 'dummy' + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_no_locale(self): + self.cmd.input_file = 'dummy' + self.cmd.output_file = 'dummy' + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_with_output_dir(self): + self.cmd.input_file = 'project/i18n/messages.pot' + self.cmd.locale = 'en_US' + self.cmd.output_dir = 'project/i18n' + + self.cmd.finalize_options() + self.cmd.run() + + po_file = os.path.join(self.datadir, 'project', 'i18n', 'en_US', + 'LC_MESSAGES', 'messages.po') + assert os.path.isfile(po_file) + + self.assertEqual( +r"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: %(date)s\n" +"Last-Translator: FULL NAME \n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(po_file, 'U').read()) + + +class CommandLineInterfaceTestCase(unittest.TestCase): + + def setUp(self): + self.datadir = os.path.join(os.path.dirname(__file__), 'data') + self.orig_argv = sys.argv + self.orig_stdout = sys.stdout + self.orig_stderr = sys.stderr + sys.argv = ['babel'] + sys.stdout = StringIO() + sys.stderr = StringIO() + self.cli = frontend.CommandLineInterface() + + def tearDown(self): + sys.argv = self.orig_argv + sys.stdout = self.orig_stdout + sys.stderr = self.orig_stderr + + def test_usage(self): + try: + self.cli.run(sys.argv) + self.fail('Expected SystemExit') + except SystemExit, e: + self.assertEqual(2, e.code) + self.assertEqual("""\ +usage: babel command [options] [args] + +babel: error: incorrect number of arguments +""", sys.stderr.getvalue().lower()) + + def test_help(self): + try: + self.cli.run(sys.argv + ['--help']) + self.fail('Expected SystemExit') + except SystemExit, e: + self.assertEqual(0, e.code) + self.assertEqual("""\ +usage: babel command [options] [args] + +options: + --version show program's version number and exit + -h, --help show this help message and exit + +commands: + extract extract messages from source files and generate a pot file + init create new message catalogs from a template +""", sys.stdout.getvalue().lower()) + + def test_extract_with_default_mapping(self): + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + try: + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--msgid-bugs-address', 'bugs.address@email.tld', + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, os.path.join(self.datadir, 'project')]) + except SystemExit, e: + self.assertEqual(0, e.code) + assert os.path.isfile(pot_file) + self.assertEqual( +r"""# Translations template for TestProject. +# Copyright (C) %(year)s FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , %(year)s. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: %(date)s\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +#: project/CVS/this_wont_normally_be_here.py:11 +msgid "FooBar" +msgid_plural "FooBars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'year': time.strftime('%Y'), + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(pot_file, 'U').read()) + + def test_extract_with_mapping_file(self): + pot_file = os.path.join(self.datadir, 'project', 'i18n', 'temp.pot') + try: + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--msgid-bugs-address', 'bugs.address@email.tld', + '--mapping', os.path.join(self.datadir, 'mapping.cfg'), + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, os.path.join(self.datadir, 'project')]) + except SystemExit, e: + self.assertEqual(0, e.code) + assert os.path.isfile(pot_file) + self.assertEqual( +r"""# Translations template for TestProject. +# Copyright (C) %(year)s FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , %(year)s. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: %(date)s\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'year': time.strftime('%Y'), + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(pot_file, 'U').read()) + + def test_init_with_output_dir(self): + po_file = os.path.join(self.datadir, 'project', 'i18n', 'en_US', + 'LC_MESSAGES', 'messages.po') + try: + self.cli.run(sys.argv + ['init', + '--locale', 'en_US', + '-d', os.path.join(self.datadir, 'project', 'i18n'), + '-i', os.path.join(self.datadir, 'project', 'i18n', + 'messages.pot')]) + except SystemExit, e: + self.assertEqual(0, e.code) + assert os.path.isfile(pot_file) + self.assertEqual( +r"""# English (United States) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: TestProject 0.1\n" +"Report-Msgid-Bugs-To: bugs.address@email.tld\n" +"POT-Creation-Date: 2007-04-01 15:30+0200\n" +"PO-Revision-Date: %(date)s\n" +"Last-Translator: FULL NAME \n" +"Language-Team: en_US \n" +"Plural-Forms: nplurals=2; plural=(n != 1)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel %(version)s\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "" +msgstr[1] "" + +""" % {'version': VERSION, + 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', + tzinfo=LOCALTZ, locale='en')}, + open(po_file, 'U').read()) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(frontend)) + suite.addTest(unittest.makeSuite(ExtractMessagesTestCase)) + suite.addTest(unittest.makeSuite(NewCatalogTestCase)) + suite.addTest(unittest.makeSuite(CommandLineInterfaceTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/messages/tests/pofile.py b/0.8.x/babel/messages/tests/pofile.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/messages/tests/pofile.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +from datetime import datetime +import doctest +from StringIO import StringIO +import unittest + +from babel.messages.catalog import Catalog +from babel.messages import pofile + + +class ReadPoTestCase(unittest.TestCase): + + def test_read_multiline(self): + buf = StringIO(r'''msgid "" +"Here's some text that\n" +"includesareallylongwordthatmightbutshouldnt" +" throw us into an infinite " +"loop\n" +msgstr ""''') + catalog = pofile.read_po(buf) + self.assertEqual(1, len(catalog)) + message = list(catalog)[1] + self.assertEqual("Here's some text that\nincludesareallylongwordthat" + "mightbutshouldnt throw us into an infinite loop\n", + message.id) + + +class WritePoTestCase(unittest.TestCase): + + def test_join_locations(self): + catalog = Catalog() + catalog.add(u'foo', locations=[('main.py', 1)]) + catalog.add(u'foo', locations=[('utils.py', 3)]) + buf = StringIO() + pofile.write_po(buf, catalog, omit_header=True) + self.assertEqual('''#: main.py:1 utils.py:3 +msgid "foo" +msgstr ""''', buf.getvalue().strip()) + + def test_wrap_long_lines(self): + text = """Here's some text where +white space and line breaks matter, and should + +not be removed + +""" + catalog = Catalog() + catalog.add(text, locations=[('main.py', 1)]) + buf = StringIO() + pofile.write_po(buf, catalog, no_location=True, omit_header=True, + width=42) + self.assertEqual(r'''msgid "" +"Here's some text where \n" +"white space and line breaks matter, and" +" should\n" +"\n" +"not be removed\n" +"\n" +msgstr ""''', buf.getvalue().strip()) + + def test_wrap_long_lines_with_long_word(self): + text = """Here's some text that +includesareallylongwordthatmightbutshouldnt throw us into an infinite loop +""" + catalog = Catalog() + catalog.add(text, locations=[('main.py', 1)]) + buf = StringIO() + pofile.write_po(buf, catalog, no_location=True, omit_header=True, + width=32) + self.assertEqual(r'''msgid "" +"Here's some text that\n" +"includesareallylongwordthatmightbutshouldnt" +" throw us into an infinite " +"loop\n" +msgstr ""''', buf.getvalue().strip()) + + def test_wrap_long_lines_in_header(self): + """ + Verify that long lines in the header comment are wrapped correctly. + """ + catalog = Catalog(project='AReallyReallyLongNameForAProject', + revision_date=datetime(2007, 4, 1)) + buf = StringIO() + pofile.write_po(buf, catalog) + self.assertEqual('''\ +# Translations template for AReallyReallyLongNameForAProject. +# Copyright (C) 2007 ORGANIZATION +# This file is distributed under the same license as the +# AReallyReallyLongNameForAProject project. +# FIRST AUTHOR , 2007. +# +#, fuzzy''', '\n'.join(buf.getvalue().splitlines()[:7])) + + def test_pot_with_translator_comments(self): + catalog = Catalog() + catalog.add(u'foo', locations=[('main.py', 1)], + auto_comments=['Comment About `foo`']) + catalog.add(u'bar', locations=[('utils.py', 3)], + user_comments=['Comment About `bar` with', + 'multiple lines.']) + buf = StringIO() + pofile.write_po(buf, catalog, omit_header=True) + self.assertEqual('''#. Comment About `foo` +#: main.py:1 +msgid "foo" +msgstr "" + +# Comment About `bar` with +# multiple lines. +#: utils.py:3 +msgid "bar" +msgstr ""''', buf.getvalue().strip()) + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(pofile)) + suite.addTest(unittest.makeSuite(ReadPoTestCase)) + suite.addTest(unittest.makeSuite(WritePoTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/numbers.py b/0.8.x/babel/numbers.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/numbers.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Locale dependent formatting and parsing of numeric data. + +The default locale for the functions in this module is determined by the +following environment variables, in that order: + + * ``LC_NUMERIC``, + * ``LC_ALL``, and + * ``LANG`` +""" +# TODO: percent and scientific formatting + +import re + +from babel.core import default_locale, Locale + +__all__ = ['format_number', 'format_decimal', 'format_currency', + 'format_percent', 'format_scientific', 'parse_number', + 'parse_decimal', 'NumberFormatError'] +__docformat__ = 'restructuredtext en' + +LC_NUMERIC = default_locale('LC_NUMERIC') + +def get_currency_symbol(currency, locale=LC_NUMERIC): + """Return the symbol used by the locale for the specified currency. + + >>> get_currency_symbol('USD', 'en_US') + u'$' + + :param currency: the currency code + :param locale: the `Locale` object or locale identifier + :return: the currency symbol + :rtype: `unicode` + """ + return Locale.parse(locale).currency_symbols.get(currency, currency) + +def get_decimal_symbol(locale=LC_NUMERIC): + """Return the symbol used by the locale to separate decimal fractions. + + >>> get_decimal_symbol('en_US') + u'.' + + :param locale: the `Locale` object or locale identifier + :return: the decimal symbol + :rtype: `unicode` + """ + return Locale.parse(locale).number_symbols.get('decimal', u'.') + +def get_group_symbol(locale=LC_NUMERIC): + """Return the symbol used by the locale to separate groups of thousands. + + >>> get_group_symbol('en_US') + u',' + + :param locale: the `Locale` object or locale identifier + :return: the group symbol + :rtype: `unicode` + """ + return Locale.parse(locale).number_symbols.get('group', u',') + +def format_number(number, locale=LC_NUMERIC): + """Return the given number formatted for a specific locale. + + >>> format_number(1099, locale='en_US') + u'1,099' + + :param number: the number to format + :param locale: the `Locale` object or locale identifier + :return: the formatted number + :rtype: `unicode` + """ + # Do we really need this one? + return format_decimal(number, locale=locale) + +def format_decimal(number, format=None, locale=LC_NUMERIC): + """Return the given decimal number formatted for a specific locale. + + >>> 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: + :param locale: the `Locale` object or locale identifier + :return: the formatted decimal number + :rtype: `unicode` + """ + locale = Locale.parse(locale) + if not format: + format = locale.decimal_formats.get(format) + pattern = parse_pattern(format) + return pattern.apply(number, locale) + +def format_currency(number, currency, format=None, locale=LC_NUMERIC): + u"""Return formatted currency value. + + >>> format_currency(1099.98, 'USD', locale='en_US') + u'$1,099.98' + >>> format_currency(1099.98, 'USD', locale='es_CO') + u'US$1.099,98' + >>> format_currency(1099.98, 'EUR', locale='de_DE') + u'1.099,98 \\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 locale: the `Locale` object or locale identifier + :return: the formatted currency value + :rtype: `unicode` + """ + locale = Locale.parse(locale) + if not format: + format = locale.currency_formats.get(format) + pattern = parse_pattern(format) + return pattern.apply(number, locale, currency=currency) + +def format_percent(number, format=None, locale=LC_NUMERIC): + """Return formatted percent value for a specific locale. + + >>> 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 %' + + 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: + :param locale: the `Locale` object or locale identifier + :return: the formatted percent number + :rtype: `unicode` + """ + locale = Locale.parse(locale) + if not format: + format = locale.percent_formats.get(format) + pattern = parse_pattern(format) + return pattern.apply(number, locale) + +def format_scientific(number, locale=LC_NUMERIC): + # TODO: implement + raise NotImplementedError + + +class NumberFormatError(ValueError): + """Exception raised when a string cannot be parsed into a number.""" + + +def parse_number(string, locale=LC_NUMERIC): + """Parse localized number string into a long integer. + + >>> 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 + :param locale: the `Locale` object or locale identifier + :return: the parsed number + :rtype: `long` + :raise `NumberFormatError`: if the string can not be converted to a number + """ + try: + return long(string.replace(get_group_symbol(locale), '')) + except ValueError: + raise NumberFormatError('%r is not a valid number' % string) + +def parse_decimal(string, locale=LC_NUMERIC): + """Parse localized decimal string into a float. + + >>> 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 + :param locale: the `Locale` object or locale identifier + :return: the parsed decimal number + :rtype: `float` + :raise `NumberFormatError`: if the string can not be converted to a + decimal number + """ + locale = Locale.parse(locale) + try: + return float(string.replace(get_group_symbol(locale), '') + .replace(get_decimal_symbol(locale), '.')) + except ValueError: + raise NumberFormatError('%r is not a valid decimal number' % string) + + +PREFIX_END = r'[^0-9@#.,]' +NUMBER_TOKEN = r'[0-9@#.\-,E]' + +PREFIX_PATTERN = r"(?P(?:'[^']*'|%s)*)" % PREFIX_END +NUMBER_PATTERN = r"(?P%s+)" % NUMBER_TOKEN +SUFFIX_PATTERN = r"(?P.*)" + +number_re = re.compile(r"%s%s%s" % (PREFIX_PATTERN, NUMBER_PATTERN, + SUFFIX_PATTERN)) + +# TODO: +# Filling +# Rounding increment in pattern +# Scientific notation +# Significant Digits +def parse_pattern(pattern): + """Parse number format patterns""" + if isinstance(pattern, NumberPattern): + return pattern + + # Do we have a negative subpattern? + if ';' in pattern: + pattern, neg_pattern = pattern.split(';', 1) + pos_prefix, number, pos_suffix = number_re.search(pattern).groups() + neg_prefix, _, neg_suffix = number_re.search(neg_pattern).groups() + else: + pos_prefix, number, pos_suffix = number_re.search(pattern).groups() + neg_prefix = '-' + pos_prefix + neg_suffix = pos_suffix + if '.' in number: + integer, fraction = number.rsplit('.', 1) + else: + integer = number + fraction = '' + min_frac = max_frac = 0 + + def parse_precision(p): + """Calculate the min and max allowed digits""" + min = max = 0 + for c in p: + if c == '0': + min += 1 + max += 1 + elif c == '#': + max += 1 + else: + break + return min, max + + def parse_grouping(p): + """Parse primary and secondary digit grouping + + >>> parse_grouping('##') + 0, 0 + >>> parse_grouping('#,###') + 3, 3 + >>> parse_grouping('#,####,###') + 3, 4 + """ + width = len(p) + g1 = p.rfind(',') + if g1 == -1: + return 1000, 1000 + g1 = width - g1 - 1 + g2 = p[:-g1 - 1].rfind(',') + if g2 == -1: + return g1, g1 + g2 = width - g1 - g2 - 2 + return g1, g2 + + int_precision = parse_precision(integer) + frac_precision = parse_precision(fraction) + grouping = parse_grouping(integer) + int_precision = (int_precision[0], 1000) # Unlimited + return NumberPattern(pattern, (pos_prefix, neg_prefix), + (pos_suffix, neg_suffix), grouping, + int_precision, frac_precision) + + +class NumberPattern(object): + + def __init__(self, pattern, prefix, suffix, grouping, + int_precision, frac_precision): + self.pattern = pattern + self.prefix = prefix + self.suffix = suffix + self.grouping = grouping + self.int_precision = int_precision + self.frac_precision = frac_precision + self.format = '%%#.%df' % self.frac_precision[1] + if '%' in ''.join(self.prefix + self.suffix): + self.scale = 100.0 + elif u'‰' in ''.join(self.prefix + self.suffix): + self.scale = 1000.0 + else: + self.scale = 1.0 + + def __repr__(self): + return '<%s %r>' % (type(self).__name__, self.pattern) + + def apply(self, value, locale, currency=None): + value *= self.scale + negative = int(value < 0) + a, b = (self.format % abs(value)).split('.', 1) + retval = u'%s%s%s%s' % (self.prefix[negative], + self._format_int(a, locale), + self._format_frac(b, locale), + self.suffix[negative]) + if u'¤' in retval: + retval = retval.replace(u'¤¤', currency.upper()) + retval = retval.replace(u'¤', get_currency_symbol(currency, locale)) + return retval + + def _format_int(self, value, locale): + min, max = self.int_precision + width = len(value) + if width < min: + value += '0' * (min - width) + gsize = self.grouping[0] + ret = '' + symbol = get_group_symbol(locale) + while len(value) > gsize: + ret = symbol + value[-gsize:] + ret + value = value[:-gsize] + gsize = self.grouping[1] + return value + ret + + def _format_frac(self, value, locale): + min, max = self.frac_precision + if max == 0 or (min == 0 and int(value) == 0): + return '' + width = len(value) + while len(value) > min and value[-1] == '0': + value = value[:-1] + return get_decimal_symbol(locale) + value diff --git a/0.8.x/babel/support.py b/0.8.x/babel/support.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/support.py @@ -0,0 +1,320 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Several classes and functions that help with integrating and using Babel +in applications. + +.. note: the code in this module is not used by Babel itself +""" + +from datetime import date, datetime, time +import gettext + +from babel.core import Locale +from babel.dates import format_date, format_datetime, format_time, LC_TIME +from babel.numbers import format_number, format_decimal, format_currency, \ + format_percent, format_scientific, LC_NUMERIC +from babel.util import UTC + +__all__ = ['Format', 'LazyProxy', 'Translations'] +__docformat__ = 'restructuredtext en' + + +class Format(object): + """Wrapper class providing the various date and number formatting functions + bound to a specific locale and time-zone. + + >>> fmt = Format('en_US', UTC) + >>> fmt.date(date(2007, 4, 1)) + u'Apr 1, 2007' + >>> fmt.decimal(1.2345) + u'1.234' + """ + + def __init__(self, locale, tzinfo=None): + """Initialize the formatter. + + :param locale: the locale identifier or `Locale` instance + :param tzinfo: the time-zone info (a `tzinfo` instance or `None`) + """ + self.locale = Locale.parse(locale) + self.tzinfo = tzinfo + + def date(self, date=None, format='medium'): + """Return a date formatted according to the given pattern. + + >>> fmt = Format('en_US') + >>> fmt.date(date(2007, 4, 1)) + u'Apr 1, 2007' + + :see: `babel.dates.format_date` + """ + return format_date(date, format, locale=self.locale) + + def datetime(self, datetime=None, format='medium'): + """Return a date and time formatted according to the given pattern. + + >>> from pytz import timezone + >>> fmt = Format('en_US', tzinfo=timezone('US/Eastern')) + >>> fmt.datetime(datetime(2007, 4, 1, 15, 30)) + u'Apr 1, 2007 11:30:00 AM' + + :see: `babel.dates.format_datetime` + """ + return format_datetime(datetime, format, tzinfo=self.tzinfo, + locale=self.locale) + + def time(self, time=None, format='medium'): + """Return a time formatted according to the given pattern. + + >>> from pytz import timezone + >>> fmt = Format('en_US', tzinfo=timezone('US/Eastern')) + >>> fmt.time(time(15, 30)) + u'11:30:00 AM' + + :see: `babel.dates.format_time` + """ + return format_time(time, format, tzinfo=self.tzinfo, locale=self.locale) + + def number(self, number): + """Return an integer number formatted for the locale. + + >>> fmt = Format('en_US') + >>> fmt.number(1099) + u'1,099' + + :see: `babel.numbers.format_number` + """ + return format_number(number, locale=self.locale) + + def decimal(self, number, format=None): + """Return a decimal number formatted for the locale. + + >>> fmt = Format('en_US') + >>> fmt.decimal(1.2345) + u'1.234' + + :see: `babel.numbers.format_decimal` + """ + return format_decimal(number, format, locale=self.locale) + + def currency(self, number, currency): + """Return a number in the given currency formatted for the locale. + + :see: `babel.numbers.format_currency` + """ + return format_currency(number, currency, locale=self.locale) + + def percent(self, number, format=None): + """Return a number formatted as percentage for the locale. + + >>> fmt = Format('en_US') + >>> fmt.percent(0.34) + u'34%' + + :see: `babel.numbers.format_percent` + """ + return format_percent(number, format, locale=self.locale) + + def scientific(self, number): + """Return a number formatted using scientific notation for the locale. + + :see: `babel.numbers.format_scientific` + """ + return format_scientific(number, locale=self.locale) + + +class LazyProxy(object): + """Class for proxy objects that delegate to a specified function to evaluate + the actual object. + + >>> def greeting(name='world'): + ... return 'Hello, %s!' % name + >>> lazy_greeting = LazyProxy(greeting, name='Joe') + >>> print lazy_greeting + Hello, Joe! + >>> u' ' + lazy_greeting + u' Hello, Joe!' + >>> u'(%s)' % lazy_greeting + u'(Hello, Joe!)' + + This can be used, for example, to implement lazy translation functions that + delay the actual translation until the string is actually used. The + rationale for such behavior is that the locale of the user may not always + be available. In web applications, you only know the locale when processing + a request. + + The proxy implementation attempts to be as complete as possible, so that + the lazy objects should mostly work as expected, for example for sorting: + + >>> greetings = [ + ... LazyProxy(greeting, 'world'), + ... LazyProxy(greeting, 'Joe'), + ... LazyProxy(greeting, 'universe'), + ... ] + >>> greetings.sort() + >>> for greeting in greetings: + ... print greeting + Hello, Joe! + Hello, universe! + Hello, world! + """ + __slots__ = ['_func', '_args', '_kwargs', '_value'] + + def __init__(self, func, *args, **kwargs): + # Avoid triggering our own __setattr__ implementation + object.__setattr__(self, '_func', func) + object.__setattr__(self, '_args', args) + object.__setattr__(self, '_kwargs', kwargs) + object.__setattr__(self, '_value', None) + + def value(self): + if self._value is None: + value = self._func(*self._args, **self._kwargs) + object.__setattr__(self, '_value', value) + return self._value + value = property(value) + + def __contains__(self, key): + return key in self.value + + def __nonzero__(self): + return bool(self.value) + + def __dir__(self): + return dir(self.value) + + def __iter__(self): + return iter(self.value) + + def __len__(self): + return len(self.value) + + def __str__(self): + return str(self.value) + + def __unicode__(self): + return unicode(self.value) + + def __add__(self, other): + return self.value + other + + def __radd__(self, other): + return other + self.value + + def __mod__(self, other): + return self.value % other + + def __rmod__(self, other): + return other % self.value + + def __mul__(self, other): + return self.value * other + + def __rmul__(self, other): + return other * self.value + + def __call__(self, *args, **kwargs): + return self.value(*args, **kwargs) + + def __lt__(self, other): + return self.value < other + + def __le__(self, other): + return self.value <= other + + def __eq__(self, other): + return self.value == other + + def __ne__(self, other): + return self.value != other + + def __gt__(self, other): + return self.value > other + + def __ge__(self, other): + return self.value >= other + + def __delattr__(self, name): + delattr(self.value, name) + + def __getattr__(self, name): + return getattr(self.value, name) + + def __setattr__(self, name, value): + setattr(self.value, name, value) + + def __delitem__(self, key): + del self.value[key] + + def __getitem__(self, key): + return self.value[key] + + def __setitem__(self, key, value): + self.value[key] = value + + +class Translations(gettext.GNUTranslations): + """An extended translation catalog class.""" + + DEFAULT_DOMAIN = 'messages' + + def __init__(self, fileobj=None): + """Initialize the translations catalog. + + :param fileobj: the file-like object the translation should be read + from + """ + gettext.GNUTranslations.__init__(self, fp=fileobj) + self.files = [getattr(fileobj, 'name')] + + def load(cls, dirname=None, locales=None, domain=DEFAULT_DOMAIN): + """Load translations from the given directory. + + :param dirname: the directory containing the ``MO`` files + :param locales: the list of locales in order of preference (items in + this list can be either `Locale` objects or locale + strings) + :param domain: the message domain + :return: the loaded catalog, or a ``NullTranslations`` instance if no + matching translations were found + :rtype: `Translations` + """ + if not isinstance(locales, (list, tuple)): + locales = [locales] + locales = [str(locale) for locale in locales] + filename = gettext.find(domain or cls.DEFAULT_DOMAIN, dirname, locales) + if not filename: + return gettext.NullTranslations() + return cls(fileobj=open(filename, 'rb')) + load = classmethod(load) + + def merge(self, translations): + """Merge the given translations into the catalog. + + Message translations in the specfied catalog override any messages with + the same identifier in the existing catalog. + + :param translations: the `Translations` instance with the messages to + merge + :return: the `Translations` instance (``self``) so that `merge` calls + can be easily chained + :rtype: `Translations` + """ + if isinstance(translations, Translations): + self._catalog.update(translations._catalog) + self.files.extend(translations.files) + return self + + def __repr__(self): + return "<%s %r>" % (type(self).__name__) diff --git a/0.8.x/babel/tests/__init__.py b/0.8.x/babel/tests/__init__.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/__init__.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import unittest + +def suite(): + from babel.tests import core, dates, localedata, numbers, support, util + from babel.messages import tests as messages + suite = unittest.TestSuite() + suite.addTest(core.suite()) + suite.addTest(dates.suite()) + suite.addTest(localedata.suite()) + suite.addTest(messages.suite()) + suite.addTest(numbers.suite()) + suite.addTest(support.suite()) + suite.addTest(util.suite()) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/core.py b/0.8.x/babel/tests/core.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/core.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel import core + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(core)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/dates.py b/0.8.x/babel/tests/dates.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/dates.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +from datetime import date, datetime, time +import doctest +import unittest + +from pytz import timezone + +from babel import dates + + +class DateTimeFormatTestCase(unittest.TestCase): + + def test_local_day_of_week(self): + d = datetime(2007, 4, 1) # a sunday + fmt = dates.DateTimeFormat(d, locale='de_DE') + self.assertEqual('7', fmt['e']) # monday is first day of week + fmt = dates.DateTimeFormat(d, locale='en_US') + self.assertEqual('01', fmt['ee']) # sunday is first day of week + fmt = dates.DateTimeFormat(d, locale='dv_MV') + self.assertEqual('03', fmt['ee']) # friday is first day of week + + d = datetime(2007, 4, 2) # a monday + fmt = dates.DateTimeFormat(d, locale='de_DE') + self.assertEqual('1', fmt['e']) # monday is first day of week + fmt = dates.DateTimeFormat(d, locale='en_US') + self.assertEqual('02', fmt['ee']) # sunday is first day of week + fmt = dates.DateTimeFormat(d, locale='dv_MV') + self.assertEqual('04', fmt['ee']) # friday is first day of week + + def test_local_day_of_week_standalone(self): + d = datetime(2007, 4, 1) # a sunday + fmt = dates.DateTimeFormat(d, locale='de_DE') + self.assertEqual('7', fmt['c']) # monday is first day of week + fmt = dates.DateTimeFormat(d, locale='en_US') + self.assertEqual('1', fmt['c']) # sunday is first day of week + fmt = dates.DateTimeFormat(d, locale='dv_MV') + self.assertEqual('3', fmt['c']) # friday is first day of week + + d = datetime(2007, 4, 2) # a monday + fmt = dates.DateTimeFormat(d, locale='de_DE') + self.assertEqual('1', fmt['c']) # monday is first day of week + fmt = dates.DateTimeFormat(d, locale='en_US') + self.assertEqual('2', fmt['c']) # sunday is first day of week + fmt = dates.DateTimeFormat(d, locale='dv_MV') + self.assertEqual('4', fmt['c']) # friday is first day of week + + def test_timezone_rfc822(self): + tz = timezone('Europe/Berlin') + t = time(15, 30, tzinfo=tz) + fmt = dates.DateTimeFormat(t, locale='de_DE') + self.assertEqual('+0100', fmt['Z']) + + def test_timezone_gmt(self): + tz = timezone('Europe/Berlin') + t = time(15, 30, tzinfo=tz) + fmt = dates.DateTimeFormat(t, locale='de_DE') + self.assertEqual('GMT +01:00', fmt['ZZZZ']) + + def test_timezone_walltime_short(self): + tz = timezone('Europe/Paris') + t = time(15, 30, tzinfo=tz) + fmt = dates.DateTimeFormat(t, locale='en_US') + self.assertEqual('CET', fmt['v']) + + def test_timezone_walltime_long(self): + tz = timezone('Europe/Paris') + t = time(15, 30, tzinfo=tz) + fmt = dates.DateTimeFormat(t, locale='en_US') + self.assertEqual('Central European Time', fmt['vvvv']) + + +class FormatDateTestCase(unittest.TestCase): + + def test_with_time_fields_in_pattern(self): + self.assertRaises(AttributeError, dates.format_date, date(2007, 04, 01), + "yyyy-MM-dd HH:mm", locale='en_US') + + def test_with_time_fields_in_pattern_and_datetime_param(self): + self.assertRaises(AttributeError, dates.format_date, + datetime(2007, 04, 01, 15, 30), + "yyyy-MM-dd HH:mm", locale='en_US') + + +class FormatTimeTestCase(unittest.TestCase): + + def test_with_date_fields_in_pattern(self): + self.assertRaises(AttributeError, dates.format_time, date(2007, 04, 01), + "yyyy-MM-dd HH:mm", locale='en_US') + + def test_with_date_fields_in_pattern_and_datetime_param(self): + self.assertRaises(AttributeError, dates.format_time, + datetime(2007, 04, 01, 15, 30), + "yyyy-MM-dd HH:mm", locale='en_US') + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(dates)) + suite.addTest(unittest.makeSuite(DateTimeFormatTestCase)) + suite.addTest(unittest.makeSuite(FormatDateTestCase)) + suite.addTest(unittest.makeSuite(FormatTimeTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/localedata.py b/0.8.x/babel/tests/localedata.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/localedata.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel import localedata + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(localedata)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/numbers.py b/0.8.x/babel/tests/numbers.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/numbers.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel import numbers + + +class FormatDecimalTestCase(unittest.TestCase): + + def test_subpatterns(self): + self.assertEqual(numbers.format_decimal(-12345, '#,##0.##;-#', + locale='en_US'), '-12,345') + self.assertEqual(numbers.format_decimal(-12345, '#,##0.##;(#)', + locale='en_US'), '(12,345)') + + def test_default_rounding(self): + """ + Testing Round-Half-Even (Banker's rounding) + + A '5' is rounded to the closest 'even' number + """ + self.assertEqual(numbers.format_decimal(5.5, '0', locale='sv'), '6') + self.assertEqual(numbers.format_decimal(6.5, '0', locale='sv'), '6') + self.assertEqual(numbers.format_decimal(1.2325, locale='sv'), '1,232') + self.assertEqual(numbers.format_decimal(1.2335, locale='sv'), '1,234') + + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(numbers)) + suite.addTest(unittest.makeSuite(FormatDecimalTestCase)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/support.py b/0.8.x/babel/tests/support.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/support.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel import support + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(support)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/tests/util.py b/0.8.x/babel/tests/util.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/tests/util.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +import doctest +import unittest + +from babel import util + +def suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocTestSuite(util)) + return suite + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/0.8.x/babel/util.py b/0.8.x/babel/util.py new file mode 100644 --- /dev/null +++ b/0.8.x/babel/util.py @@ -0,0 +1,222 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +"""Various utility classes and functions.""" + +from datetime import timedelta, tzinfo +import os +import re +import time + +__all__ = ['pathmatch', 'relpath', 'UTC', 'LOCALTZ'] +__docformat__ = 'restructuredtext en' + +def pathmatch(pattern, filename): + """Extended pathname pattern matching. + + This function is similar to what is provided by the ``fnmatch`` module in + the Python standard library, but: + + * can match complete (relative or absolute) path names, and not just file + names, and + * also supports a convenience pattern ("**") to match files at any + directory level. + + Examples: + + >>> pathmatch('**.py', 'bar.py') + True + >>> pathmatch('**.py', 'foo/bar/baz.py') + True + >>> pathmatch('**.py', 'templates/index.html') + False + + >>> pathmatch('**/templates/*.html', 'templates/index.html') + True + >>> pathmatch('**/templates/*.html', 'templates/foo/bar.html') + False + + :param pattern: the glob pattern + :param filename: the path name of the file to match against + :return: `True` if the path name matches the pattern, `False` otherwise + :rtype: `bool` + """ + symbols = { + '?': '[^/]', + '?/': '[^/]/', + '*': '[^/]+', + '*/': '[^/]+/', + '**/': '(?:.+/)*?', + '**': '(?:.+/)*?[^/]+', + } + buf = [] + for idx, part in enumerate(re.split('([?*]+/?)', pattern)): + if idx % 2: + buf.append(symbols[part]) + elif part: + buf.append(re.escape(part)) + match = re.match(''.join(buf) + '$', filename.replace(os.sep, '/')) + return match is not None + + +class odict(dict): + """Ordered dict implementation. + + :see: `http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/107747` + """ + def __init__(self, data=None): + dict.__init__(self, data or {}) + self._keys = [] + + def __delitem__(self, key): + dict.__delitem__(self, key) + self._keys.remove(key) + + def __setitem__(self, key, item): + dict.__setitem__(self, key, item) + if key not in self._keys: + self._keys.append(key) + + def __iter__(self): + return iter(self._keys) + + def clear(self): + dict.clear(self) + self._keys = [] + + def copy(self): + d = odict() + d.update(self) + return d + + def items(self): + return zip(self._keys, self.values()) + + def keys(self): + return self._keys[:] + + def setdefault(self, key, failobj = None): + dict.setdefault(self, key, failobj) + if key not in self._keys: + self._keys.append(key) + + def update(self, dict): + for (key, val) in dict.items(): + self[key] = val + + def values(self): + return map(self.get, self._keys) + + +try: + relpath = os.path.relpath +except AttributeError: + def relpath(path, start='.'): + """Compute the relative path to one path from another. + + >>> relpath('foo/bar.txt', '').replace(os.sep, '/') + 'foo/bar.txt' + >>> relpath('foo/bar.txt', 'foo').replace(os.sep, '/') + 'bar.txt' + >>> relpath('foo/bar.txt', 'baz').replace(os.sep, '/') + '../foo/bar.txt' + + :return: the relative path + :rtype: `basestring` + """ + start_list = os.path.abspath(start).split(os.sep) + path_list = os.path.abspath(path).split(os.sep) + + # Work out how much of the filepath is shared by start and path. + i = len(os.path.commonprefix([start_list, path_list])) + + rel_list = [os.path.pardir] * (len(start_list) - i) + path_list[i:] + return os.path.join(*rel_list) + +ZERO = timedelta(0) + + +class FixedOffsetTimezone(tzinfo): + """Fixed offset in minutes east from UTC.""" + + def __init__(self, offset, name=None): + self._offset = timedelta(minutes=offset) + if name is None: + name = 'Etc/GMT+%d' % offset + self.zone = name + + def __str__(self): + return self.zone + + def __repr__(self): + return '' % (self.zone, self._offset) + + def utcoffset(self, dt): + return self._offset + + def tzname(self, dt): + return self.zone + + def dst(self, dt): + return ZERO + + +try: + from pytz import UTC +except ImportError: + UTC = FixedOffsetTimezone(0, 'UTC') + """`tzinfo` object for UTC (Universal Time). + + :type: `tzinfo` + """ + +STDOFFSET = timedelta(seconds = -time.timezone) +if time.daylight: + DSTOFFSET = timedelta(seconds = -time.altzone) +else: + DSTOFFSET = STDOFFSET + +DSTDIFF = DSTOFFSET - STDOFFSET + + +class LocalTimezone(tzinfo): + + def utcoffset(self, dt): + if self._isdst(dt): + return DSTOFFSET + else: + return STDOFFSET + + def dst(self, dt): + if self._isdst(dt): + return DSTDIFF + else: + return ZERO + + def tzname(self, dt): + return time.tzname[self._isdst(dt)] + + def _isdst(self, dt): + tt = (dt.year, dt.month, dt.day, + dt.hour, dt.minute, dt.second, + dt.weekday(), 0, -1) + stamp = time.mktime(tt) + tt = time.localtime(stamp) + return tt.tm_isdst > 0 + + +LOCALTZ = LocalTimezone() +"""`tzinfo` object for local time-zone. + +:type: `tzinfo` +""" diff --git a/0.8.x/doc/cmdline.txt b/0.8.x/doc/cmdline.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/cmdline.txt @@ -0,0 +1,107 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +====================== +Command-Line Interface +====================== + +Babel includes a command-line interface for working with message catalogs, +similar to the various GNU ``gettext`` tools commonly available on Linux/Unix +systems. + + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +When properly installed, Babel provides a script called ``babel``:: + + $ babel --help + usage: babel subcommand [options] [args] + + options: + --version show program's version number and exit + -h, --help show this help message and exit + + subcommands: + extract extract messages from source files and generate a POT file + init create new message catalogs from a template + +The ``babel`` script provides a number of sub-commands that do the actual work. +Those sub-commands are described below. + + +extract +======= + +The ``extract`` sub-command can be used to extract localizable messages from +a collection of source files:: + + $ babel extract --help + usage: babel extract [options] dir1 ... + + extract messages from source files and generate a POT file + + options: + -h, --help show this help message and exit + --charset=CHARSET charset to use in the output + -k KEYWORDS, --keyword=KEYWORDS + keywords to look for in addition to the defaults. You + can specify multiple -k flags on the command line. + --no-default-keywords + do not include the default keywords + -F MAPPING_FILE, --mapping=MAPPING_FILE + path to the extraction mapping file + --no-location do not include location comments with filename and + line number + --omit-header do not include msgid "" entry in header + -o OUTPUT, --output=OUTPUT + path to the output POT file + -w WIDTH, --width=WIDTH + set output line width (default 76) + --no-wrap do not break long message lines, longer than the + output line width, into several lines + --sort-output generate sorted output (default False) + --sort-by-file sort output by file location (default False) + --msgid-bugs-address=EMAIL@ADDRESS + set report address for msgid + --copyright-holder=COPYRIGHT_HOLDER + set copyright holder in output + -c TAG, --add-comments=TAG + place comment block with TAG (or those preceding + keyword lines) in output file. One TAG per argument + call + + +init +==== + +The `init` sub-command creates a new translations catalog based on a PO +template file:: + + $ babel init --help + usage: babel init [options] + + create new message catalogs from a template + + options: + -h, --help show this help message and exit + -D DOMAIN, --domain=DOMAIN + domain of PO file (defaults to lower-cased project + name) + -i INPUT_FILE, --input-file=INPUT_FILE + name of the input file + -d OUTPUT_DIR, --output-dir=OUTPUT_DIR + path to output directory + -o OUTPUT_FILE, --output-file=OUTPUT_FILE + name of the output file (default + '//.po') + -l LOCALE, --locale=LOCALE + locale for the new localized catalog + --first-author=FIRST_AUTHOR_NAME + name of first author + --first-author-email=FIRST_AUTHOR_EMAIL + email of first author + --project-name=NAME the project name + --project-version=VERSION + the project version diff --git a/0.8.x/doc/conf/docutils.ini b/0.8.x/doc/conf/docutils.ini new file mode 100644 --- /dev/null +++ b/0.8.x/doc/conf/docutils.ini @@ -0,0 +1,9 @@ +[general] +input_encoding = utf-8 +strip_comments = yes +toc_backlinks = none + +[html4css1 writer] +embed_stylesheet = no +stylesheet = style/edgewall.css +xml_declaration = no diff --git a/0.8.x/doc/conf/epydoc.ini b/0.8.x/doc/conf/epydoc.ini new file mode 100644 --- /dev/null +++ b/0.8.x/doc/conf/epydoc.ini @@ -0,0 +1,24 @@ +[epydoc] + +name: Documentation Index +url: ../index.html +modules: babel +verbosity: 1 + +# Extraction +docformat: restructuredtext +parse: yes +introspect: yes +exclude: .*\.tests.* +inheritance: listed +private: no +imports: no +include-log: no + +# HTML output +output: html +target: doc/api/ +css: doc/style/epydoc.css +top: babel +frames: no +sourcecode: no diff --git a/0.8.x/doc/dates.txt b/0.8.x/doc/dates.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/dates.txt @@ -0,0 +1,249 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +=============== +Date Formatting +=============== + + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +When working with date and time information in Python, you commonly use the +classes ``date``, ``datetime`` and/or ``time`` from the `datetime`_ package. +Babel provides functions for locale-specific formatting of those objects in its +``dates`` module: + +.. _`datetime`: http://docs.python.org/lib/module-datetime.html + +.. code-block:: pycon + + >>> from datetime import date, datetime, time + >>> from babel.dates import format_date, format_datetime, format_time + + >>> d = date(2007, 4, 1) + >>> format_date(d, locale='en') + u'Apr 1, 2007' + >>> format_date(d, locale='de_DE') + u'01.04.2007' + +As this example demonstrates, Babel will automatically choose a date format +that is appropriate for the requested locale. + +The ``format_*()`` functions also accept an optional ``format`` argument, which +allows you to choose between one of four format variations: + + * ``short``, + * ``medium`` (the default), + * ``long``, and + * ``full``. + +For example: + +.. code-block:: pycon + + >>> format_date(d, format='short', locale='en') + u'4/1/07' + >>> format_date(d, format='long', locale='en') + u'April 1, 2007' + >>> format_date(d, format='full', locale='en') + u'Sunday, April 1, 2007' + + +Pattern Syntax +============== + +While Babel makes it simple to use the appropriate date/time format for a given +locale, you can also force it to use custom patterns. Note that Babel uses +different patterns for specifying number and date formats compared to the +Python equivalents (such as ``time.strftime()``), which have mostly been +inherited from C and POSIX. The patterns used in Babel are based on the +`Locale Data Markup Language specification`_ (LDML), which defines them as +follows: + + A date/time pattern is a string of characters, where specific strings of + characters are replaced with date and time data from a calendar when formatting + or used to generate data for a calendar when parsing. […] + + Characters may be used multiple times. For example, if ``y`` is used for the + year, ``yy`` might produce "99", whereas ``yyyy`` produces "1999". For most + numerical fields, the number of characters specifies the field width. For + example, if ``h`` is the hour, ``h`` might produce "5", but ``hh`` produces + "05". For some characters, the count specifies whether an abbreviated or full + form should be used […] + + Two single quotes represent a literal single quote, either inside or outside + single quotes. Text within single quotes is not interpreted in any way (except + for two adjacent single quotes). + +For example: + +.. code-block:: pycon + + >>> d = date(2007, 4, 1) + >>> format_date(d, "EEE, MMM d, ''yy", locale='en') + u"Sun, Apr 1, '07" + >>> format_date(d, "EEEE, d.M.yyyy", locale='de') + u'Sonntag, 1.4.2007' + + >>> t = time(15, 30) + >>> format_time(t, "hh 'o''clock' a", locale='en') + u"03 o'clock PM" + >>> format_time(t, 'H:mm a', locale='de') + u'15:30 nachm.' + + >>> dt = datetime(2007, 4, 1, 15, 30) + >>> format_datetime(dt, "yyyyy.MMMM.dd GGG hh:mm a", locale='en') + u'02007.April.01 AD 03:30 PM' + +The syntax for custom datetime format patterns is described in detail in the +the `Locale Data Markup Language specification`_. The following table is just a +relatively brief overview. + + .. _`Locale Data Markup Language specification`: http://unicode.org/reports/tr35/#Date_Format_Patterns + +Date Fields +----------- + + +----------+--------+--------------------------------------------------------+ + | Field | Symbol | Description | + +==========+========+========================================================+ + | Era | ``G`` | Replaced with the era string for the current date. One | + | | | to three letters for the abbreviated form, four | + | | | lettersfor the long form, five for the narrow form | + +----------+--------+--------------------------------------------------------+ + | Year | ``y`` | Replaced by the year. Normally the length specifies | + | | | the padding, but for two letters it also specifies the | + | | | maximum length. | + | +--------+--------------------------------------------------------+ + | | ``Y`` | Same as ``y`` but uses the ISO year-week calendar. | + | +--------+--------------------------------------------------------+ + | | ``u`` | ?? | + +----------+--------+--------------------------------------------------------+ + | Quarter | ``Q`` | Use one or two for the numerical quarter, three for | + | | | the abbreviation, or four for the full name. | + | +--------+--------------------------------------------------------+ + | | ``q`` | Use one or two for the numerical quarter, three for | + | | | the abbreviation, or four for the full name. | + +----------+--------+--------------------------------------------------------+ + | Month | ``M`` | Use one or two for the numerical month, three for the | + | | | abbreviation, or four for the full name, or five for | + | | | the narrow name. | + | +--------+--------------------------------------------------------+ + | | ``L`` | Use one or two for the numerical month, three for the | + | | | abbreviation, or four for the full name, or 5 for the | + | | | narrow name. | + +----------+--------+--------------------------------------------------------+ + | Week | ``w`` | Week of year. | + | +--------+--------------------------------------------------------+ + | | ``W`` | Week of month. | + +----------+--------+--------------------------------------------------------+ + | Day | ``d`` | Day of month. | + | +--------+--------------------------------------------------------+ + | | ``D`` | Day of year. | + | +--------+--------------------------------------------------------+ + | | ``F`` | Day of week in month. | + | +--------+--------------------------------------------------------+ + | | ``g`` | ?? | + +----------+--------+--------------------------------------------------------+ + | Week day | ``E`` | Day of week. Use one through three letters for the | + | | | short day, or four for the full name, or five for the | + | | | narrow name. | + | +--------+--------------------------------------------------------+ + | | ``e`` | Local day of week. Same as E except adds a numeric | + | | | value that will depend on the local starting day of | + | | | the week, using one or two letters. | + | +--------+--------------------------------------------------------+ + | | ``c`` | ?? | + +----------+--------+--------------------------------------------------------+ + +Time Fields +----------- + + +----------+--------+--------------------------------------------------------+ + | Field | Symbol | Description | + +==========+========+========================================================+ + | Period | ``a`` | AM or PM | + +----------+--------+--------------------------------------------------------+ + | Hour | ``h`` | Hour [1-12]. | + | +--------+--------------------------------------------------------+ + | | ``H`` | Hour [0-23]. | + | +--------+--------------------------------------------------------+ + | | ``K`` | Hour [0-11]. | + | +--------+--------------------------------------------------------+ + | | ``k`` | Hour [1-24]. | + +----------+--------+--------------------------------------------------------+ + | Minute | ``m`` | Use one or two for zero places padding. | + +----------+--------+--------------------------------------------------------+ + | Second | ``s`` | Use one or two for zero places padding. | + | +--------+--------------------------------------------------------+ + | | ``S`` | Fractional second, rounds to the count of letters. | + | +--------+--------------------------------------------------------+ + | | ``A`` | Milliseconds in day. | + +----------+--------+--------------------------------------------------------+ + | Timezone | ``z`` | Use one to three letters for the short timezone or | + | | | four for the full name. | + | +--------+--------------------------------------------------------+ + | | ``Z`` | Use one to three letters for RFC 822, four letters for | + | | | GMT format. | + | +--------+--------------------------------------------------------+ + | | ``v`` | Use one letter for short wall (generic) time, four for | + | | | long wall time. | + +----------+--------+--------------------------------------------------------+ + + +Time-Zone Support +================= + +Many of the verbose time formats include the time-zone, but time-zone +information is not by default available for the Python ``datetime`` and +``time`` objects. The standard library includes only the abstract ``tzinfo`` +class, which you need appropriate implementations for to actually use in your +application. Babel includes a ``tzinfo`` implementation for UTC (Universal +Time). + +For real time-zone support, it is strongly recommended that you use the +third-party package `pytz`_, which includes the definitions of practically all +of the time-zones used on the world, as well as important functions for +reliably converting from UTC to local time, and vice versa: + +.. code-block:: pycon + + >>> from datetime import time + >>> from pytz import timezone, utc + >>> dt = datetime(2007, 04, 01, 15, 30, tzinfo=utc) + >>> eastern = timezone('US/Eastern') + >>> format_datetime(dt, 'H:mm Z', tzinfo=eastern, locale='en_US') + u'11:30 -0400' + +The recommended approach to deal with different time-zones in a Python +application is to always use UTC internally, and only convert from/to the users +time-zone when accepting user input and displaying date/time data, respectively. +You can use Babel together with ``pytz`` to apply a time-zone to any +``datetime`` or ``time`` object for display, leaving the original information +unchanged: + +.. code-block:: pycon + + >>> british = timezone('Europe/London') + >>> format_datetime(dt, 'H:mm zzzz', tzinfo=british, locale='en_US') + u'16:30 British Summer Time' + +Here, the given UTC time is adjusted to the "Europe/London" time-zone, and +daylight savings time is taken into account. Daylight savings time is also +applied to ``format_time``, but because the actual date is unknown in that +case, the current day is assumed to determine whether DST or standard time +should be used. + + .. _`pytz`: http://pytz.sourceforge.net/ + + +Parsing Dates +============= + +Babel can also parse date and time information in a locale-sensitive manner: + +.. code-block:: pycon + + >>> from babel.dates import parse_date, parse_datetime, parse_time diff --git a/0.8.x/doc/display.txt b/0.8.x/doc/display.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/display.txt @@ -0,0 +1,83 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +==================== +Locale Display Names +==================== + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Introduction +============ + +While `message catalogs `_ allow you to localize any messages +in your application, there are a number of strings that are used in many +applications for which translations are readily available. + +Imagine for example you have a list of countries that users can choose from, +and you'd like to display the names of those countries in the language the +user prefers. Instead of translating all those country names yourself in your +application, you can make use of the translations provided by the locale data +included with Babel, which is based on the `Common Locale Data Repository +(CLDR) `_ developed and maintained by the `Unicode +Consortium `_. + + +The ``Locale`` Class +==================== + +You normally access such locale data through the `Locale`_ class provided +by Babel: + +.. code-block:: pycon + + >>> from babel import Locale + >>> locale = Locale('en', 'US') + >>> locale.territories['US'] + u'United States' + >>> locale = Locale('es', 'MX') + >>> locale.territories['US'] + u'Estados Unidos' + +.. _`Locale`: api/babel.core.Locale-class.html + +In addition to country/territory names, the locale data also provides access to +names of languages, scripts, variants, time zones, and more. Some of the data +is closely related to number and date formatting. + +Most of the corresponding ``Locale`` properties return dictionaries, where the +key is a code such as the ISO country and language codes. Consult the API +documentation for references to the relevant specifications. + + +Calender Display Names +====================== + +The `Locale`_ class provides access to many locale display names related to +calendar display, such as the names of week days or months. + +These display names are of course used for date formatting, but can also be +used, for example, to show a list of months to the user in their preferred +language: + +.. code-block:: pycon + + >>> locale = Locale('es') + >>> month_names = locale.months['format']['wide'].items() + >>> month_names.sort() + >>> for idx, name in month_names: + ... print name + enero + febrero + marzo + abril + mayo + junio + julio + agosto + septiembre + octubre + noviembre + diciembre diff --git a/0.8.x/doc/index.txt b/0.8.x/doc/index.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/index.txt @@ -0,0 +1,30 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +===== +Babel +===== + +.. image:: logo.png + :width: 426 + :height: 187 + :align: center + :alt: Babel + :class: logo + +--------------------------------------------------- +Simple Internationalization for Python Applications +--------------------------------------------------- + +Babel is an integrated collection of utilities that assist in +internationalizing and localizing Python applications, with an emphasis on +web-based applications. + + * `Introduction `_ + * `Locale Display Names `_ + * `Date Formatting `_ + * `Number Formatting `_ + * `Working with Message Catalogs `_ + * `Command-Line Interface `_ + * `Distutils/Setuptools Integration `_ + * `Support Classes and Functions `_ + * `Generated API Documentation `_ diff --git a/0.8.x/doc/intro.txt b/0.8.x/doc/intro.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/intro.txt @@ -0,0 +1,61 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +============ +Introduction +============ + +The functionality Babel provides for internationalization (I18n) and +localization (L10N) can be separated into two different aspects: + + * tools to build and work with ``gettext`` message catalogs, and + * a Python interface to the CLDR (Common Locale Data Repository), providing + access to various locale display names, localized number and date + formatting, etc. + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Message Catalogs +================ + +While the Python standard library includes a +`gettext `_ module that enables +applications to use message catalogs, it requires developers to build these +catalogs using GNU tools such as ``xgettext``, ``msgmerge``, and ``msgfmt``. +And while ``xgettext`` does have support for extracting messages from Python +files, it does not know how to deal with other kinds of files commonly found +in Python web-applications, such as templates, nor does it provide an easy +extensibility mechanism to add such support. + +Babel addresses this by providing a framework where various extraction methods +can be plugged in to a larger message extraction framework, and also removes +the dependency on the GNU ``gettext`` tools for common tasks, as these aren't +necessarily available on all platforms. See `Working with Message Catalogs`_ +for details on this aspect of Babel. + +.. _`Working with Message Catalogs`: messages.html + + +Locale Data +=========== + +Furthermore, while the Python standard library does include support for basic +localization with respect to the formatting of numbers and dates (the +`locale `_ module, among others), +this support is based on the assumption that there will be only one specific +locale used per process (at least simultaneously.) Also, it doesn't provide +access to other kinds of locale data, such as the localized names of countries, +languages, or time-zones, which are frequently needed in web-based applications. + +For these requirements, Babel includes data extracted from the `Common Locale +Data Repository (CLDR) `_, and provides a number of +convenient methods for accessing and using this data. See `Locale Display +Names`_, `Date Formatting`_, and `Number Formatting`_ for more information on +this aspect of Babel. + + +.. _`Locale Display Names`: display.html +.. _`Date Formatting`: dates.html +.. _`Number Formatting`: numbers.html diff --git a/0.8.x/doc/logo.pdf b/0.8.x/doc/logo.pdf new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..5385fd067c5f2433249ee2445e466a4ee2274746 GIT binary patch literal 44986 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�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 diff --git a/0.8.x/doc/logo_small.png b/0.8.x/doc/logo_small.png new file mode 100755 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..b9eddbdd9b831a21f0a0d733d3d4f31614d34439 GIT binary patch literal 6118 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 G$o~RM%YoAX diff --git a/0.8.x/doc/messages.txt b/0.8.x/doc/messages.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/messages.txt @@ -0,0 +1,276 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +============================= +Working with Message Catalogs +============================= + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Introduction +============ + +The ``gettext`` translation system enables you to mark any strings used in your +application as subject to localization, by wrapping them in functions such as +``gettext(str)`` and ``ngettext(singular, plural, num)``. For brevity, the +``gettext`` function is often aliased to ``_(str)``, so you can write: + +.. code-block:: python + + print _("Hello") + +instead of just: + +.. code-block:: python + + print "Hello" + +to make the string "Hello" localizable. + +Message catalogs are collections of translations for such localizable messages +used in an application. They are commonly stored in PO (Portable Object) and MO +(Machine Object) files, the formats of which are defined by the GNU `gettext`_ +tools and the GNU `translation project`_. + + .. _`gettext`: http://www.gnu.org/software/gettext/ + .. _`translation project`: http://sourceforge.net/projects/translation + +The general procedure for building message catalogs looks something like this: + + * use a tool (such as ``xgettext``) to extract localizable strings from the + code base and write them to a POT (PO Template) file. + * make a copy of the POT file for a specific locale (for example, "en_US") + and start translating the messages + * use a tool such as ``msgfmt`` to compile the locale PO file into an binary + MO file + * later, when code changes make it necessary to update the translations, you + regenerate the POT file and merge the changes into the various + locale-specific PO files, for example using ``msgmerge`` + +Python provides the `gettext module`_ as part of the standard library, which +enables applications to work with appropriately generated MO files. + + .. _`gettext module`: http://docs.python.org/lib/module-gettext.html + +As ``gettext`` provides a solid and well supported foundation for translating +application messages, Babel does not reinvent the wheel, but rather reuses this +infrastructure, and makes it easier to build message catalogs for Python +applications. + + +Message Extraction +================== + +Babel provides functionality similar to that of the ``xgettext`` program, +except that only extraction from Python source files is built-in, while support +for other file formats can be added using a simple extension mechanism. + +Unlike ``xgettext``, which is usually invoked once for every file, the routines +for message extraction in Babel operate on directories. While the per-file +approach of ``xgettext`` works nicely with projects using a ``Makefile``, +Python projects rarely use ``make``, and thus a different mechanism is needed +for extracting messages from the heterogeneous collection of source files that +many Python projects are composed of. + +When message extraction is based on directories instead of individual files, +there needs to be a way to configure which files should be treated in which +manner. For example, while many projects may contain ``.html`` files, some of +those files may be static HTML files that don't contain localizable message, +while others may be `Django`_ templates, and still others may contain `Genshi`_ +markup templates. Some projects may even mix HTML files for different templates +languages (for whatever reason). Therefore the way in which messages are +extracted from source files can not only depend on the file extension, but +needs to be controllable in a precise manner. + +.. _`Django`: http://www.djangoproject.com/ +.. _`Genshi`: http://genshi.edgewall.org/ + +Babel accepts a configuration file to specify this mapping of files to +extraction methods, which is described below. + + +.. _`mapping`: + +------------------------------------------- +Extraction Method Mapping and Configuration +------------------------------------------- + +The mapping of extraction methods to files in Babel is done via a configuration +file. This file maps extended glob patterns to the names of the extraction +methods, and can also set various options for each pattern (which options are +available depends on the specific extraction method). + +For example, the following configuration adds extraction of messages from both +Genshi markup templates and text templates: + +.. code-block:: ini + + # Extraction from Python source files + + [python: foobar/**.py] + + # Extraction from Genshi HTML and text templates + + [genshi: foobar/**/templates/**.html] + ignore_tags = script,style + include_attrs = alt title summary + + [genshi: foobar/**/templates/**.txt] + template_class = genshi.template.text:TextTemplate + encoding = ISO-8819-15 + +The configuration file syntax is based on the format commonly found in ``.INI`` +files on Windows systems, and as supported by the ``ConfigParser`` module in +the Python standard libraries. Section names (the strings enclosed in square +brackets) specify both the name of the extraction method, and the extended glob +pattern to specify the files that this extraction method should be used for, +separated by a colon. The options in the sections are passed to the extraction +method. Which options are available is specific to the extraction method used. + +The extended glob patterns used in this configuration are similar to the glob +patterns provided by most shells. A single asterisk (``*``) is a wildcard for +any number of characters (except for the pathname component separator "/"), +while a question mark (``?``) only matches a single character. In addition, +two subsequent asterisk characters (``**``) can be used to make the wildcard +match any directory level, so the pattern ``**.txt`` matches any file with the +extension ``.txt`` in any directory. + +Lines that start with a ``#`` or ``;`` character are ignored and can be used +for comments. Empty lines are also ignored, too. + +.. note:: if you're performing message extraction using the command Babel + provides for integration into ``setup.py`` scripts (see below), you + can also provide this configuration in a different way, namely as a + keyword argument to the ``setup()`` function. + + +---------- +Front-Ends +---------- + +Babel provides two different front-ends to access its functionality for working +with message catalogs: + + * A `Command-line interface `_, and + * `Integration with distutils/setuptools `_ + +Which one you choose depends on the nature of your project. For most modern +Python projects, the distutils/setuptools integration is probably more +convenient. + + +-------------------------- +Writing Extraction Methods +-------------------------- + +Adding new methods for extracting localizable methods is easy. First, you'll +need to implement a function that complies with the following interface: + +.. code-block:: python + + def extract_xxx(fileobj, keywords, comment_tags, options): + """Extract messages from XXX files. + + :param fileobj: the file-like object the messages should be extracted + from + :param keywords: a list of keywords (i.e. function names) that should + be recognized as translation functions + :param comment_tags: a list of translator tags to search for and + include in the results + :param options: a dictionary of additional options (optional) + :return: an iterator over ``(lineno, funcname, message, comments)`` + tuples + :rtype: ``iterator`` + """ + +.. note:: Any strings in the tuples produced by this function must be either + ``unicode`` objects, or ``str`` objects using plain ASCII characters. + That means that if sources contain strings using other encodings, it + is the job of the extractor implementation to do the decoding to + ``unicode`` objects. + +Next, you should register that function as an entry point. This requires your +``setup.py`` script to use `setuptools`_, and your package to be installed with +the necessary metadata. If that's taken care of, add something like the +following to your ``setup.py`` script: + +.. code-block:: python + + def setup(... + + entry_points = """ + [babel.extractors] + xxx = your.package:extract_xxx + """, + +That is, add your extraction method to the entry point group +``babel.extractors``, where the name of the entry point is the name that people +will use to reference the extraction method, and the value being the module and +the name of the function (separated by a colon) implementing the actual +extraction. + +.. _`setuptools`: http://peak.telecommunity.com/DevCenter/setuptools + +Comments Tags And Translator Comments Explanation +................................................. + +First of all what are comments tags. Comments tags are excerpts of text to +search for in comments, only comments, right before the `python gettext`_ +calls, as shown on the following example: + + .. _`python gettext`: http://docs.python.org/lib/module-gettext.html + +.. code-block:: python + + # NOTE: This is a comment about `Foo Bar` + _('Foo Bar') + +The comments tag for the above example would be ``NOTE:``, and the translator +comment for that tag would be ``This is a comment about `Foo Bar```. + +The resulting output in the catalog template would be something like:: + + #. This is a comment about `Foo Bar` + #: main.py:2 + msgid "Foo Bar" + msgstr "" + +Now, you might ask, why would I need that? + +Consider this simple case; you have a menu item called “Manual”. You know what +it means, but when the translator sees this they will wonder did you mean: + +1. a document or help manual, or +2. a manual process? + +This is the simplest case where a translation comment such as +“The installation manual” helps to clarify the situation and makes a translator +more productive. + +**More examples of the need for translation comments** + +Real world examples are best. This is a discussion over the use of the word +“Forward” in Northern Sotho: + +“When you go forward. You go ‘Pele’, but when you forward the document, +you ‘Fetišetša pele’. So if you just say forward, we don’t know what you are +talking about. +It is better if it's in a sentence. But in this case i think we will use ‘pele’ +because on the string no. 86 and 88 there is “show previous page in history” +and “show next page in history”. + +Were the translators guess correct? I think so, but it makes it so much easier +if they don’t need to be super `sleuths`_ as well as translators. + + .. _`sleuths`: http://www.thefreedictionary.com/sleuth + + +*Explanation Borrowed From:* `Wordforge`_ + + .. _`Wordforge`: http://www.wordforge.org/static/translation_comments.html + +**Note**: Translator comments are currently only supported in python source +code. + diff --git a/0.8.x/doc/numbers.txt b/0.8.x/doc/numbers.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/numbers.txt @@ -0,0 +1,111 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +========================== +Number Formatting +========================== + + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +Support for locale-specific formatting and parsing of numbers is provided by +the ``babel.numbers`` module: + +.. code-block:: pycon + + >>> from babel.numbers import format_number, format_decimal, format_percent + +Examples: + +.. code-block:: pycon + + >>> format_decimal(1.2345, locale='en_US') + u'1.234' + >>> format_decimal(1.2345, locale='sv_SE') + u'1,234' + >>> format_decimal(12345, locale='de_DE') + u'12.345' + + +Pattern Syntax +============== + +While Babel makes it simple to use the appropriate number format for a given +locale, you can also force it to use custom patterns. As with date/time +formatting patterns, the patterns Babel supports for number formatting are +based on the `Locale Data Markup Language specification`_ (LDML). + +Examples: + +.. code-block:: pycon + + >>> format_decimal(-1.2345, format='#,##0.##;-#', locale='en') + u'-1.23' + >>> format_decimal(-1.2345, format='#,##0.##;(#)', locale='en') + u'(1.23)' + +The syntax for custom number format patterns is described in detail in the +the specification. The following table is just a relatively brief overview. + + .. _`Locale Data Markup Language specification`: http://unicode.org/reports/tr35/#Number_Format_Patterns + + +----------+-----------------------------------------------------------------+ + | Symbol | Description | + +==========+=================================================================+ + | ``0`` | Digit | + +----------+-----------------------------------------------------------------+ + | ``1-9`` | '1' through '9' indicate rounding. | + +----------+-----------------------------------------------------------------+ + | ``@`` | Significant digit | + +----------+-----------------------------------------------------------------+ + | ``#`` | Digit, zero shows as absent | + +----------+-----------------------------------------------------------------+ + | ``.`` | Decimal separator or monetary decimal separator | + +----------+-----------------------------------------------------------------+ + | ``-`` | Minus sign | + +----------+-----------------------------------------------------------------+ + | ``,`` | Grouping separator | + +----------+-----------------------------------------------------------------+ + | ``E`` | Separates mantissa and exponent in scientific notation | + +----------+-----------------------------------------------------------------+ + | ``+`` | Prefix positive exponents with localized plus sign | + +----------+-----------------------------------------------------------------+ + | ``;`` | Separates positive and negative subpatterns | + +----------+-----------------------------------------------------------------+ + | ``%`` | Multiply by 100 and show as percentage | + +----------+-----------------------------------------------------------------+ + | ``‰`` | Multiply by 1000 and show as per mille | + +----------+-----------------------------------------------------------------+ + | ``¤`` | Currency sign, replaced by currency symbol. If doubled, | + | | replaced by international currency symbol. If tripled, uses the | + | | long form of the decimal symbol. | + +----------+-----------------------------------------------------------------+ + | ``'`` | Used to quote special characters in a prefix or suffix | + +----------+-----------------------------------------------------------------+ + | ``*`` | Pad escape, precedes pad character | + +----------+-----------------------------------------------------------------+ + + +Parsing Numbers +=============== + +Babel can also parse numeric data in a locale-sensitive manner: + +.. code-block:: pycon + + >>> from babel.numbers import parse_decimal, parse_number + +Examples: + +.. code-block:: pycon + + >>> parse_decimal('1,099.98', locale='en_US') + 1099.98 + >>> parse_decimal('1.099,98', locale='de') + 1099.98 + >>> parse_decimal('2,109,998', locale='de') + Traceback (most recent call last): + ... + NumberFormatError: '2,109,998' is not a valid decimal number diff --git a/0.8.x/doc/setup.txt b/0.8.x/doc/setup.txt new file mode 100644 --- /dev/null +++ b/0.8.x/doc/setup.txt @@ -0,0 +1,214 @@ +.. -*- mode: rst; encoding: utf-8 -*- + +================================ +Distutils/Setuptools Integration +================================ + +Babel provides commands for integration into ``setup.py`` scripts, based on +either the ``distutils`` package that is part of the Python standard library, +or the third-party ``setuptools`` package. + +These commands are available by default when Babel has been properly installed, +and ``setup.py`` is using ``setuptools``. For projects that use plain old +``distutils``, the commands need to be registered explicitly, for example: + +.. code-block:: python + + from distutils.core import setup + from babel.messages import frontend as babel + + setup( + ... + cmd_class = {'extract_messages': babel.extract_messages, + 'new_catalog': babel.new_catalog} + ) + + +.. contents:: Contents + :depth: 2 +.. sectnum:: + + +extract_messages +================ + +The ``extract_messages`` command is comparable to the GNU ``xgettext`` program: +it can extract localizable messages from a variety of difference source files, +and generate a PO (portable object) template file from the collected messages. + +If the command has been correctly installed or registered, another project's +``setup.py`` script should allow you to use the command:: + + $ ./setup.py extract_messages --help + Global options: + --verbose (-v) run verbosely (default) + --quiet (-q) run quietly (turns verbosity off) + --dry-run (-n) don't actually do anything + --help (-h) show detailed help message + + Options for 'extract_messages' command: + ... + +Running the command will produce a PO template file:: + + $ ./setup.py extract_messages --output-file foobar/locale/messages.pot + running extract_messages + extracting messages from foobar/__init__.py + extracting messages from foobar/core.py + ... + writing PO template file to foobar/locale/messages.pot + + +Method Mapping +-------------- + +The mapping of file patterns to extraction methods (and options) can be +specified using a configuration file that is pointed to using the +``--mapping-file`` option shown above. Alternatively, you can configure the +mapping directly in ``setup.py`` using a keyword argument to the ``setup()`` +function: + +.. code-block:: python + + setup(... + + message_extractors = { + 'foobar': [ + ('**.py', 'python', None), + ('**/templates/**.html', 'genshi', None), + ('**/templates/**.txt', 'genshi', { + 'template_class': 'genshi.template.text.TextTemplate' + }) + ], + }, + + ... + ) + + +Options +------- + +The ``extract_messages`` command accepts the following options: + + +-----------------------------+----------------------------------------------+ + | Option | Description | + +=============================+==============================================+ + | ``--charset`` | charset to use in the output file | + +-----------------------------+----------------------------------------------+ + | ``--keywords`` (``-k``) | space-separated list of keywords to look for | + | | in addition to the defaults | + +-----------------------------+----------------------------------------------+ + | ``--no-default-keywords`` | do not include the default keywords | + +-----------------------------+----------------------------------------------+ + | ``--mapping-file`` (``-F``) | path to the mapping configuration file | + +-----------------------------+----------------------------------------------+ + | ``--no-location`` | do not include location comments with | + | | filename and line number | + +-----------------------------+----------------------------------------------+ + | ``--omit-header`` | do not include msgid "" entry in header | + +-----------------------------+----------------------------------------------+ + | ``--output-file`` (``-o``) | name of the output file | + +-----------------------------+----------------------------------------------+ + | ``--width`` (``-w``) | set output line width (default 76) | + +-----------------------------+----------------------------------------------+ + | ``--no-wrap`` | do not break long message lines, longer than | + | | the output line width, into several lines | + +-----------------------------+----------------------------------------------+ + | ``--input-dirs`` | directories that should be scanned for | + | | messages | + +-----------------------------+----------------------------------------------+ + | ``--sort-output`` | generate sorted output (default False) | + +-----------------------------+----------------------------------------------+ + | ``--sort-by-file`` | sort output by file location (default False) | + +-----------------------------+----------------------------------------------+ + | ``--msgid-bugs-address`` | set email address for message bug reports | + +-----------------------------+----------------------------------------------+ + | ``--copyright-holder`` | set copyright holder in output | + +-----------------------------+----------------------------------------------+ + | ``--add-comments (-c)`` | place comment block with TAG (or those | + | | preceding keyword lines) in output file. | + | | Separate multiple TAGs with commas(,) | + +-----------------------------+----------------------------------------------+ + +These options can either be specified on the command-line, or in the +``setup.cfg`` file. In the latter case, the options above become entries of the +section ``[extract_messages]``, and the option names are changed to use +underscore characters instead of dashes, for example: + +.. code-block:: ini + + [extract_messages] + keywords = _, gettext, ngettext + mapping_file = babel.cfg + width = 80 + +This would be equivalent to invoking the command from the command-line as +follows:: + + $ setup.py extract_messages -k _ -k gettext -k ngettext -F mapping.cfg -w 80 + +Any path names are interpreted relative to the location of the ``setup.py`` +file. For boolean options, use "true" or "false" values. + + +new_catalog +=========== + +The ``new_catalog`` command is basically equivalent to the GNU ``msginit`` +program: it creates a new translation catalog based on a PO template file (POT). + +If the command has been correctly installed or registered, another project's +``setup.py`` script should allow you to use the command:: + + $ ./setup.py new_catalog --help + Global options: + --verbose (-v) run verbosely (default) + --quiet (-q) run quietly (turns verbosity off) + --dry-run (-n) don't actually do anything + --help (-h) show detailed help message + + Options for 'new_catalog' command: + ... + +Running the command will produce a PO file:: + + $ ./setup.py new_catalog -l fr -i foobar/locales/messages.pot \ + -o foobar/locales/fr/messages.po + running new_catalog + creating catalog 'foobar/locales/fr/messages.po' based on 'foobar/locales/messages.pot' + + +Options +------- + +The ``new_catalog`` command accepts the following options: + + +-----------------------------+----------------------------------------------+ + | Option | Description | + +=============================+==============================================+ + | ``--domain`` | domain of the PO file (defaults to | + | | lower-cased project name) | + +-----------------------------+----------------------------------------------+ + | ``--input-file`` (``-i``) | name of the input file | + +-----------------------------+----------------------------------------------+ + | ``--output-dir`` (``-d``) | name of the output directory | + +-----------------------------+----------------------------------------------+ + | ``--output-file`` (``-o``) | name of the output file | + +-----------------------------+----------------------------------------------+ + | ``--locale`` | locale for the new localized string | + +-----------------------------+----------------------------------------------+ + | ``--omit-header`` | do not include msgid "" entry in header | + +-----------------------------+----------------------------------------------+ + | ``--first-author`` | name of the first author | + +-----------------------------+----------------------------------------------+ + | ``--first-author-email`` | email address of the first author | + +-----------------------------+----------------------------------------------+ + +If ``output-dir`` is specified, but ``output-file`` is not, the default filename +of the output file will be:: + + //LC_MESSAGES/.po + +These options can either be specified on the command-line, or in the +``setup.cfg`` file. diff --git a/0.8.x/doc/style/bkgnd_pattern.png b/0.8.x/doc/style/bkgnd_pattern.png new file mode 100644 index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..90e92682135d3f7213332f870f973bd06d6d57ee GIT binary patch literal 112 zc%17D@N?(olHy`uVBq!ia0vp^B0wy_$P6TPuAPklQfvV}A+FxOzMSaRTj#)AV3j$Ayz@Myi$y`k~e02bnvp_D$RTJdd?9OQ`;xMNQr6-*0k%)gGDlIK1ArveB)f zy1Birqq{afsXt{x+N6vrS=0I^&YBz=ojG&P!l|%IowZOI~+1jT1O3(*SY z=AC!{^{#V=egxhBzNNG+Wcfh@o-JZ+A^YdBKeo7Z&60On=AvAqCA)Sc&P(TZUiF#( z^rj1DlCO7NijoNWTO^lr+bufXqg3k6Z@=t^|9Q_@g4NuszNO~8s$076@rDAqInO=8 zN?HUmba__RS@I|KY$^3?e!VMWHqT1EydCdvADZy$5&PrG?DFy^Z!PAn-4+0JID@CF KpUXO@geCwp5oi>sys.stderr, 'Processing input file %r' % filename + stem, ext = os.path.splitext(filename) + if ext != '.xml': + continue + + tree = parse(os.path.join(srcdir, 'main', filename)) + data = {} + + language = None + elem = tree.find('//identity/language') + if elem is not None: + language = elem.attrib['type'] + print>>sys.stderr, ' Language: %r' % language + + territory = None + elem = tree.find('//identity/territory') + if elem is not None: + territory = elem.attrib['type'] + else: + territory = '001' # world + print>>sys.stderr, ' Territory: %r' % territory + regions = territory_containment.get(territory, []) + print>>sys.stderr, ' Regions: %r' % regions + + # + + territories = data.setdefault('territories', {}) + for elem in tree.findall('//territories/territory'): + if 'draft' in elem.attrib and elem.attrib['type'] in territories: + continue + territories[elem.attrib['type']] = _text(elem) + + languages = data.setdefault('languages', {}) + for elem in tree.findall('//languages/language'): + if 'draft' in elem.attrib and elem.attrib['type'] in languages: + continue + languages[elem.attrib['type']] = _text(elem) + + variants = data.setdefault('variants', {}) + for elem in tree.findall('//variants/variant'): + if 'draft' in elem.attrib and elem.attrib['type'] in variants: + continue + variants[elem.attrib['type']] = _text(elem) + + scripts = data.setdefault('scripts', {}) + for elem in tree.findall('//scripts/script'): + if 'draft' in elem.attrib and elem.attrib['type'] in scripts: + continue + scripts[elem.attrib['type']] = _text(elem) + + # + + week_data = data.setdefault('week_data', {}) + supelem = sup.find('//weekData') + + for elem in supelem.findall('minDays'): + territories = elem.attrib['territories'].split() + if territory in territories or any([r in territories for r in regions]): + week_data['min_days'] = int(elem.attrib['count']) + + for elem in supelem.findall('firstDay'): + territories = elem.attrib['territories'].split() + if territory in territories or any([r in territories for r in regions]): + week_data['first_day'] = weekdays[elem.attrib['day']] + + for elem in supelem.findall('weekendStart'): + territories = elem.attrib['territories'].split() + if territory in territories or any([r in territories for r in regions]): + week_data['weekend_start'] = weekdays[elem.attrib['day']] + + for elem in supelem.findall('weekendEnd'): + territories = elem.attrib['territories'].split() + if territory in territories or any([r in territories for r in regions]): + week_data['weekend_end'] = weekdays[elem.attrib['day']] + + time_zones = data.setdefault('time_zones', {}) + for elem in tree.findall('//timeZoneNames/zone'): + info = {} + city = elem.findtext('exemplarCity') + if city: + info['city'] = unicode(city) + for child in elem.findall('long/*'): + info.setdefault('long', {})[child.tag] = unicode(child.text) + for child in elem.findall('short/*'): + info.setdefault('short', {})[child.tag] = unicode(child.text) + time_zones[elem.attrib['type']] = info + + zone_aliases = data.setdefault('zone_aliases', {}) + if stem == 'root': + for elem in sup.findall('//timezoneData/zoneFormatting/zoneItem'): + if 'aliases' in elem.attrib: + canonical_id = elem.attrib['type'] + for alias in elem.attrib['aliases'].split(): + zone_aliases[alias] = canonical_id + + for calendar in tree.findall('//calendars/calendar'): + if calendar.attrib['type'] != 'gregorian': + # TODO: support other calendar types + continue + + months = data.setdefault('months', {}) + for ctxt in calendar.findall('months/monthContext'): + ctxts = months.setdefault(ctxt.attrib['type'], {}) + for width in ctxt.findall('monthWidth'): + widths = ctxts.setdefault(width.attrib['type'], {}) + for elem in width.findall('month'): + if 'draft' in elem.attrib and int(elem.attrib['type']) in widths: + continue + widths[int(elem.attrib.get('type'))] = unicode(elem.text) + + days = data.setdefault('days', {}) + for ctxt in calendar.findall('days/dayContext'): + ctxts = days.setdefault(ctxt.attrib['type'], {}) + for width in ctxt.findall('dayWidth'): + widths = ctxts.setdefault(width.attrib['type'], {}) + for elem in width.findall('day'): + dtype = weekdays[elem.attrib['type']] + if 'draft' in elem.attrib and dtype in widths: + continue + widths[dtype] = unicode(elem.text) + + quarters = data.setdefault('quarters', {}) + for ctxt in calendar.findall('quarters/quarterContext'): + ctxts = quarters.setdefault(ctxt.attrib['type'], {}) + for width in ctxt.findall('quarterWidth'): + widths = ctxts.setdefault(width.attrib['type'], {}) + for elem in width.findall('quarter'): + if 'draft' in elem.attrib and int(elem.attrib['type']) in widths: + continue + widths[int(elem.attrib.get('type'))] = unicode(elem.text) + + eras = data.setdefault('eras', {}) + for width in calendar.findall('eras/*'): + ewidth = {'eraNames': 'wide', 'eraAbbr': 'abbreviated'}[width.tag] + widths = eras.setdefault(ewidth, {}) + for elem in width.findall('era'): + if 'draft' in elem.attrib and int(elem.attrib['type']) in widths: + continue + widths[int(elem.attrib.get('type'))] = unicode(elem.text) + + # AM/PM + periods = data.setdefault('periods', {}) + for elem in calendar.findall('am'): + if 'draft' in elem.attrib and elem.tag in periods: + continue + periods[elem.tag] = unicode(elem.text) + for elem in calendar.findall('pm'): + if 'draft' in elem.attrib and elem.tag in periods: + continue + periods[elem.tag] = unicode(elem.text) + + date_formats = data.setdefault('date_formats', {}) + for elem in calendar.findall('dateFormats/dateFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in date_formats: + continue + try: + date_formats[elem.attrib.get('type')] = \ + dates.parse_pattern(unicode(elem.findtext('dateFormat/pattern'))) + except ValueError, e: + print>>sys.stderr, 'ERROR: %s' % e + + time_formats = data.setdefault('time_formats', {}) + for elem in calendar.findall('timeFormats/timeFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in time_formats: + continue + try: + time_formats[elem.attrib.get('type')] = \ + dates.parse_pattern(unicode(elem.findtext('timeFormat/pattern'))) + except ValueError, e: + print>>sys.stderr, 'ERROR: %s' % e + + datetime_formats = data.setdefault('datetime_formats', {}) + for elem in calendar.findall('dateTimeFormats/dateTimeFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in datetime_formats: + continue + try: + datetime_formats[elem.attrib.get('type')] = \ + unicode(elem.findtext('dateTimeFormat/pattern')) + except ValueError, e: + print>>sys.stderr, 'ERROR: %s' % e + + # + + number_symbols = data.setdefault('number_symbols', {}) + for elem in tree.findall('//numbers/symbols/*'): + number_symbols[elem.tag] = unicode(elem.text) + + decimal_formats = data.setdefault('decimal_formats', {}) + for elem in tree.findall('//decimalFormats/decimalFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in decimal_formats: + continue + pattern = unicode(elem.findtext('decimalFormat/pattern')) + decimal_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + + scientific_formats = data.setdefault('scientific_formats', {}) + for elem in tree.findall('//scientificFormats/scientificFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in scientific_formats: + continue + pattern = unicode(elem.findtext('scientificFormat/pattern')) + scientific_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + + currency_formats = data.setdefault('currency_formats', {}) + for elem in tree.findall('//currencyFormats/currencyFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in currency_formats: + continue + pattern = unicode(elem.findtext('currencyFormat/pattern')) + currency_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + + percent_formats = data.setdefault('percent_formats', {}) + for elem in tree.findall('//percentFormats/percentFormatLength'): + if 'draft' in elem.attrib and elem.attrib.get('type') in percent_formats: + continue + pattern = unicode(elem.findtext('percentFormat/pattern')) + percent_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + + currency_names = data.setdefault('currency_names', {}) + currency_symbols = data.setdefault('currency_symbols', {}) + for elem in tree.findall('//currencies/currency'): + name = elem.findtext('displayName') + if name: + currency_names[elem.attrib['type']] = unicode(name) + symbol = elem.findtext('symbol') + if symbol: + currency_symbols[elem.attrib['type']] = unicode(symbol) + + dicts[stem] = data + outfile = open(os.path.join(destdir, stem + '.dat'), 'wb') + try: + pickle.dump(data, outfile, 2) + finally: + outfile.close() + +if __name__ == '__main__': + main() diff --git a/0.8.x/setup.cfg b/0.8.x/setup.cfg new file mode 100644 --- /dev/null +++ b/0.8.x/setup.cfg @@ -0,0 +1,3 @@ +[egg_info] +tag_build = dev +tag_svn_revision = true diff --git a/0.8.x/setup.py b/0.8.x/setup.py new file mode 100755 --- /dev/null +++ b/0.8.x/setup.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2007 Edgewall Software +# All rights reserved. +# +# This software is licensed as described in the file COPYING, which +# you should have received as part of this distribution. The terms +# are also available at http://babel.edgewall.org/wiki/License. +# +# This software consists of voluntary contributions made by many +# individuals. For the exact contribution history, see the revision +# history and logs, available at http://babel.edgewall.org/log/. + +from distutils.cmd import Command +import doctest +from glob import glob +import os +try: + from setuptools import setup +except ImportError: + from distutils.core import setup +import sys + + +class build_doc(Command): + description = 'Builds the documentation' + user_options = [ + ('force', None, + "force regeneration even if no reStructuredText files have changed"), + ('without-apidocs', None, + "whether to skip the generation of API documentaton"), + ] + boolean_options = ['force', 'without-apidocs'] + + def initialize_options(self): + self.force = False + self.without_apidocs = False + + def finalize_options(self): + pass + + def run(self): + from docutils.core import publish_cmdline + from docutils.nodes import raw + from docutils.parsers import rst + + docutils_conf = os.path.join('doc', 'conf', 'docutils.ini') + epydoc_conf = os.path.join('doc', 'conf', 'epydoc.ini') + + try: + from pygments import highlight + from pygments.lexers import get_lexer_by_name + from pygments.formatters import HtmlFormatter + + def code_block(name, arguments, options, content, lineno, + content_offset, block_text, state, state_machine): + lexer = get_lexer_by_name(arguments[0]) + html = highlight('\n'.join(content), lexer, HtmlFormatter()) + return [raw('', html, format='html')] + code_block.arguments = (1, 0, 0) + code_block.options = {'language' : rst.directives.unchanged} + code_block.content = 1 + rst.directives.register_directive('code-block', code_block) + except ImportError: + print 'Pygments not installed, syntax highlighting disabled' + + for source in glob('doc/*.txt'): + dest = os.path.splitext(source)[0] + '.html' + if self.force or not os.path.exists(dest) or \ + os.path.getmtime(dest) < os.path.getmtime(source): + print 'building documentation file %s' % dest + publish_cmdline(writer_name='html', + argv=['--config=%s' % docutils_conf, source, + dest]) + + if not self.without_apidocs: + try: + from epydoc import cli + old_argv = sys.argv[1:] + sys.argv[1:] = [ + '--config=%s' % epydoc_conf, + '--no-private', # epydoc bug, not read from config + '--simple-term', + '--verbose' + ] + cli.cli() + sys.argv[1:] = old_argv + + except ImportError: + print 'epydoc not installed, skipping API documentation.' + + +class test_doc(Command): + description = 'Tests the code examples in the documentation' + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + for filename in glob('doc/*.txt'): + print 'testing documentation file %s' % filename + doctest.testfile(filename, False, optionflags=doctest.ELLIPSIS) + + +setup( + name = 'Babel', + version = '0.8', + description = 'Internationalization utilities', + long_description = \ +"""A collection of tools for internationalizing Python applications.""", + author = 'Edgewall Software', + author_email = 'info@edgewall.org', + license = 'BSD', + url = 'http://babel.edgewall.org/', + download_url = 'http://babel.edgewall.org/wiki/Download', + zip_safe = False, + + classifiers = [ + 'Development Status :: 4 - Beta', + 'Environment :: Web Environment', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], + packages = ['babel', 'babel.messages'], + package_data = {'babel': ['localedata/*.dat']}, + test_suite = 'babel.tests.suite', + + entry_points = """ + [console_scripts] + babel = babel.messages.frontend:main + + [distutils.commands] + extract_messages = babel.messages.frontend:extract_messages + new_catalog = babel.messages.frontend:new_catalog + + [distutils.setup_keywords] + message_extractors = babel.messages.frontend:check_message_extractors + + [babel.extractors] + ignore = babel.messages.extract:extract_nothing + python = babel.messages.extract:extract_python + """, + + cmdclass = {'build_doc': build_doc, 'test_doc': test_doc} +)