diff options
-rw-r--r-- | ChangeLog | 5 | ||||
-rw-r--r-- | babel/compat.py | 6 | ||||
-rw-r--r-- | babel/core.py | 56 | ||||
-rw-r--r-- | babel/dates.py | 158 | ||||
-rwxr-xr-x | babel/messages/frontend.py | 77 | ||||
-rw-r--r-- | babel/messages/tests/test_frontend.py | 140 | ||||
-rw-r--r-- | babel/numbers.py | 37 | ||||
-rw-r--r-- | babel/support.py | 12 | ||||
-rw-r--r-- | babel/tests/test_dates.py | 21 | ||||
-rw-r--r-- | babel/tests/test_support.py | 3 | ||||
-rw-r--r-- | babel/util.py | 9 | ||||
-rwxr-xr-x | scripts/import_cldr.py | 18 | ||||
-rwxr-xr-x | setup.py | 67 |
13 files changed, 412 insertions, 197 deletions
@@ -59,6 +59,11 @@ http://svn.edgewall.org/repos/babel/tags/1.0.0/ Étienne Bersac) * ensure .mo file header contains the same information as the source .po file (#199) + * added support for get_language_name() on the locale objects. + * added support for get_territory_name() on the locale objects. + * added support for get_script_name() on the locale objects. + * added pluralization support for currency names and added a '¤¤¤' + pattern for currencies that includes the full name. Version 0.9.6 diff --git a/babel/compat.py b/babel/compat.py index 8aab36f..c9d407a 100644 --- a/babel/compat.py +++ b/babel/compat.py @@ -12,12 +12,6 @@ # history and logs, available at http://babel.edgewall.org/log/. try: - any = any -except NameError: - def any(iterable): - return filter(None, list(iterable)) - -try: import threading except ImportError: import dummy_threading as threading diff --git a/babel/core.py b/babel/core.py index 42dcf7d..a72684d 100644 --- a/babel/core.py +++ b/babel/core.py @@ -287,6 +287,57 @@ class Locale(object): :type: `unicode` """) + def get_language_name(self, locale=None): + """Return the language of this locale in the given locale. + + >>> Locale('zh', 'CN', script='Hans').get_language_name('de') + u'Chinesisch' + + .. versionadded:: 1.0 + + :param locale: the locale to use + :return: the display name of the language + """ + if locale is None: + locale = self + locale = Locale.parse(locale) + return locale.languages.get(self.language) + + language_name = property(get_language_name, doc="""\ + The localized language name of the locale. + + >>> Locale('en', 'US').language_name + u'English' + """) + + def get_territory_name(self, locale=None): + """Return the territory name in the given locale.""" + if locale is None: + locale = self + locale = Locale.parse(locale) + return locale.territories.get(self.territory) + + territory_name = property(get_territory_name, doc="""\ + The localized territory name of the locale if available. + + >>> Locale('de', 'DE').territory_name + u'Deutschland' + """) + + def get_script_name(self, locale=None): + """Return the script name in the given locale.""" + if locale is None: + locale = self + locale = Locale.parse(locale) + return locale.scripts.get(self.script) + + script_name = property(get_script_name, doc="""\ + The localized script name of the locale if available. + + >>> Locale('ms', 'SG', script='Latn').script_name + u'Latin' + """) + @property def english_name(self): """The english display name of the locale. @@ -348,7 +399,10 @@ class Locale(object): @property def currencies(self): - """Mapping of currency codes to translated currency names. + """Mapping of currency codes to translated currency names. This + only returns the generic form of the currency name, not the count + specific one. If an actual number is requested use the + :func:`babel.numbers.get_currency_name` function. >>> Locale('en').currencies['COP'] u'Colombian Peso' diff --git a/babel/dates.py b/babel/dates.py index 642195b..562abf1 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -23,10 +23,12 @@ following environment variables, in that order: from __future__ import division from datetime import date, datetime, time, timedelta +from bisect import bisect_right import re +import pytz as _pytz from babel.core import default_locale, get_global, Locale -from babel.util import UTC +from babel.util import UTC, LOCALTZ __all__ = ['format_date', 'format_datetime', 'format_time', 'format_timedelta', 'get_timezone_name', 'parse_date', 'parse_datetime', 'parse_time'] @@ -39,6 +41,78 @@ date_ = date datetime_ = datetime time_ = time +def get_timezone(zone): + # XXX: return _pytz.timezone with a nice fallback. + if zone is None: + return LOCALTZ + if not isinstance(zone, basestring): + return zone + try: + return _pytz.timezone(zone) + except _pytz.UnknownTimeZoneError: + raise LookupError('Unknown timezone %s' % zone) + +def get_next_timezone_transition(zone, dt=None): + zone = get_timezone(zone) + if dt is None: + dt = datetime.utcnow() + else: + dt = dt.replace(tzinfo=None) + + if not hasattr(zone, '_utc_transition_times'): + raise TypeError('Given timezone does not have UTC transition ' + 'times. This can happen because the operating ' + 'system local timezone is used of a custom ' + 'timezone object') + + try: + idx = max(0, bisect_right(zone._utc_transition_times, dt)) + old_trans = zone._transition_info[idx - 1] + new_trans = zone._transition_info[idx] + old_tz = zone._tzinfos[old_trans] + new_tz = zone._tzinfos[new_trans] + except (LookupError, ValueError): + raise RuntimeError('Could not calculate transition') + + return TimezoneTransition( + activates=zone._utc_transition_times[idx], + from_tzinfo=old_tz, + to_tzinfo=new_tz, + reference_date=dt + ) + + +class TimezoneTransition(object): + + def __init__(self, activates, from_tzinfo, to_tzinfo, reference_date=None): + self.activates = activates + self.from_tzinfo = from_tzinfo + self.to_tzinfo = to_tzinfo + self.reference_date = reference_date + + @property + def from_tz(self): + return self.from_tzinfo._tzname + + @property + def to_tz(self): + return self.to_tzinfo._tzname + + @property + def from_offset(self): + return self.from_tzinfo._utcoffset.total_seconds() + + @property + def to_offset(self): + return self.to_tzinfo._utcoffset.total_seconds() + + def __repr__(self): + return '<TimezoneTransition %s -> %s (%s)>' % ( + self.from_tz, + self.to_tz, + self.activates, + ) + def get_period_names(locale=LC_TIME): """Return the names for day periods (AM/PM) used by the locale. @@ -178,8 +252,7 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): >>> get_timezone_gmt(dt, locale='en') u'GMT+00:00' - >>> from pytz import timezone - >>> tz = timezone('America/Los_Angeles') + >>> tz = get_timezone('America/Los_Angeles') >>> dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) >>> get_timezone_gmt(dt, locale='en') u'GMT-08:00' @@ -223,18 +296,17 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): The result depends on both the local display name of the country and the city associated with the time zone: - >>> from pytz import timezone - >>> tz = timezone('America/St_Johns') + >>> tz = get_timezone('America/St_Johns') >>> get_timezone_location(tz, locale='de_DE') u"Kanada (St. John's) Zeit" - >>> tz = timezone('America/Mexico_City') + >>> tz = get_timezone('America/Mexico_City') >>> get_timezone_location(tz, locale='de_DE') u'Mexiko (Mexiko-Stadt) Zeit' If the timezone is associated with a country that uses only a single timezone, just the localized country name is returned: - >>> tz = timezone('Europe/Berlin') + >>> tz = get_timezone('Europe/Berlin') >>> get_timezone_name(tz, locale='de_DE') u'Mitteleurop\\xe4ische Zeit' @@ -304,8 +376,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, r"""Return the localized display name for the given timezone. The timezone may be specified using a ``datetime`` or `tzinfo` object. - >>> from pytz import timezone - >>> dt = time(15, 30, tzinfo=timezone('America/Los_Angeles')) + >>> dt = time(15, 30, tzinfo=get_timezone('America/Los_Angeles')) >>> get_timezone_name(dt, locale='en_US') u'Pacific Standard Time' >>> get_timezone_name(dt, width='short', locale='en_US') @@ -316,7 +387,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, time. This can be used for example for selecting timezones, or to set the time of events that recur across DST changes: - >>> tz = timezone('America/Los_Angeles') + >>> tz = get_timezone('America/Los_Angeles') >>> get_timezone_name(tz, locale='en_US') u'Pacific Time' >>> get_timezone_name(tz, 'short', locale='en_US') @@ -326,7 +397,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, is associated with a country that uses only a single timezone, the name of that country is returned, formatted according to the locale: - >>> tz = timezone('Europe/Berlin') + >>> tz = get_timezone('Europe/Berlin') >>> get_timezone_name(tz, locale='de_DE') u'Mitteleurop\xe4ische Zeit' >>> get_timezone_name(tz, locale='pt_BR') @@ -335,7 +406,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, On the other hand, if the country uses multiple timezones, the city is also included in the representation: - >>> tz = timezone('America/St_Johns') + >>> tz = get_timezone('America/St_Johns') >>> get_timezone_name(tz, locale='de_DE') u'Neufundland-Zeit' @@ -458,12 +529,11 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, 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/Paris'), + >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'), ... locale='fr_FR') u'dimanche 1 avril 2007 17:30:00 heure avanc\xe9e d\u2019Europe centrale' >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", - ... tzinfo=timezone('US/Eastern'), locale='en') + ... tzinfo=get_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 @@ -512,16 +582,15 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): >>> 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: + For any pattern requiring the display of the time-zone a + timezone has to be specified explicitly: - >>> from pytz import timezone >>> t = datetime(2007, 4, 1, 15, 30) - >>> tzinfo = timezone('Europe/Paris') + >>> tzinfo = get_timezone('Europe/Paris') >>> t = tzinfo.localize(t) >>> format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR') u'15:30:00 heure avanc\xe9e d\u2019Europe centrale' - >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=timezone('US/Eastern'), + >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'), ... locale='en') u"09 o'clock AM, Eastern Daylight Time" @@ -539,10 +608,10 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): parameter is only used to display the timezone name if needed: >>> t = time(15, 30) - >>> format_time(t, format='full', tzinfo=timezone('Europe/Paris'), + >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'), ... locale='fr_FR') u'15:30:00 heure normale de l\u2019Europe centrale' - >>> format_time(t, format='full', tzinfo=timezone('US/Eastern'), + >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'), ... locale='en_US') u'3:30:00 PM Eastern Standard Time' @@ -589,13 +658,15 @@ TIMEDELTA_UNITS = ( ('second', 1) ) -def format_timedelta(delta, granularity='second', threshold=.85, locale=LC_TIME): +def format_timedelta(delta, granularity='second', threshold=.85, + add_direction=False, format='medium', + locale=LC_TIME): """Return a time delta according to the rules of the given locale. >>> format_timedelta(timedelta(weeks=12), locale='en_US') - u'3 mths' + u'3 months' >>> format_timedelta(timedelta(seconds=1), locale='es') - u'1 s' + u'1 segundo' The granularity parameter can be provided to alter the lowest unit presented, which defaults to a second. @@ -611,7 +682,15 @@ def format_timedelta(delta, granularity='second', threshold=.85, locale=LC_TIME) >>> format_timedelta(timedelta(hours=23), threshold=0.9, locale='en_US') u'1 day' >>> format_timedelta(timedelta(hours=23), threshold=1.1, locale='en_US') - u'23 hrs' + u'23 hours' + + In addition directional information can be provided that informs + the user if the date is in the past or in the future: + + >>> format_timedelta(timedelta(hours=1), add_direction=True) + u'In 1 hour' + >>> format_timedelta(timedelta(hours=-1), add_direction=True) + u'1 hour ago' :param delta: a ``timedelta`` object representing the time difference to format, or the delta in seconds as an `int` value @@ -620,15 +699,32 @@ def format_timedelta(delta, granularity='second', threshold=.85, locale=LC_TIME) "hour", "minute" or "second" :param threshold: factor that determines at which point the presentation switches to the next higher unit + :param add_direction: if this flag is set to `True` the return value will + include directional information. For instance a + positive timedelta will include the information about + it being in the future, a negative will be information + about the value being in the past. + :param format: the format (currently only "medium" and "short" are supported) :param locale: a `Locale` object or a locale identifier :rtype: `unicode` """ + if format not in ('short', 'medium'): + raise TypeError('Format can only be one of "short" or "medium"') if isinstance(delta, timedelta): seconds = int((delta.days * 86400) + delta.seconds) else: seconds = delta locale = Locale.parse(locale) + def _iter_choices(unit): + if add_direction: + if seconds >= 0: + yield unit + '-future' + else: + yield unit + '-past' + yield unit + ':' + format + yield unit + for unit, secs_per_unit in TIMEDELTA_UNITS: value = abs(seconds) / secs_per_unit if value >= threshold or unit == granularity: @@ -636,7 +732,15 @@ def format_timedelta(delta, granularity='second', threshold=.85, locale=LC_TIME) value = max(1, value) value = int(round(value)) plural_form = locale.plural_form(value) - pattern = locale._data['unit_patterns'][unit][plural_form] + pattern = None + for choice in _iter_choices(unit): + patterns = locale._data['unit_patterns'].get(choice) + if patterns is not None: + pattern = patterns[plural_form] + break + # This really should not happen + if pattern is None: + return u'' return pattern.replace('{0}', str(value)) return u'' diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 90d01a4..753151e 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -881,11 +881,6 @@ class CommandLineInterface(object): 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: @@ -914,47 +909,53 @@ class CommandLineInterface(object): parser.error("'--sort-output' and '--sort-by-file' are mutually " "exclusive") - try: - catalog = Catalog(project=options.project, - version=options.version, - 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) - - 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()]) - self.log.info('extracting messages from %s%s', filepath, - optstr) + catalog = Catalog(project=options.project, + version=options.version, + 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) + + 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()]) + self.log.info('extracting messages from %s%s', filepath, + optstr) + + extracted = extract_from_dir(dirname, method_map, options_map, + keywords, options.comment_tags, + callback=callback, + strip_comment_tags= + options.strip_comment_tags) + for filename, lineno, message, comments, context in extracted: + filepath = os.path.normpath(os.path.join(dirname, filename)) + catalog.add(message, None, [(filepath, lineno)], + auto_comments=comments, context=context) - extracted = extract_from_dir(dirname, method_map, options_map, - keywords, options.comment_tags, - callback=callback, - strip_comment_tags= - options.strip_comment_tags) - for filename, lineno, message, comments, context in extracted: - filepath = os.path.normpath(os.path.join(dirname, filename)) - catalog.add(message, None, [(filepath, lineno)], - auto_comments=comments, context=context) + if options.output not in (None, '-'): + self.log.info('writing PO template file to %s' % options.output) + outfile = open(options.output, 'w') + close_output = True + else: + outfile = sys.stdout + close_output = False - if options.output not in (None, '-'): - self.log.info('writing PO template file to %s' % options.output) + try: 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: + if close_output: outfile.close() def init(self, argv): diff --git a/babel/messages/tests/test_frontend.py b/babel/messages/tests/test_frontend.py index 2d934e7..0d76b66 100644 --- a/babel/messages/tests/test_frontend.py +++ b/babel/messages/tests/test_frontend.py @@ -115,7 +115,8 @@ class ExtractMessagesTestCase(unittest.TestCase): self.cmd.finalize_options() self.cmd.run() - catalog = read_po(open(self._pot_file(), 'U')) + with open(self._pot_file(), 'U') as f: + catalog = read_po(f) msg = catalog.get('bar') self.assertEqual(1, len(msg.locations)) self.assertTrue('file1.py' in msg.locations[0][0]) @@ -138,8 +139,7 @@ class ExtractMessagesTestCase(unittest.TestCase): self.assert_pot_file_exists() - self.assertEqual( -r"""# Translations template for TestProject. + expected_content = r"""# Translations template for TestProject. # Copyright (C) %(year)s FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -180,8 +180,10 @@ msgstr[1] "" """ % {'version': VERSION, 'year': time.strftime('%Y'), 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(self._pot_file(), 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(self._pot_file(), 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_extraction_with_mapping_file(self): self.cmd.copyright_holder = 'FooBar, Inc.' @@ -195,8 +197,7 @@ msgstr[1] "" self.assert_pot_file_exists() - self.assertEqual( -r"""# Translations template for TestProject. + expected_content = r"""# Translations template for TestProject. # Copyright (C) %(year)s FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -231,8 +232,10 @@ msgstr[1] "" """ % {'version': VERSION, 'year': time.strftime('%Y'), 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(self._pot_file(), 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(self._pot_file(), 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_extraction_with_mapping_dict(self): self.dist.message_extractors = { @@ -251,8 +254,7 @@ msgstr[1] "" self.assert_pot_file_exists() - self.assertEqual( -r"""# Translations template for TestProject. + expected_content = r"""# Translations template for TestProject. # Copyright (C) %(year)s FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -287,8 +289,10 @@ msgstr[1] "" """ % {'version': VERSION, 'year': time.strftime('%Y'), 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(self._pot_file(), 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(self._pot_file(), 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) class InitCatalogTestCase(unittest.TestCase): @@ -343,8 +347,7 @@ class InitCatalogTestCase(unittest.TestCase): po_file = self._po_file('en_US') assert os.path.isfile(po_file) - self.assertEqual( -r"""# English (United States) translations for TestProject. + expected_content = r"""# English (United States) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -378,8 +381,10 @@ msgstr[1] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_keeps_catalog_non_fuzzy(self): self.cmd.input_file = 'project/i18n/messages_non_fuzzy.pot' @@ -392,8 +397,7 @@ msgstr[1] "" po_file = self._po_file('en_US') assert os.path.isfile(po_file) - self.assertEqual( -r"""# English (United States) translations for TestProject. + expected_content = r"""# English (United States) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -427,8 +431,10 @@ msgstr[1] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_correct_init_more_than_2_plurals(self): self.cmd.input_file = 'project/i18n/messages.pot' @@ -441,8 +447,7 @@ msgstr[1] "" po_file = self._po_file('lv_LV') assert os.path.isfile(po_file) - self.assertEqual( -r"""# Latvian (Latvia) translations for TestProject. + expected_content = r"""# Latvian (Latvia) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -478,8 +483,10 @@ msgstr[2] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_correct_init_singular_plural_forms(self): self.cmd.input_file = 'project/i18n/messages.pot' @@ -492,8 +499,7 @@ msgstr[2] "" po_file = self._po_file('ja_JP') assert os.path.isfile(po_file) - self.assertEqual( -r"""# Japanese (Japan) translations for TestProject. + expected_content = r"""# Japanese (Japan) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -526,8 +532,10 @@ msgstr[0] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='ja_JP')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='ja_JP')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_supports_no_wrap(self): self.cmd.input_file = 'project/i18n/long_messages.pot' @@ -536,9 +544,11 @@ msgstr[0] "" long_message = '"'+ 'xxxxx '*15 + '"' - pot_contents = open('project/i18n/messages.pot', 'U').read() + with open('project/i18n/messages.pot', 'U') as f: + pot_contents = f.read() pot_with_very_long_line = pot_contents.replace('"bar"', long_message) - open(self.cmd.input_file, 'wb').write(pot_with_very_long_line) + with open(self.cmd.input_file, 'wb') as f: + f.write(pot_with_very_long_line) self.cmd.no_wrap = True self.cmd.finalize_options() @@ -546,7 +556,7 @@ msgstr[0] "" po_file = self._po_file('en_US') assert os.path.isfile(po_file) - self.assertEqual(r"""# English (United States) translations for TestProject. + expected_content = r"""# English (United States) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -581,8 +591,10 @@ msgstr[1] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US'), - 'long_message': long_message}, - open(po_file, 'U').read()) + 'long_message': long_message} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_supports_width(self): self.cmd.input_file = 'project/i18n/long_messages.pot' @@ -591,17 +603,18 @@ msgstr[1] "" long_message = '"'+ 'xxxxx '*15 + '"' - pot_contents = open('project/i18n/messages.pot', 'U').read() + with open('project/i18n/messages.pot', 'U') as f: + pot_contents = f.read() pot_with_very_long_line = pot_contents.replace('"bar"', long_message) - open(self.cmd.input_file, 'wb').write(pot_with_very_long_line) + with open(self.cmd.input_file, 'wb') as f: + f.write(pot_with_very_long_line) self.cmd.width = 120 self.cmd.finalize_options() self.cmd.run() po_file = self._po_file('en_US') assert os.path.isfile(po_file) - self.assertEqual( -r"""# English (United States) translations for TestProject. + expected_content = r"""# English (United States) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -636,8 +649,10 @@ msgstr[1] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', tzinfo=LOCALTZ, locale='en_US'), - 'long_message': long_message}, - open(po_file, 'U').read()) + 'long_message': long_message} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) class CommandLineInterfaceTestCase(unittest.TestCase): @@ -753,8 +768,7 @@ commands: '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', '-o', pot_file, 'project']) self.assert_pot_file_exists() - self.assertEqual( -r"""# Translations template for TestProject. + expected_content = r"""# Translations template for TestProject. # Copyright (C) %(year)s FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -795,8 +809,10 @@ 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()) + tzinfo=LOCALTZ, locale='en')} + with open(pot_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_extract_with_mapping_file(self): pot_file = self._pot_file() @@ -808,8 +824,7 @@ msgstr[1] "" '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', '-o', pot_file, 'project']) self.assert_pot_file_exists() - self.assertEqual( -r"""# Translations template for TestProject. + expected_content = r"""# Translations template for TestProject. # Copyright (C) %(year)s FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -844,8 +859,10 @@ 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()) + tzinfo=LOCALTZ, locale='en')} + with open(pot_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_init_with_output_dir(self): po_file = self._po_file('en_US') @@ -854,8 +871,7 @@ msgstr[1] "" '-d', os.path.join(self._i18n_dir()), '-i', os.path.join(self._i18n_dir(), 'messages.pot')]) assert os.path.isfile(po_file) - self.assertEqual( -r"""# English (United States) translations for TestProject. + expected_content = r"""# English (United States) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -890,8 +906,10 @@ msgstr[1] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def _i18n_dir(self): return os.path.join(self.datadir, 'project', 'i18n') @@ -903,8 +921,7 @@ msgstr[1] "" '-d', os.path.join(self._i18n_dir()), '-i', os.path.join(self._i18n_dir(), 'messages.pot')]) assert os.path.isfile(po_file) - self.assertEqual( -r"""# Japanese (Japan) translations for TestProject. + expected_content = r"""# Japanese (Japan) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -938,8 +955,10 @@ msgstr[0] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_init_more_than_2_plural_forms(self): po_file = self._po_file('lv_LV') @@ -948,8 +967,7 @@ msgstr[0] "" '-d', self._i18n_dir(), '-i', os.path.join(self._i18n_dir(), 'messages.pot')]) assert os.path.isfile(po_file) - self.assertEqual( -r"""# Latvian (Latvia) translations for TestProject. + expected_content = r"""# Latvian (Latvia) translations for TestProject. # Copyright (C) 2007 FooBar, Inc. # This file is distributed under the same license as the TestProject # project. @@ -986,8 +1004,10 @@ msgstr[2] "" """ % {'version': VERSION, 'date': format_datetime(datetime.now(LOCALTZ), 'yyyy-MM-dd HH:mmZ', - tzinfo=LOCALTZ, locale='en')}, - open(po_file, 'U').read()) + tzinfo=LOCALTZ, locale='en')} + with open(po_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) def test_compile_catalog(self): po_file = self._po_file('de_DE') diff --git a/babel/numbers.py b/babel/numbers.py index c8980c7..cecba34 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -36,24 +36,32 @@ __all__ = ['format_number', 'format_decimal', 'format_currency', LC_NUMERIC = default_locale('LC_NUMERIC') -def get_currency_name(currency, locale=LC_NUMERIC): +def get_currency_name(currency, count=None, locale=LC_NUMERIC): """Return the name used by the locale for the specified currency. - >>> get_currency_name('USD', 'en_US') + >>> get_currency_name('USD', locale='en_US') u'US Dollar' :param currency: the currency code + :param count: the optional count. If provided the currency name + will be pluralized to that number if possible. :param locale: the `Locale` object or locale identifier :return: the currency symbol :rtype: `unicode` :since: version 0.9.4 """ - return Locale.parse(locale).currencies.get(currency, currency) + loc = Locale.parse(locale) + if count is not None: + plural_form = loc.plural_form(count) + plural_names = loc._data['currency_names_plural'] + if currency in plural_names: + return plural_names[currency][plural_form] + return loc.currencies.get(currency, currency) 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') + >>> get_currency_symbol('USD', locale='en_US') u'$' :param currency: the currency code @@ -182,10 +190,15 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC): >>> format_currency(1099.98, 'EUR', locale='de_DE') u'1.099,98\\xa0\\u20ac' - The pattern can also be specified explicitly: + The pattern can also be specified explicitly. The currency is + placed with the '¤' sign. As the sign gets repeated the format + expands (¤ being the symbol, ¤¤ is the currency abbreviation and + ¤¤¤ is the full name of the currency): >>> format_currency(1099.98, 'EUR', u'\xa4\xa4 #,##0.00', locale='en_US') u'EUR 1,099.98' + >>> format_currency(1099.98, 'EUR', u'#,##0.00 \xa4\xa4\xa4', locale='en_US') + u'1,099.98 euros' :param number: the number to format :param currency: the currency code @@ -412,13 +425,19 @@ def parse_pattern(pattern): if isinstance(pattern, NumberPattern): return pattern + def _match_number(pattern): + rv = number_re.search(pattern) + if rv is None: + raise ValueError('Invalid number pattern %r' % pattern) + return rv.groups() + # 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() + pos_prefix, number, pos_suffix = _match_number(pattern) + neg_prefix, _, neg_suffix = _match_number(neg_pattern) else: - pos_prefix, number, pos_suffix = number_re.search(pattern).groups() + pos_prefix, number, pos_suffix = _match_number(pattern) neg_prefix = '-' + pos_prefix neg_suffix = pos_suffix if 'E' in number: @@ -568,6 +587,8 @@ class NumberPattern(object): retval = u'%s%s%s' % (self.prefix[is_negative], number, self.suffix[is_negative]) if u'¤' in retval: + retval = retval.replace(u'¤¤¤', + get_currency_name(currency, value, locale)) retval = retval.replace(u'¤¤', currency.upper()) retval = retval.replace(u'¤', get_currency_symbol(currency, locale)) return retval diff --git a/babel/support.py b/babel/support.py index 80f015c..fb6d3ad 100644 --- a/babel/support.py +++ b/babel/support.py @@ -88,17 +88,20 @@ class Format(object): """ return format_time(time, format, tzinfo=self.tzinfo, locale=self.locale) - def timedelta(self, delta, granularity='second', threshold=.85): + def timedelta(self, delta, granularity='second', threshold=.85, + format='medium', add_direction=False): """Return a time delta according to the rules of the given locale. >>> fmt = Format('en_US') >>> fmt.timedelta(timedelta(weeks=11)) - u'3 mths' + u'3 months' :see: `babel.dates.format_timedelta` """ return format_timedelta(delta, granularity=granularity, - threshold=threshold, locale=self.locale) + threshold=threshold, + format=format, add_direction=add_direction, + locale=self.locale) def number(self, number): """Return an integer number formatted for the locale. @@ -555,7 +558,8 @@ class Translations(NullTranslations, gettext.GNUTranslations): filename = gettext.find(domain, dirname, locales) if not filename: return NullTranslations() - return cls(fp=open(filename, 'rb'), domain=domain) + with open(filename, 'rb') as fp: + return cls(fp=fp, domain=domain) def __repr__(self): return '<%s: "%s">' % (type(self).__name__, diff --git a/babel/tests/test_dates.py b/babel/tests/test_dates.py index ece32aa..75e7e11 100644 --- a/babel/tests/test_dates.py +++ b/babel/tests/test_dates.py @@ -281,16 +281,37 @@ class FormatTimedeltaTestCase(unittest.TestCase): def test_zero_seconds(self): string = dates.format_timedelta(timedelta(seconds=0), locale='en') + self.assertEqual('0 seconds', string) + string = dates.format_timedelta(timedelta(seconds=0), locale='en', + format='short') self.assertEqual('0 secs', string) string = dates.format_timedelta(timedelta(seconds=0), granularity='hour', locale='en') + self.assertEqual('0 hours', string) + string = dates.format_timedelta(timedelta(seconds=0), + granularity='hour', locale='en', + format='short') self.assertEqual('0 hrs', string) def test_small_value_with_granularity(self): string = dates.format_timedelta(timedelta(seconds=42), granularity='hour', locale='en') + self.assertEqual('1 hour', string) + string = dates.format_timedelta(timedelta(seconds=42), + granularity='hour', locale='en', + format='short') self.assertEqual('1 hr', string) + def test_direction_adding(self): + string = dates.format_timedelta(timedelta(hours=1), + locale='en', + add_direction=True) + self.assertEqual('In 1 hour', string) + string = dates.format_timedelta(timedelta(hours=-1), + locale='en', + add_direction=True) + self.assertEqual('1 hour ago', string) + class TimeZoneAdjustTestCase(unittest.TestCase): def _utc(self): diff --git a/babel/tests/test_support.py b/babel/tests/test_support.py index 00b8f18..f2e4796 100644 --- a/babel/tests/test_support.py +++ b/babel/tests/test_support.py @@ -173,7 +173,8 @@ class TranslationsTestCase(unittest.TestCase): os.makedirs(messages_dir) catalog = Catalog(locale='fr', domain='messages') catalog.add('foo', 'bar') - write_mo(file(os.path.join(messages_dir, 'messages.mo'), 'wb'), catalog) + with open(os.path.join(messages_dir, 'messages.mo'), 'wb') as f: + write_mo(f, catalog) translations = support.Translations.load(tempdir, locales=('fr',), domain='messages') self.assertEqual('bar', translations.gettext('foo')) diff --git a/babel/util.py b/babel/util.py index 8eb7f69..fe202d7 100644 --- a/babel/util.py +++ b/babel/util.py @@ -294,14 +294,9 @@ class FixedOffsetTimezone(tzinfo): return ZERO -try: - from pytz import UTC -except ImportError: - UTC = FixedOffsetTimezone(0, 'UTC') - """`tzinfo` object for UTC (Universal Time). +import pytz as _pytz - :type: `tzinfo` - """ +UTC = _pytz.utc STDOFFSET = timedelta(seconds = -time.timezone) if time.daylight: diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 7cdd114..60aeace 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -23,7 +23,6 @@ from xml.etree import ElementTree sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..')) from babel import dates, numbers -from babel.compat import any from babel.plural import PluralRule from babel.localedata import Alias @@ -504,15 +503,18 @@ def main(): percent_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) currency_names = data.setdefault('currency_names', {}) + currency_names_plural = data.setdefault('currency_names_plural', {}) currency_symbols = data.setdefault('currency_symbols', {}) for elem in tree.findall('.//currencies/currency'): code = elem.attrib['type'] - # TODO: support plural rules for currency name selection for name in elem.findall('displayName'): - if ('draft' in name.attrib or 'count' in name.attrib) \ - and code in currency_names: + if ('draft' in name.attrib) and code in currency_names: continue - currency_names[code] = unicode(name.text) + if 'count' in name.attrib: + currency_names_plural.setdefault(code, {})[name.attrib['count']] = \ + unicode(name.text) + else: + currency_names[code] = unicode(name.text) # TODO: support choice patterns for currency symbol selection symbol = elem.find('symbol') if symbol is not None and 'draft' not in symbol.attrib \ @@ -524,9 +526,11 @@ def main(): unit_patterns = data.setdefault('unit_patterns', {}) for elem in tree.findall('.//units/unit'): unit_type = elem.attrib['type'] - unit_pattern = unit_patterns.setdefault(unit_type, {}) for pattern in elem.findall('unitPattern'): - unit_patterns[unit_type][pattern.attrib['count']] = \ + box = unit_type + if 'alt' in pattern.attrib: + box += ':' + pattern.attrib['alt'] + unit_patterns.setdefault(box, {})[pattern.attrib['count']] = \ unicode(pattern.text) outfile = open(data_filename, 'wb') @@ -13,13 +13,8 @@ # history and logs, available at http://babel.edgewall.org/log/. import os -try: - from setuptools import setup - have_setuptools = True -except ImportError: - from distutils.core import setup - have_setuptools = False import sys +from setuptools import setup sys.path.append(os.path.join('doc', 'common')) try: @@ -28,37 +23,6 @@ except ImportError: build_doc = test_doc = None -extra_arguments = dict() -if have_setuptools: - extra_arguments = dict( - zip_safe = False, - test_suite = 'babel.tests.suite', - tests_require = ['pytz'], - - entry_points = """ - [console_scripts] - pybabel = babel.messages.frontend:main - - [distutils.commands] - compile_catalog = babel.messages.frontend:compile_catalog - extract_messages = babel.messages.frontend:extract_messages - init_catalog = babel.messages.frontend:init_catalog - update_catalog = babel.messages.frontend:update_catalog - - [distutils.setup_keywords] - message_extractors = babel.messages.frontend:check_message_extractors - - [babel.checkers] - num_plurals = babel.messages.checkers:num_plurals - python_format = babel.messages.checkers:python_format - - [babel.extractors] - ignore = babel.messages.extract:extract_nothing - python = babel.messages.extract:extract_python - javascript = babel.messages.extract:extract_javascript - """, - ) - setup( name = 'Babel', version = '1.0', @@ -82,8 +46,35 @@ setup( ], packages = ['babel', 'babel.messages'], package_data = {'babel': ['global.dat', 'localedata/*.dat']}, + install_requires=[ + 'pytz', + ], cmdclass = {'build_doc': build_doc, 'test_doc': test_doc}, - **extra_arguments + zip_safe = False, + test_suite = 'babel.tests.suite', + + entry_points = """ + [console_scripts] + pybabel = babel.messages.frontend:main + + [distutils.commands] + compile_catalog = babel.messages.frontend:compile_catalog + extract_messages = babel.messages.frontend:extract_messages + init_catalog = babel.messages.frontend:init_catalog + update_catalog = babel.messages.frontend:update_catalog + + [distutils.setup_keywords] + message_extractors = babel.messages.frontend:check_message_extractors + + [babel.checkers] + num_plurals = babel.messages.checkers:num_plurals + python_format = babel.messages.checkers:python_format + + [babel.extractors] + ignore = babel.messages.extract:extract_nothing + python = babel.messages.extract:extract_python + javascript = babel.messages.extract:extract_javascript + """ ) |