From f1e1cb213860aee44f2e20f93c1bb6a31ed01892 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 26 Jul 2013 18:15:02 +0200 Subject: [PATCH 001/489] Ready for 2.0-dev --- babel/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/__init__.py b/babel/__init__.py index 617726bc2..addf71aec 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '1.0' +__version__ = '2.0-dev' diff --git a/setup.py b/setup.py index 680c5fc12..22a0f5389 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='Babel', - version='1.0', + version='2.0-dev', description='Internationalization utilities', long_description=\ """A collection of tools for internationalizing Python applications.""", From 1bd7c37053991be20116d665456a449c67dbc57d Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 26 Jul 2013 18:15:20 +0200 Subject: [PATCH 002/489] Bump major numbers in release script --- scripts/make-release.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/make-release.py b/scripts/make-release.py index 137460795..5afd0a327 100755 --- a/scripts/make-release.py +++ b/scripts/make-release.py @@ -49,7 +49,7 @@ def bump_version(version): parts = map(int, version.split('.')) except ValueError: fail('Current version is not numeric') - parts[-1] += 1 + parts[0] += 1 return '.'.join(map(str, parts)) From 559da009e6c0677f5ece7927693acbd9b1802669 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 26 Jul 2013 19:45:16 +0200 Subject: [PATCH 003/489] Edgewall -> Babel Team --- docs/conf.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4a0f8b689..b1467c74b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,7 +43,7 @@ # General information about the project. project = u'Babel' -copyright = u'2013, Edgewall Software' +copyright = u'2013, The Babel Team' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -194,7 +194,7 @@ # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'Babel.tex', u'Babel Documentation', - u'Edgewall Software', 'manual'), + u'The Babel Team', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of @@ -224,7 +224,7 @@ # (source start file, name, description, authors, manual section). man_pages = [ ('index_', 'babel', u'Babel Documentation', - [u'Edgewall Software'], 1) + [u'The Babel Team'], 1) ] # If true, show URL addresses after external links. @@ -238,7 +238,7 @@ # dir menu entry, description, category) texinfo_documents = [ ('index_', 'Babel', u'Babel Documentation', - u'Edgewall Software', 'Babel', 'One line description of project.', + u'The Babel Team', 'Babel', 'One line description of project.', 'Miscellaneous'), ] From 5d494ffa0430b0d9d7f0afeee291f876bcb06aba Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 26 Jul 2013 19:46:26 +0200 Subject: [PATCH 004/489] Added links to sidebar --- docs/_templates/sidebar-links.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/_templates/sidebar-links.html b/docs/_templates/sidebar-links.html index 666ddc611..856aa1bab 100644 --- a/docs/_templates/sidebar-links.html +++ b/docs/_templates/sidebar-links.html @@ -1,3 +1,11 @@ +

Other Formats

+

+ You can download the documentation in other formats as well: +

+

Useful Links

  • Babel Website
  • From 6947cf97d2bf4bb45b31c79b8d3e015a109c0bf2 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sat, 27 Jul 2013 11:56:34 +0200 Subject: [PATCH 005/489] Added 2.0 to the changelog --- CHANGES | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES b/CHANGES index adfcc65e7..9810079e7 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,12 @@ Babel Changelog =============== +Version 2.0 +----------- + +(release date to be decided, codename to be selected) + + Version 1.2 ----------- From 8304ea137d8b905cd5853f469eadb87b0a9a988e Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 28 Jul 2013 23:35:30 +0200 Subject: [PATCH 006/489] Added another testcase for subtag expansion. --- tests/test_core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 160515941..b4a1ac575 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -102,6 +102,11 @@ def test_parse_likely_subtags(self): assert l.territory == 'CN' assert l.script == 'Hans' + l = Locale.parse('zh_SG') + assert l.language == 'zh' + assert l.territory == 'SG' + assert l.script == 'Hans' + l = Locale.parse('und_AT') assert l.language == 'de' assert l.territory == 'AT' From 963801d5a4c18b29b6b49efb97d100e7c223db76 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 29 Jul 2013 11:26:19 +0200 Subject: [PATCH 007/489] Fixed a bug on Python 3 when writing to stdout --- CHANGES | 2 ++ babel/messages/frontend.py | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 66202f2dd..6fe47511e 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,8 @@ Version 1.3 This primarily makes ``zh_CN`` work again which was broken due to how it was defined in the likely subtags combined with our broken resolving. This fixes #37. +- Fixed a bug that caused pybabel to break when writing to stdout + on Python 3. Version 1.2 ----------- diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 3cec787dc..144bc98a1 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -35,7 +35,7 @@ from babel.messages.mofile import write_mo from babel.messages.pofile import read_po, write_po from babel.util import odict, LOCALTZ -from babel._compat import string_types, BytesIO +from babel._compat import string_types, BytesIO, PY2 class compile_catalog(Command): @@ -826,7 +826,7 @@ def extract(self, argv): help='path to the output POT file') parser.add_option('-w', '--width', dest='width', type='int', help="set output line width (default 76)") - parser.add_option('--no-wrap', dest='no_wrap', action = 'store_true', + 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', @@ -921,16 +921,25 @@ def callback(filename, method, options): catalog.add(message, None, [(filepath, lineno)], auto_comments=comments, context=context) + catalog_charset = catalog.charset if options.output not in (None, '-'): self.log.info('writing PO template file to %s' % options.output) outfile = open(options.output, 'wb') close_output = True else: outfile = sys.stdout + + # This is a bit of a hack on Python 3. stdout is a text stream so + # we need to find the underlying file when we write the PO. In + # later versions of Babel we want the write_po function to accept + # text or binary streams and automatically adjust the encoding. + if not PY2 and hasattr(outfile, 'buffer'): + catalog.charset = outfile.encoding + outfile = outfile.buffer.raw + close_output = False try: - print(outfile) write_po(outfile, catalog, width=options.width, no_location=options.no_location, omit_header=options.omit_header, @@ -939,6 +948,7 @@ def callback(filename, method, options): finally: if close_output: outfile.close() + catalog.charset = catalog_charset def init(self, argv): """Subcommand for creating new message catalogs from a template. From f35626949fa142e77b624db40f2ffee2452d8c56 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 29 Jul 2013 11:34:37 +0200 Subject: [PATCH 008/489] Added a missing changelog entry --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 6fe47511e..4c6fd125d 100644 --- a/CHANGES +++ b/CHANGES @@ -17,6 +17,8 @@ Version 1.3 our broken resolving. This fixes #37. - Fixed a bug that caused pybabel to break when writing to stdout on Python 3. +- Removed a stray print that was causing issues when writing to + stdout for message catalogs. Version 1.2 ----------- From c3a7bf10158748aea1b8ce430e5f3f1cf4f1ce0f Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 29 Jul 2013 13:33:55 +0200 Subject: [PATCH 009/489] Ready for 1.4 --- babel/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/__init__.py b/babel/__init__.py index dd9f17e04..046bdc012 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '1.3' +__version__ = '1.4-dev' diff --git a/setup.py b/setup.py index 4a57551f7..7a566ab5c 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='Babel', - version='1.3', + version='1.4-dev', description='Internationalization utilities', long_description=\ """A collection of tools for internationalizing Python applications.""", From 0d96186cf8b622e3fed0f2f11b940885344aea72 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 29 Jul 2013 19:08:04 +0200 Subject: [PATCH 010/489] Added changelog line for 1.4 --- CHANGES | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGES b/CHANGES index 496f519bb..e8d6983af 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,11 @@ Babel Changelog =============== +Version 1.4 +----------- + +(bugfix release, release date to be decided) + Version 1.3 ----------- From e224a7b134d23a3c89cbb7ad492e684267023934 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 29 Jul 2013 19:09:07 +0200 Subject: [PATCH 011/489] Fixed territory aliases not working properly --- CHANGES | 5 +++++ babel/core.py | 2 +- tests/test_core.py | 5 +++++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index e8d6983af..8b93fd200 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,11 @@ Version 1.4 (bugfix release, release date to be decided) +- Fixed a bug that caused deprecated territory codes not being + converted properly by the subtag resolving. This for instance + showed up when trying to use ``und_UK`` as a language code + which now properly resolves to ``en_GB``. + Version 1.3 ----------- diff --git a/babel/core.py b/babel/core.py index 6e6e6d619..56dbf5593 100644 --- a/babel/core.py +++ b/babel/core.py @@ -282,7 +282,7 @@ def _try_load_reducing(parts): language, territory, script, variant = parts language = get_global('language_aliases').get(language, language) - territory = get_global('territory_aliases').get(territory, territory) + territory = get_global('territory_aliases').get(territory, (territory,))[0] script = get_global('script_aliases').get(script, script) variant = get_global('variant_aliases').get(variant, variant) diff --git a/tests/test_core.py b/tests/test_core.py index b4a1ac575..ec3f9ea9d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -111,6 +111,11 @@ def test_parse_likely_subtags(self): assert l.language == 'de' assert l.territory == 'AT' + l = Locale.parse('und_UK') + assert l.language == 'en' + assert l.territory == 'GB' + assert l.script is None + def test_get_display_name(self): zh_CN = Locale('zh', 'CN', script='Hans') assert zh_CN.get_display_name('en') == 'Chinese (Simplified, China)' From b409813eff5142e1856e5e57c221b72d8d5b978b Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 30 Jul 2013 02:13:45 +0200 Subject: [PATCH 012/489] Added support for territory currency lookups. The main usecase of this is to figure out at what point in time did a country use a certain currency. The default behavior is to use the current date. This fixes #42 --- CHANGES | 4 ++ babel/numbers.py | 88 +++++++++++++++++++++++++++++++++++++++++- docs/api/numbers.rst | 2 + scripts/import_cldr.py | 28 ++++++++++++++ tests/test_numbers.py | 23 +++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 7dbaf25cc..147124e4f 100644 --- a/CHANGES +++ b/CHANGES @@ -6,6 +6,10 @@ Version 2.0 (release date to be decided, codename to be selected) +- Added support for looking up currencies that belong to a territory + through the :func:`babel.numbers.get_territory_currencies` + function. + Version 1.4 ----------- diff --git a/babel/numbers.py b/babel/numbers.py index 0f387190a..344625e19 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -21,8 +21,9 @@ from decimal import Decimal, InvalidOperation import math import re +from datetime import date as date_, datetime as datetime_ -from babel.core import default_locale, Locale +from babel.core import default_locale, Locale, get_global from babel._compat import range_type @@ -63,6 +64,91 @@ def get_currency_symbol(currency, locale=LC_NUMERIC): return Locale.parse(locale).currency_symbols.get(currency, currency) +def get_territory_currencies(territory, start_date=None, end_date=None, + tender=True, non_tender=False, + include_details=False): + """Returns the list of currencies for the given territory that are valid at + the given date range. In addition to that the currency database + distinguishes between tender and non-tender currencies. By default only + tender currencies are returned. + + The return value is a list of all currencies roughly ordered by the time + of when the currency became active. The longer the currency is being in + use the more to the left of the list it will be. + + The start date defaults to today. If no end date is given it will be the + same as the start date. Otherwise a range can be defined. For instance + this can be used to find the currencies in use in Austria between 1995 and + 2011: + + >>> from datetime import date + >>> get_territory_currencies('AT', date(1995, 1, 1), date(2011, 1, 1)) + ['ATS', 'EUR'] + + Likewise it's also possible to find all the currencies in use on a + single date: + + >>> get_territory_currencies('AT', date(1995, 1, 1)) + ['ATS'] + >>> get_territory_currencies('AT', date(2011, 1, 1)) + ['EUR'] + + By default the return value only includes tender currencies. This + however can be changed: + + >>> get_territory_currencies('US') + ['USD'] + >>> get_territory_currencies('US', tender=False, non_tender=True) + ['USN', 'USS'] + + .. versionadded:: 2.0 + + :param territory: the name of the territory to find the currency fo + :param start_date: the start date. If not given today is assumed. + :param end_date: the end date. If not given the start date is assumed. + :param tender: controls whether tender currencies should be included. + :param non_tender: controls whether non-tender currencies should be + included. + :param include_details: if set to `True`, instead of returning currency + codes the return value will be dictionaries + with detail information. In that case each + dictionary will have the keys ``'currency'``, + ``'from'``, ``'to'``, and ``'tender'``. + """ + currencies = get_global('territory_currencies') + if start_date is None: + start_date = date_.today() + elif isinstance(start_date, datetime_): + start_date = start_date.date() + if end_date is None: + end_date = start_date + elif isinstance(end_date, datetime_): + end_date = end_date.date() + + curs = currencies.get(territory.upper(), ()) + # TODO: validate that the territory exists + + def _is_active(start, end): + return (start is None or start <= end_date) and \ + (end is None or end >= start_date) + + result = [] + for currency_code, start, end, is_tender in curs: + if ((is_tender and tender) or \ + (not is_tender and non_tender)) and _is_active(start, end): + if include_details: + result.append({ + 'currency': currency_code, + 'from': start, + 'to': end, + 'tender': is_tender, + }) + else: + result.append(currency_code) + + return result + + def get_decimal_symbol(locale=LC_NUMERIC): """Return the symbol used by the locale to separate decimal fractions. diff --git a/docs/api/numbers.rst b/docs/api/numbers.rst index de3573e71..207ae0bca 100644 --- a/docs/api/numbers.rst +++ b/docs/api/numbers.rst @@ -43,3 +43,5 @@ Data Access .. autofunction:: get_plus_sign_symbol .. autofunction:: get_minus_sign_symbol + +.. autofunction:: get_territory_currencies diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 84b2b1ddf..c189e4f3e 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -21,6 +21,8 @@ except ImportError: from xml.etree import ElementTree +from datetime import date + # Make sure we're using Babel source, and not some previously installed version sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..')) @@ -95,6 +97,18 @@ def _translate_alias(ctxt, path): return keys +def _parse_currency_date(s): + if not s: + return None + parts = s.split('-', 2) + return date(*map(int, parts + [1] * (3 - len(parts)))) + + +def _currency_sort_key(tup): + code, start, end, tender = tup + return int(not tender), start or date(1, 1, 1) + + def main(): parser = OptionParser(usage='%prog path/to/cldr') options, args = parser.parse_args() @@ -128,6 +142,7 @@ def main(): script_aliases = global_data.setdefault('script_aliases', {}) variant_aliases = global_data.setdefault('variant_aliases', {}) likely_subtags = global_data.setdefault('likely_subtags', {}) + territory_currencies = global_data.setdefault('territory_currencies', {}) # create auxiliary zone->territory map from the windows zones (we don't set # the 'zones_territories' map directly here, because there are some zones @@ -186,6 +201,19 @@ def main(): for likely_subtag in sup_likely.findall('.//likelySubtags/likelySubtag'): likely_subtags[likely_subtag.attrib['from']] = likely_subtag.attrib['to'] + # Currencies in territories + for region in sup.findall('.//currencyData/region'): + region_code = region.attrib['iso3166'] + region_currencies = [] + for currency in region.findall('./currency'): + cur_start = _parse_currency_date(currency.attrib.get('from')) + cur_end = _parse_currency_date(currency.attrib.get('to')) + region_currencies.append((currency.attrib['iso4217'], + cur_start, cur_end, + currency.attrib.get('tender', 'true') == 'true')) + region_currencies.sort(key=_currency_sort_key) + territory_currencies[region_code] = region_currencies + outfile = open(global_path, 'wb') try: pickle.dump(global_data, outfile, 2) diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 6db4b6795..99e0d1bda 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -15,6 +15,8 @@ import unittest import pytest +from datetime import date + from babel import numbers @@ -180,6 +182,27 @@ def test_get_currency_symbol(): assert numbers.get_currency_symbol('USD', 'en_US') == u'$' +def test_get_territory_currencies(): + assert numbers.get_territory_currencies('AT', date(1995, 1, 1)) == ['ATS'] + assert numbers.get_territory_currencies('AT', date(2011, 1, 1)) == ['EUR'] + + assert numbers.get_territory_currencies('US', date(2013, 1, 1)) == ['USD'] + assert sorted(numbers.get_territory_currencies('US', date(2013, 1, 1), + non_tender=True)) == ['USD', 'USN', 'USS'] + + assert numbers.get_territory_currencies('US', date(2013, 1, 1), + include_details=True) == [{ + 'currency': 'USD', + 'from': date(1792, 1, 1), + 'to': None, + 'tender': True + }] + + assert numbers.get_territory_currencies('LS', date(2013, 1, 1)) == ['ZAR', 'LSL'] + + assert numbers.get_territory_currencies('QO', date(2013, 1, 1)) == [] + + def test_get_decimal_symbol(): assert numbers.get_decimal_symbol('en_US') == u'.' From 47dd1be41fe812fef67897839487bfd8fb29993c Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 30 Jul 2013 02:17:03 +0200 Subject: [PATCH 013/489] Renamed a chapter in the docs --- docs/api/numbers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api/numbers.rst b/docs/api/numbers.rst index 207ae0bca..1b21425ee 100644 --- a/docs/api/numbers.rst +++ b/docs/api/numbers.rst @@ -1,5 +1,5 @@ -Numbers -======= +Numbers and Currencies +====================== .. module:: babel.numbers From 8b56f05f347b2aa63b6ebec18a323b8a7c667025 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 30 Jul 2013 02:32:35 +0200 Subject: [PATCH 014/489] Fixed a typo --- babel/numbers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/numbers.py b/babel/numbers.py index 344625e19..2f7fe1621 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -67,7 +67,7 @@ def get_currency_symbol(currency, locale=LC_NUMERIC): def get_territory_currencies(territory, start_date=None, end_date=None, tender=True, non_tender=False, include_details=False): - """Returns the list of currencies for the given territory that are valid at + """Returns the list of currencies for the given territory that are valid for the given date range. In addition to that the currency database distinguishes between tender and non-tender currencies. By default only tender currencies are returned. From a42ac1bbae0b9dbc618e8a83b4d1315732f402f5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 31 Jul 2013 22:48:52 +0200 Subject: [PATCH 015/489] Fixed a CLDR import error on windows. When building the CLDR data from scratch the process would break on windows because the timezone mapping is not available yet. This fixes #43. --- CHANGES | 2 ++ babel/localtime/_win32.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 8b93fd200..219f7cfe9 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,8 @@ Version 1.4 converted properly by the subtag resolving. This for instance showed up when trying to use ``und_UK`` as a language code which now properly resolves to ``en_GB``. +- Fixed a bug that made it impossible to import the CLDR data + from scratch on windows systems. Version 1.3 ----------- diff --git a/babel/localtime/_win32.py b/babel/localtime/_win32.py index 1f6ecc7c0..3752dffac 100644 --- a/babel/localtime/_win32.py +++ b/babel/localtime/_win32.py @@ -10,7 +10,14 @@ import pytz -tz_names = get_global('windows_zone_mapping') +# When building the cldr data on windows this module gets imported. +# Because at that point there is no global.dat yet this call will +# fail. We want to catch it down in that case then and just assume +# the mapping was empty. +try: + tz_names = get_global('windows_zone_mapping') +except RuntimeError: + tz_names = {} def valuestodict(key): From ca2f29cba122b3f6d64ea13669eb6210f0e457b5 Mon Sep 17 00:00:00 2001 From: Hyunjun Kim Date: Fri, 16 Aug 2013 20:08:23 +0900 Subject: [PATCH 016/489] Fixed typos on cli errors. --- babel/messages/frontend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 144bc98a1..3d496f3ee 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -89,7 +89,7 @@ def finalize_options(self): raise DistutilsOptionError('you must specify either the input file ' 'or the base directory') if not self.output_file and not self.directory: - raise DistutilsOptionError('you must specify either the input file ' + raise DistutilsOptionError('you must specify either the output file ' 'or the base directory') def run(self): @@ -750,7 +750,7 @@ def compile(self, argv): mo_files.append(options.output_file) else: if not options.directory: - parser.error('you must specify either the input file or ' + parser.error('you must specify either the output file or ' 'the base directory') mo_files.append(os.path.join(options.directory, options.locale, 'LC_MESSAGES', From e5a4738b5ad6b6497383c562dc102a08631d532f Mon Sep 17 00:00:00 2001 From: Hyunjun Kim Date: Fri, 16 Aug 2013 20:40:31 +0900 Subject: [PATCH 017/489] Fixed a typo on description for setuptools command option. --- babel/messages/frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 3d496f3ee..f26e985ed 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -65,7 +65,7 @@ class compile_catalog(Command): 'name of the input file'), ('output-file=', 'o', "name of the output file (default " - "'//LC_MESSAGES/.po')"), + "'//LC_MESSAGES/.mo')"), ('locale=', 'l', 'locale of the catalog to compile'), ('use-fuzzy', 'f', From d764b35bc3910b8f67ff811752df615afa7667f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Neuha=CC=88user?= Date: Thu, 22 Aug 2013 19:40:55 +0200 Subject: [PATCH 018/489] Fix tox configuration Re-using the pickled data across 2.x and 3.x causes errors when running the tests. --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 52d8df9fc..6d4eb0341 100644 --- a/tox.ini +++ b/tox.ini @@ -4,4 +4,5 @@ envlist = py26, py27, pypy, py33 [testenv] deps = pytest -commands = py.test tests +whitelist_externals = make +commands = make clean-cldr test From 6c220383993f0612923387ed171b21449711c75f Mon Sep 17 00:00:00 2001 From: "Alexander A. Dyshev" Date: Fri, 23 Aug 2013 17:06:21 +0300 Subject: [PATCH 019/489] Python3 Without 'b' option, using python3 everybody will get during update: bash-3.2$ pybabel update -i messages.pot -d translations updating catalog 'translations/en/LC_MESSAGES/messages.po' based on 'messages.pot' Traceback (most recent call last): File "/home/user/.pyenv/versions/3.3.2/bin/pybabel", line 9, in load_entry_point('Babel==1.3', 'console_scripts', 'pybabel')() File "/home/user/.pyenv/versions/3.3.2/lib/python3.3/site-packages/babel/messages/frontend.py", line 1151, in main return CommandLineInterface().run(sys.argv) File "/home/user/.pyenv/versions/3.3.2/lib/python3.3/site-packages/babel/messages/frontend.py", line 665, in run return getattr(self, cmdname)(args[1:]) File "/home/user/.pyenv/versions/3.3.2/lib/python3.3/site-packages/babel/messages/frontend.py", line 1130, in update width=options.width) File "/home/user/.pyenv/versions/3.3.2/lib/python3.3/site-packages/babel/messages/pofile.py", line 444, in write_po _write(comment_header + u'\n') File "/home/user/.pyenv/versions/3.3.2/lib/python3.3/site-packages/babel/messages/pofile.py", line 388, in _write fileobj.write(text) TypeError: must be str, not bytes --- babel/messages/frontend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 144bc98a1..86274e0fb 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -1121,7 +1121,7 @@ def update(self, argv): tmpname = os.path.join(os.path.dirname(filename), tempfile.gettempprefix() + os.path.basename(filename)) - tmpfile = open(tmpname, 'w') + tmpfile = open(tmpname, 'wb') try: try: write_po(tmpfile, catalog, From dffa7ec421f14f73b04d35258be6b0babcca8a0e Mon Sep 17 00:00:00 2001 From: Matt Iversen Date: Sat, 19 Oct 2013 22:27:38 +1100 Subject: [PATCH 020/489] Update classifiers to give more detailed information about babel's version compatibility --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index c7b39c3ca..b89a4ec4e 100755 --- a/setup.py +++ b/setup.py @@ -48,7 +48,10 @@ def run(self): 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', 'Topic :: Software Development :: Libraries :: Python Modules', ], packages=['babel', 'babel.messages', 'babel.localtime'], From 3ec7bb104e808d1fda16fbf9fec4b9d7efccaa3f Mon Sep 17 00:00:00 2001 From: Sjoerd Langkemper Date: Wed, 6 Nov 2013 11:32:28 +0100 Subject: [PATCH 021/489] Correctly parse number pattern with '-' on the end For the nl_NL locale, negative numbers would be formatted just like positive numbers by format_currency. By changing NUMBER_TOKEN to no longer have a minus sign in it, the minus sign on the end of the negative pattern for nl_NL is correctly parsed. --- babel/numbers.py | 2 +- tests/test_numbers.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/babel/numbers.py b/babel/numbers.py index 2f7fe1621..587d64070 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -389,7 +389,7 @@ def parse_decimal(string, locale=LC_NUMERIC): PREFIX_END = r'[^0-9@#.,]' -NUMBER_TOKEN = r'[0-9@#.\-,E+]' +NUMBER_TOKEN = r'[0-9@#.,E+]' PREFIX_PATTERN = r"(?P(?:'[^']*'|%s)*)" % PREFIX_END NUMBER_PATTERN = r"(?P%s+)" % NUMBER_TOKEN diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 99e0d1bda..1c4d13fc9 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -213,6 +213,7 @@ def test_get_plus_sign_symbol(): def test_get_minus_sign_symbol(): assert numbers.get_minus_sign_symbol('en_US') == u'-' + assert numbers.get_minus_sign_symbol('nl_NL') == u'-' def test_get_exponential_symbol(): @@ -247,6 +248,8 @@ def test_format_currency(): assert (numbers.format_currency(1099.98, 'EUR', u'\xa4\xa4 #,##0.00', locale='en_US') == u'EUR 1,099.98') + assert (numbers.format_currency(1099.98, 'EUR', locale='nl_NL') + != numbers.format_currency(-1099.98, 'EUR', locale='nl_NL')) def test_format_percent(): @@ -298,3 +301,8 @@ def test_parse_grouping(): assert numbers.parse_grouping('##') == (1000, 1000) assert numbers.parse_grouping('#,###') == (3, 3) assert numbers.parse_grouping('#,####,###') == (3, 4) + + +def test_parse_pattern(): + assert numbers.parse_pattern(u'¤#,##0.00;(¤#,##0.00)').suffix == (u'', u')') + assert numbers.parse_pattern(u'¤ #,##0.00;¤ #,##0.00-').suffix == (u'', u'-') From 7edfab980eb89e06ee97c576b0778283f1c40707 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 20 Nov 2013 17:38:25 +0000 Subject: [PATCH 022/489] Reformatted import script --- scripts/import_cldr.py | 60 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index c189e4f3e..3a2f1217c 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -56,6 +56,7 @@ def _text(elem): 'timeFormats': 'time_formats' } + def log(message, *args): if args: message = message % args @@ -70,7 +71,8 @@ def error(message, *args): def need_conversion(dst_filename, data_dict, source_filename): with open(source_filename, 'rb') as f: blob = f.read(4096) - version = int(re.search(b'version number="\\$Revision: (\\d+)', blob).group(1)) + version = int(re.search(b'version number="\\$Revision: (\\d+)', + blob).group(1)) data_dict['_version'] = version if not os.path.isfile(dst_filename): @@ -149,11 +151,14 @@ def main(): # aliases listed and we defer the decision of which ones to choose to the # 'bcp47' data _zone_territory_map = {} - for map_zone in sup_windows_zones.findall('.//windowsZones/mapTimezones/mapZone'): + for map_zone in sup_windows_zones.findall( + './/windowsZones/mapTimezones/mapZone'): if map_zone.attrib.get('territory') == '001': - win_mapping[map_zone.attrib['other']] = map_zone.attrib['type'].split()[0] + win_mapping[map_zone.attrib['other']] = \ + map_zone.attrib['type'].split()[0] for tzid in text_type(map_zone.attrib['type']).split(): - _zone_territory_map[tzid] = text_type(map_zone.attrib['territory']) + _zone_territory_map[tzid] = \ + text_type(map_zone.attrib['territory']) for key_elem in bcp47_timezone.findall('.//keyword/key'): if key_elem.attrib['name'] == 'tz': @@ -185,7 +190,8 @@ def main(): # Territory aliases for alias in sup_metadata.findall('.//alias/territoryAlias'): - territory_aliases[alias.attrib['type']] = alias.attrib['replacement'].split() + territory_aliases[alias.attrib['type']] = \ + alias.attrib['replacement'].split() # Script aliases for alias in sup_metadata.findall('.//alias/scriptAlias'): @@ -199,7 +205,8 @@ def main(): # Likely subtags for likely_subtag in sup_likely.findall('.//likelySubtags/likelySubtag'): - likely_subtags[likely_subtag.attrib['from']] = likely_subtag.attrib['to'] + likely_subtags[likely_subtag.attrib['from']] = \ + likely_subtag.attrib['to'] # Currencies in territories for region in sup.findall('.//currencyData/region'): @@ -210,7 +217,8 @@ def main(): cur_end = _parse_currency_date(currency.attrib.get('to')) region_currencies.append((currency.attrib['iso4217'], cur_start, cur_end, - currency.attrib.get('tender', 'true') == 'true')) + currency.attrib.get( + 'tender', 'true') == 'true')) region_currencies.sort(key=_currency_sort_key) territory_currencies[region_code] = region_currencies @@ -405,7 +413,8 @@ def main(): if ('draft' in elem.attrib or 'alt' in elem.attrib) \ and int(elem.attrib['type']) in widths: continue - widths[int(elem.attrib.get('type'))] = text_type(elem.text) + widths[int(elem.attrib.get('type'))] = \ + text_type(elem.text) elif elem.tag == 'alias': ctxts[width_type] = Alias( _translate_alias(['months', ctxt_type, width_type], @@ -422,7 +431,8 @@ def main(): for elem in width.getiterator(): if elem.tag == 'day': dtype = weekdays[elem.attrib['type']] - if ('draft' in elem.attrib or 'alt' not in elem.attrib) \ + if ('draft' in elem.attrib or + 'alt' not in elem.attrib) \ and dtype in widths: continue widths[dtype] = text_type(elem.text) @@ -447,9 +457,9 @@ def main(): widths[int(elem.attrib['type'])] = text_type(elem.text) elif elem.tag == 'alias': ctxts[width_type] = Alias( - _translate_alias(['quarters', ctxt_type, width_type], - elem.attrib['path']) - ) + _translate_alias(['quarters', ctxt_type, + width_type], + elem.attrib['path'])) eras = data.setdefault('eras', {}) for width in calendar.findall('eras/*'): @@ -470,7 +480,7 @@ def main(): # AM/PM periods = data.setdefault('periods', {}) for day_period_width in calendar.findall( - 'dayPeriods/dayPeriodContext/dayPeriodWidth'): + 'dayPeriods/dayPeriodContext/dayPeriodWidth'): if day_period_width.attrib['type'] == 'wide': for day_period in day_period_width.findall('dayPeriod'): if 'alt' not in day_period.attrib: @@ -486,7 +496,8 @@ def main(): continue try: date_formats[elem.attrib.get('type')] = \ - dates.parse_pattern(text_type(elem.findtext('dateFormat/pattern'))) + dates.parse_pattern(text_type( + elem.findtext('dateFormat/pattern'))) except ValueError as e: error(e) elif elem.tag == 'alias': @@ -503,7 +514,8 @@ def main(): continue try: time_formats[elem.attrib.get('type')] = \ - dates.parse_pattern(text_type(elem.findtext('timeFormat/pattern'))) + dates.parse_pattern(text_type( + elem.findtext('timeFormat/pattern'))) except ValueError as e: error(e) elif elem.tag == 'alias': @@ -545,7 +557,8 @@ def main(): # TODO map the alias to its target continue pattern = text_type(elem.findtext('./decimalFormat/pattern')) - decimal_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + decimal_formats[elem.attrib.get('type')] = \ + numbers.parse_pattern(pattern) scientific_formats = data.setdefault('scientific_formats', {}) for elem in tree.findall('.//scientificFormats/scientificFormatLength'): @@ -553,7 +566,8 @@ def main(): and elem.attrib.get('type') in scientific_formats: continue pattern = text_type(elem.findtext('scientificFormat/pattern')) - scientific_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + scientific_formats[elem.attrib.get('type')] = \ + numbers.parse_pattern(pattern) currency_formats = data.setdefault('currency_formats', {}) for elem in tree.findall('.//currencyFormats/currencyFormatLength'): @@ -561,7 +575,8 @@ def main(): and elem.attrib.get('type') in currency_formats: continue pattern = text_type(elem.findtext('currencyFormat/pattern')) - currency_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + currency_formats[elem.attrib.get('type')] = \ + numbers.parse_pattern(pattern) percent_formats = data.setdefault('percent_formats', {}) for elem in tree.findall('.//percentFormats/percentFormatLength'): @@ -569,7 +584,8 @@ def main(): and elem.attrib.get('type') in percent_formats: continue pattern = text_type(elem.findtext('percentFormat/pattern')) - percent_formats[elem.attrib.get('type')] = numbers.parse_pattern(pattern) + percent_formats[elem.attrib.get('type')] = \ + numbers.parse_pattern(pattern) currency_names = data.setdefault('currency_names', {}) currency_names_plural = data.setdefault('currency_names_plural', {}) @@ -580,8 +596,8 @@ def main(): if ('draft' in name.attrib) and code in currency_names: continue if 'count' in name.attrib: - currency_names_plural.setdefault(code, {})[name.attrib['count']] = \ - text_type(name.text) + currency_names_plural.setdefault(code, {})[ + name.attrib['count']] = text_type(name.text) else: currency_names[code] = text_type(name.text) # TODO: support choice patterns for currency symbol selection @@ -600,7 +616,7 @@ def main(): if 'alt' in pattern.attrib: box += ':' + pattern.attrib['alt'] unit_patterns.setdefault(box, {})[pattern.attrib['count']] = \ - text_type(pattern.text) + text_type(pattern.text) outfile = open(data_filename, 'wb') try: From f5960f3116c6a615f44133c66cf17ed561d063f8 Mon Sep 17 00:00:00 2001 From: masklinn Date: Tue, 26 Nov 2013 19:45:35 +0100 Subject: [PATCH 023/489] Add links from changelog entries to their ticket via extlink Supports links to the new github (``:gh:``) and the old trac (``:trac:``). The prefixes are used to keep the same reference style as pre-extlinks in the final output (aside from a few entries at the bottom of 1.0, trac references were all in the form ``ticket #42``). --- CHANGES | 158 ++++++++++++++++++++++++++------------------------- docs/conf.py | 8 ++- 2 files changed, 89 insertions(+), 77 deletions(-) diff --git a/CHANGES b/CHANGES index 7ccd8c9e1..7f44c5f9a 100644 --- a/CHANGES +++ b/CHANGES @@ -30,7 +30,7 @@ Version 1.3 - Fixed a bug in likely-subtag resolving for some common locales. This primarily makes ``zh_CN`` work again which was broken due to how it was defined in the likely subtags combined with - our broken resolving. This fixes #37. + our broken resolving. This fixes :gh:`37`. - Fixed a bug that caused pybabel to break when writing to stdout on Python 3. - Removed a stray print that was causing issues when writing to @@ -65,8 +65,8 @@ Version 1.0 - use tox for testing on different pythons - Added support for the locale plural rules defined by the CLDR. - Added `format_timedelta` function to support localized formatting of - relative times with strings such as "2 days" or "1 month" (ticket #126). -- Fixed negative offset handling of Catalog._set_mime_headers (ticket #165). + relative times with strings such as "2 days" or "1 month" (:trac:`126`). +- Fixed negative offset handling of Catalog._set_mime_headers (:trac:`165`). - Fixed the case where messages containing square brackets would break with an unpack error. - updated to CLDR 23 @@ -74,52 +74,56 @@ Version 1.0 - Fix various typos. - Sort output of list-locales. - Make the POT-Creation-Date of the catalog being updated equal to - POT-Creation-Date of the template used to update (ticket #148). + POT-Creation-Date of the template used to update (:trac:`148`). - Use a more explicit error message if no option or argument (command) is - passed to pybabel (ticket #81). -- Keep the PO-Revision-Date if it is not the default value (ticket #148). + passed to pybabel (:trac:`81`). +- Keep the PO-Revision-Date if it is not the default value (:trac:`148`). - Make --no-wrap work by reworking --width's default and mimic xgettext's - behaviour of always wrapping comments (ticket #145). -- Add --project and --version options for commandline (ticket #173). + behaviour of always wrapping comments (:trac:`145`). +- Add --project and --version options for commandline (:trac:`173`). - Add a __ne__() method to the Local class. - Explicitly sort instead of using sorted() and don't assume ordering (Jython compatibility). - Removed ValueError raising for string formatting message checkers if the - string does not contain any string formattings (ticket #150). -- Fix Serbian plural forms (ticket #213). -- Small speed improvement in format_date() (ticket #216). + string does not contain any string formattings (:trac:`150`). +- Fix Serbian plural forms (:trac:`213`). +- Small speed improvement in format_date() (:trac:`216`). - Fix so frontend.CommandLineInterface.run does not accumulate logging - handlers (#227, reported with initial patch by dfraser) -- Fix exception if environment contains an invalid locale setting (#200) -- use cPickle instead of pickle for better performance (#225) + handlers (:trac:`227`, reported with initial patch by dfraser) +- Fix exception if environment contains an invalid locale setting + (:trac:`200`) +- use cPickle instead of pickle for better performance (:trac:`225`) - Only use bankers round algorithm as a tie breaker if there are two nearest - numbers, round as usual if there is only one nearest number (#267, patch by - Martin) -- Allow disabling cache behaviour in LazyProxy (#208, initial patch from Pedro - Algarvio) -- Support for context-aware methods during message extraction (#229, patch - from David Rios) -- "init" and "update" commands support "--no-wrap" option (#289) + numbers, round as usual if there is only one nearest number (:trac:`267`, + patch by Martin) +- Allow disabling cache behaviour in LazyProxy (:trac:`208`, initial patch + from Pedro Algarvio) +- Support for context-aware methods during message extraction (:trac:`229`, + patch from David Rios) +- "init" and "update" commands support "--no-wrap" option (:trac:`289`) - fix formatting of fraction in format_decimal() if the input value is a float - with more than 7 significant digits (#183) -- fix format_date() with datetime parameter (#282, patch from Xavier Morel) -- fix format_decimal() with small Decimal values (#214, patch from George Lund) -- fix handling of messages containing '\\n' (#198) -- handle irregular multi-line msgstr (no "" as first line) gracefully (#171) -- parse_decimal() now returns Decimals not floats, API change (#178) -- no warnings when running setup.py without installed setuptools (#262) + with more than 7 significant digits (:trac:`183`) +- fix format_date() with datetime parameter (:trac:`282`, patch from Xavier + Morel) +- fix format_decimal() with small Decimal values (:trac:`214`, patch from + George Lund) +- fix handling of messages containing '\\n' (:trac:`198`) +- handle irregular multi-line msgstr (no "" as first line) gracefully + (:trac:`171`) +- parse_decimal() now returns Decimals not floats, API change (:trac:`178`) +- no warnings when running setup.py without installed setuptools (:trac:`262`) - modified Locale.__eq__ method so Locales are only equal if all of their attributes (language, territory, script, variant) are equal -- resort to hard-coded message extractors/checkers if pkg_resources is - installed but no egg-info was found (#230) -- format_time() and format_datetime() now accept also floats (#242) +- resort to hard-coded message extractors/checkers if pkg_resources is + installed but no egg-info was found (:trac:`230`) +- format_time() and format_datetime() now accept also floats (:trac:`242`) - add babel.support.NullTranslations class similar to gettext.NullTranslations - but with all of Babel's new gettext methods (#277) -- "init" and "update" commands support "--width" option (#284) -- fix 'input_dirs' option for setuptools integration (#232, initial patch by - Étienne Bersac) -- ensure .mo file header contains the same information as the source .po file - (#199) + but with all of Babel's new gettext methods (:trac:`277`) +- "init" and "update" commands support "--width" option (:trac:`284`) +- fix 'input_dirs' option for setuptools integration (:trac:`232`, initial + patch by Étienne Bersac) +- ensure .mo file header contains the same information as the source .po file + (:trac:`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. @@ -143,31 +147,32 @@ Version 0.9.6 - Backport r493-494: documentation typo fixes. - Make the CLDR import script work with Python 2.7. - Fix various typos. -- Fixed Python 2.3 compatibility (ticket #146, #233). +- Fixed Python 2.3 compatibility (:trac:`146`, :trac:`233`). - Sort output of list-locales. - Make the POT-Creation-Date of the catalog being updated equal to - POT-Creation-Date of the template used to update (ticket #148). + POT-Creation-Date of the template used to update (:trac:`148`). - Use a more explicit error message if no option or argument (command) is - passed to pybabel (ticket #81). -- Keep the PO-Revision-Date if it is not the default value (ticket #148). + passed to pybabel (:trac:`81`). +- Keep the PO-Revision-Date if it is not the default value (:trac:`148`). - Make --no-wrap work by reworking --width's default and mimic xgettext's - behaviour of always wrapping comments (ticket #145). -- Fixed negative offset handling of Catalog._set_mime_headers (ticket #165). -- Add --project and --version options for commandline (ticket #173). + behaviour of always wrapping comments (:trac:`145`). +- Fixed negative offset handling of Catalog._set_mime_headers (:trac:`165`). +- Add --project and --version options for commandline (:trac:`173`). - Add a __ne__() method to the Local class. - Explicitly sort instead of using sorted() and don't assume ordering (Python 2.3 and Jython compatibility). - Removed ValueError raising for string formatting message checkers if the - string does not contain any string formattings (ticket #150). -- Fix Serbian plural forms (ticket #213). -- Small speed improvement in format_date() (ticket #216). + string does not contain any string formattings (:trac:`150`). +- Fix Serbian plural forms (:trac:`213`). +- Small speed improvement in format_date() (:trac:`216`). - Fix number formatting for locales where CLDR specifies alt or draft - items (ticket #217) -- Fix bad check in format_time (ticket #257, reported with patch and tests by + items (:trac:`217`) +- Fix bad check in format_time (:trac:`257`, reported with patch and tests by jomae) - Fix so frontend.CommandLineInterface.run does not accumulate logging - handlers (#227, reported with initial patch by dfraser) -- Fix exception if environment contains an invalid locale setting (#200) + handlers (:trac:`227`, reported with initial patch by dfraser) +- Fix exception if environment contains an invalid locale setting + (:trac:`200`) Version 0.9.5 @@ -179,7 +184,7 @@ Version 0.9.5 an unpack error. - Backport of r467: Fuzzy matching regarding plurals should *NOT* be checked against len(message.id) because this is always 2, instead, it's should be - checked against catalog.num_plurals (ticket #212). + checked against catalog.num_plurals (:trac:`212`). Version 0.9.4 @@ -191,17 +196,17 @@ Version 0.9.4 CLDR data are no longer imported, so the symbol code will be used instead. - Fixed quarter support in date formatting. - Fixed a serious memory leak that was introduces by the support for CLDR - aliases in 0.9.3 (ticket #128). + aliases in 0.9.3 (:trac:`128`). - Locale modifiers such as "@euro" are now stripped from locale identifiers - when parsing (ticket #136). + when parsing (:trac:`136`). - The system locales "C" and "POSIX" are now treated as aliases for "en_US_POSIX", for which the CLDR provides the appropriate data. Thanks to Manlio Perillo for the suggestion. -- Fixed JavaScript extraction for regular expression literals (ticket #138) +- Fixed JavaScript extraction for regular expression literals (:trac:`138`) and concatenated strings. - The `Translation` class in `babel.support` can now manage catalogs with different message domains, and exposes the family of `d*gettext` functions - (ticket #137). + (:trac:`137`). Version 0.9.3 @@ -211,11 +216,11 @@ Version 0.9.3 - Fixed invalid message extraction methods causing an UnboundLocalError. - Extraction method specification can now use a dot instead of the colon to - separate module and function name (ticket #105). + separate module and function name (:trac:`105`). - Fixed message catalog compilation for locales with more than two plural - forms (ticket #95). + forms (:trac:`95`). - Fixed compilation of message catalogs for locales with more than two plural - forms where the translations were empty (ticket #97). + forms where the translations were empty (:trac:`97`). - The stripping of the comment tags in comments is optional now and is done for each line in a comment. - Added a JavaScript message extractor. @@ -225,7 +230,7 @@ Version 0.9.3 correct plural forms for a locale as tuple. - Added support for alias definitions in the CLDR data files, meaning that the chance for items missing in certain locales should be greatly reduced - (ticket #68). + (:trac:`68`). Version 0.9.2 @@ -233,15 +238,15 @@ Version 0.9.2 (released on February 4th 2008) -- Fixed catalogs' charset values not being recognized (ticket #66). +- Fixed catalogs' charset values not being recognized (:trac:`66`). - Numerous improvements to the default plural forms. -- Fixed fuzzy matching when updating message catalogs (ticket #82). +- Fixed fuzzy matching when updating message catalogs (:trac:`82`). - Fixed bug in catalog updating, that in some cases pulled in translations from different catalogs based on the same template. - Location lines in PO files do no longer get wrapped at hyphens in file - names (ticket #79). + names (:trac:`79`). - Fixed division by zero error in catalog compilation on empty catalogs - (ticket #60). + (:trac:`60`). Version 0.9.1 @@ -254,7 +259,7 @@ Version 0.9.1 `ngettext`, or vice versa. - Fixed time formatting for 12 am and 12 pm. - Fixed output encoding of the `pybabel --list-locales` command. -- MO files are now written in binary mode on windows (ticket #61). +- MO files are now written in binary mode on windows (:trac:`61`). Version 0.9 @@ -264,23 +269,24 @@ Version 0.9 - The `new_catalog` distutils command has been renamed to `init_catalog` for consistency with the command-line frontend. -- Added compilation of message catalogs to MO files (ticket #21). -- Added updating of message catalogs from POT files (ticket #22). +- Added compilation of message catalogs to MO files (:trac:`21`). +- Added updating of message catalogs from POT files (:trac:`22`). - Support for significant digits in number formatting. - Apply proper "banker's rounding" in number formatting in a cross-platform manner. - The number formatting functions now also work with numbers represented by - Python `Decimal` objects (ticket #53). + Python `Decimal` objects (:trac:`53`). - Added extensible infrastructure for validating translation catalogs. - Fixed the extractor not filtering out messages that didn't validate against - the keyword's specification (ticket #39). + the keyword's specification (:trac:`39`). - Fixed the extractor raising an exception when encountering an empty string msgid. It now emits a warning to stderr. - Numerous Python message extractor fixes: it now handles nested function calls within a gettext function call correctly, uses the correct line number - for multi-line function calls, and other small fixes (tickets #38 and #39). + for multi-line function calls, and other small fixes (tickets :trac:`38` and + :trac:`39`). - Improved support for detecting Python string formatting fields in message - strings (ticket #57). + strings (:trac:`57`). - CLDR upgraded to the 1.5 release. - Improved timezone formatting. - Implemented scientific number formatting. @@ -302,21 +308,21 @@ Version 0.8.1 that way. - The character set specified in PO template files is now respected when creating new catalog files based on that template. This allows the use of - characters outside the ASCII range in POT files (ticket #17). + characters outside the ASCII range in POT files (:trac:`17`). - The default ordering of messages in generated POT files, which is based on the order those messages are found when walking the source tree, is no longer subject to differences between platforms; directory and file names are now always sorted alphabetically. - The Python message extractor now respects the special encoding comment to be - able to handle files containing non-ASCII characters (ticket #23). + able to handle files containing non-ASCII characters (:trac:`23`). - Added ``N_`` (gettext noop) to the extractor's default keywords. - Made locale string parsing more robust, and also take the script part into - account (ticket #27). + account (:trac:`27`). - Added a function to list all locales for which locale data is available. - Added a command-line option to the `pybabel` command which prints out all - available locales (ticket #24). + available locales (:trac:`24`). - The name of the command-line script has been changed from just `babel` to - `pybabel` to avoid a conflict with the OpenBabel project (ticket #34). + `pybabel` to avoid a conflict with the OpenBabel project (:trac:`34`). Version 0.8 diff --git a/docs/conf.py b/docs/conf.py index b1467c74b..c84ebaef4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,7 +27,8 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.intersphinx'] + 'sphinx.ext.intersphinx', + 'sphinx.ext.extlinks'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -254,3 +255,8 @@ intersphinx_mapping = { 'http://docs.python.org/2': None, } + +extlinks = { + 'gh': ('https://github.com/mitsuhiko/babel/issues/%s', '#'), + 'trac': ('http://babel.edgewall.org/ticket/%s', 'ticket #'), +} From 50f727ac2c741877235c592ce4e1f50bba08162c Mon Sep 17 00:00:00 2001 From: James Page Date: Fri, 6 Dec 2013 11:49:26 +0000 Subject: [PATCH 024/489] Fixup get_currency_name test This test failed in environments where no default locale can be determined; make the test deterministic and add additional test for plurality. Signed-off-by: James Page --- tests/test_numbers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 99e0d1bda..7faba6be2 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -175,7 +175,8 @@ def test_can_parse_decimals(self): def test_get_currency_name(): - assert numbers.get_currency_name('USD', 'en_US') == u'US dollars' + assert numbers.get_currency_name('USD', locale='en_US') == u'US Dollar' + assert numbers.get_currency_name('USD', count=2, locale='en_US') == u'US dollars' def test_get_currency_symbol(): From b004036f8b2624235af20ba601607efa8c3db3ca Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 21:19:47 +0200 Subject: [PATCH 025/489] use fallback plural if none is defined in CLDR fixes #69 --- babel/core.py | 4 +++- tests/test_plural.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/babel/core.py b/babel/core.py index 56dbf5593..4ada4661f 100644 --- a/babel/core.py +++ b/babel/core.py @@ -13,12 +13,14 @@ from babel import localedata from babel._compat import pickle, string_types +from babel.plural import PluralRule __all__ = ['UnknownLocaleError', 'Locale', 'default_locale', 'negotiate_locale', 'parse_locale'] _global_data = None +_default_plural_rule = PluralRule({}) def _raise_no_data_error(): @@ -737,7 +739,7 @@ def plural_form(self): >>> Locale('ru').plural_form(100) 'many' """ - return self._data['plural_form'] + return self._data.get('plural_form', _default_plural_rule) def default_locale(category=None, aliases=LOCALE_ALIASES): diff --git a/tests/test_plural.py b/tests/test_plural.py index 5fe67a548..7f31fd98f 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -90,3 +90,11 @@ def test_plural_within_rules(): assert p(7) == 'few' assert p(8) == 'few' assert p(9) == 'few' + + +def test_locales_with_no_plural_rules_have_default(): + from babel import Locale + aa_plural = Locale.parse('aa').plural_form + assert aa_plural(1) == 'other' + assert aa_plural(2) == 'other' + assert aa_plural(15) == 'other' From e92bbba373e6d56383723f71dd8d531c4cdd2806 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 21:41:25 +0200 Subject: [PATCH 026/489] extract _parse_datetime_header function --- babel/messages/catalog.py | 84 ++++++++++++++------------------------- 1 file changed, 30 insertions(+), 54 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 501763b58..82c08c881 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -40,6 +40,34 @@ ''') +def _parse_datetime_header(value): + value, tzoffset, _ = re.split('([+-]\d{4})$', value, 1) + + tt = time.strptime(value, '%Y-%m-%d %H:%M') + ts = time.mktime(tt) + + # Separate the offset into a sign component, hours, and # minutes + plus_minus_s, rest = tzoffset[0], tzoffset[1:] + hours_offset_s, mins_offset_s = rest[:2], rest[2:] + + # Make them all integers + plus_minus = int(plus_minus_s + '1') + hours_offset = int(hours_offset_s) + mins_offset = int(mins_offset_s) + + # Calculate net offset + net_mins_offset = hours_offset * 60 + net_mins_offset += mins_offset + net_mins_offset *= plus_minus + + # Create an offset object + tzoffset = FixedOffsetTimezone(net_mins_offset) + + # Store the offset in a datetime object + dt = datetime.fromtimestamp(ts) + return dt.replace(tzinfo=tzoffset) + + class Message(object): """Representation of a single message in a catalog.""" @@ -379,63 +407,11 @@ def _set_mime_headers(self, headers): self._num_plurals = int(params.get('nplurals', 2)) self._plural_expr = params.get('plural', '(n != 1)') 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) - - # Separate the offset into a sign component, hours, and minutes - plus_minus_s, rest = tzoffset[0], tzoffset[1:] - hours_offset_s, mins_offset_s = rest[:2], rest[2:] - - # Make them all integers - plus_minus = int(plus_minus_s + '1') - hours_offset = int(hours_offset_s) - mins_offset = int(mins_offset_s) - - # Calculate net offset - net_mins_offset = hours_offset * 60 - net_mins_offset += mins_offset - net_mins_offset *= plus_minus - - # Create an offset object - tzoffset = FixedOffsetTimezone(net_mins_offset) - - # Store the offset in a datetime object - dt = datetime.fromtimestamp(ts) - self.creation_date = dt.replace(tzinfo=tzoffset) + self.creation_date = _parse_datetime_header(value) elif name == 'po-revision-date': # Keep the value if it's not the default one if 'YEAR' not in value: - # 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) - - # Separate the offset into a sign component, hours, and - # minutes - plus_minus_s, rest = tzoffset[0], tzoffset[1:] - hours_offset_s, mins_offset_s = rest[:2], rest[2:] - - # Make them all integers - plus_minus = int(plus_minus_s + '1') - hours_offset = int(hours_offset_s) - mins_offset = int(mins_offset_s) - - # Calculate net offset - net_mins_offset = hours_offset * 60 - net_mins_offset += mins_offset - net_mins_offset *= plus_minus - - # Create an offset object - tzoffset = FixedOffsetTimezone(net_mins_offset) - - # Store the offset in a datetime object - dt = datetime.fromtimestamp(ts) - self.revision_date = dt.replace(tzinfo=tzoffset) + self.revision_date = _parse_datetime_header(value) mime_headers = property(_get_mime_headers, _set_mime_headers, doc="""\ The MIME headers of the catalog, used for the special ``msgid ""`` entry. From 1df64dfe5d9e10f6725362d467278c5eb02e5162 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 21:43:33 +0200 Subject: [PATCH 027/489] rewrite regexp parsing --- babel/messages/catalog.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 82c08c881..756d64cce 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -41,12 +41,13 @@ def _parse_datetime_header(value): - value, tzoffset, _ = re.split('([+-]\d{4})$', value, 1) + match = re.match(r'^(?P.*)(?P[+-]\d{4})$', value) - tt = time.strptime(value, '%Y-%m-%d %H:%M') + tt = time.strptime(match.group('datetime'), '%Y-%m-%d %H:%M') ts = time.mktime(tt) # Separate the offset into a sign component, hours, and # minutes + tzoffset = match.group('tzoffset') plus_minus_s, rest = tzoffset[0], tzoffset[1:] hours_offset_s, mins_offset_s = rest[:2], rest[2:] From 88c564228d30a6cda6368cf2077341fea0c6c6d2 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 21:55:27 +0200 Subject: [PATCH 028/489] parse datetime values with no timezone info fixes #56 --- babel/messages/catalog.py | 35 ++++++++++++++++++---------------- tests/messages/test_catalog.py | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 756d64cce..67c542591 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -41,32 +41,35 @@ def _parse_datetime_header(value): - match = re.match(r'^(?P.*)(?P[+-]\d{4})$', value) + match = re.match(r'^(?P.*?)(?P[+-]\d{4})?$', value) tt = time.strptime(match.group('datetime'), '%Y-%m-%d %H:%M') ts = time.mktime(tt) + dt = datetime.fromtimestamp(ts) # Separate the offset into a sign component, hours, and # minutes tzoffset = match.group('tzoffset') - plus_minus_s, rest = tzoffset[0], tzoffset[1:] - hours_offset_s, mins_offset_s = rest[:2], rest[2:] + if tzoffset is not None: + plus_minus_s, rest = tzoffset[0], tzoffset[1:] + hours_offset_s, mins_offset_s = rest[:2], rest[2:] - # Make them all integers - plus_minus = int(plus_minus_s + '1') - hours_offset = int(hours_offset_s) - mins_offset = int(mins_offset_s) + # Make them all integers + plus_minus = int(plus_minus_s + '1') + hours_offset = int(hours_offset_s) + mins_offset = int(mins_offset_s) - # Calculate net offset - net_mins_offset = hours_offset * 60 - net_mins_offset += mins_offset - net_mins_offset *= plus_minus + # Calculate net offset + net_mins_offset = hours_offset * 60 + net_mins_offset += mins_offset + net_mins_offset *= plus_minus - # Create an offset object - tzoffset = FixedOffsetTimezone(net_mins_offset) + # Create an offset object + tzoffset = FixedOffsetTimezone(net_mins_offset) - # Store the offset in a datetime object - dt = datetime.fromtimestamp(ts) - return dt.replace(tzinfo=tzoffset) + # Store the offset in a datetime object + dt = dt.replace(tzinfo=tzoffset) + + return dt class Message(object): diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index fcac34d3d..aac71eeac 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -454,3 +454,17 @@ def test_catalog_update(): assert not 'head' in cat assert list(cat.obsolete.values())[0].id == 'head' + + +def test_datetime_parsing(): + val1 = catalog._parse_datetime_header('2006-06-28 23:24+0200') + assert val1.year == 2006 + assert val1.month == 6 + assert val1.day == 28 + assert val1.tzinfo.zone == 'Etc/GMT+120' + + val2 = catalog._parse_datetime_header('2006-06-28 23:24') + assert val2.year == 2006 + assert val2.month == 6 + assert val2.day == 28 + assert val2.tzinfo is None From 764f68b42a0f2dc0ad82152f84008503e2ac0d3d Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 22:39:22 +0200 Subject: [PATCH 029/489] re-enable doctests --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f2cdc967c..48120c6c5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ test: import-cldr - @py.test tests + @py.test test-env: @virtualenv test-env From 2eb475f835375c1b3b7fefcb7d8aad7047d7fda7 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 22:48:15 +0200 Subject: [PATCH 030/489] specify locale otherwise, running tests in another locale fails fixes #45, thanks @Arfrever! --- babel/dates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 72674e8aa..73d54fa57 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -733,9 +733,9 @@ def format_timedelta(delta, granularity='second', threshold=.85, 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) + >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en') u'In 1 hour' - >>> format_timedelta(timedelta(hours=-1), add_direction=True) + >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en') u'1 hour ago' :param delta: a ``timedelta`` object representing the time difference to From 2af2c176b09b0fd90b91e6678e2afaf1355f838b Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 6 Jan 2014 23:15:45 +0200 Subject: [PATCH 031/489] change spelling of the encoding Apparently jython doesn't understand "utf_8". Fixes #46. --- babel/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/util.py b/babel/util.py index f46ee2b70..a65fce36e 100644 --- a/babel/util.py +++ b/babel/util.py @@ -80,7 +80,7 @@ def parse_encoding(fp): raise SyntaxError( "python refuses to compile code with both a UTF8 " "byte-order-mark and a magic encoding comment") - return 'utf_8' + return 'utf-8' elif m: return m.group(1).decode('latin-1') else: From 7bad77ded77f658c2b8a478e4a35a672d143606e Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Tue, 7 Jan 2014 08:16:16 +0200 Subject: [PATCH 032/489] fix warning for deprecated array.tostring fixes #75 --- Makefile | 2 +- babel/_compat.py | 5 +++++ babel/messages/mofile.py | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 48120c6c5..5bb68a9a9 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ test: import-cldr - @py.test + @PYTHONWARNINGS=default py.test test-env: @virtualenv test-env diff --git a/babel/_compat.py b/babel/_compat.py index 86096daa6..0f7640de1 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -1,4 +1,5 @@ import sys +import array PY2 = sys.version_info[0] == 2 @@ -26,6 +27,8 @@ cmp = lambda a, b: (a > b) - (a < b) + array_tobytes = array.array.tobytes + else: text_type = unicode string_types = (str, unicode) @@ -47,5 +50,7 @@ cmp = cmp + array_tobytes = array.array.tostring + number_types = integer_types + (float,) diff --git a/babel/messages/mofile.py b/babel/messages/mofile.py index 5dd20aee0..18503287b 100644 --- a/babel/messages/mofile.py +++ b/babel/messages/mofile.py @@ -13,7 +13,7 @@ import struct from babel.messages.catalog import Catalog, Message -from babel._compat import range_type +from babel._compat import range_type, array_tobytes LE_MAGIC = 0x950412de @@ -206,4 +206,4 @@ def write_mo(fileobj, catalog, use_fuzzy=False): 7 * 4, # start of key index 7 * 4 + len(messages) * 8, # start of value index 0, 0 # size and offset of hash table - ) + array.array("i", offsets).tostring() + ids + strs) + ) + array_tobytes(array.array("i", offsets)) + ids + strs) From c21a3323d27f17556cd997a0f9279c6d0ec65be3 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Wed, 8 Jan 2014 13:24:40 +0200 Subject: [PATCH 033/489] correctly handle 'C.UTF-8' locale fixes #57 --- babel/core.py | 2 +- tests/test_core.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/babel/core.py b/babel/core.py index 4ada4661f..df3857295 100644 --- a/babel/core.py +++ b/babel/core.py @@ -777,7 +777,7 @@ def default_locale(category=None, aliases=LOCALE_ALIASES): # the LANGUAGE variable may contain a colon-separated list of # language codes; we just pick the language on the list locale = locale.split(':')[0] - if locale in ('C', 'POSIX'): + if locale.split('.')[0] in ('C', 'POSIX'): locale = 'en_US_POSIX' elif aliases and locale in aliases: locale = aliases[locale] diff --git a/tests/test_core.py b/tests/test_core.py index ec3f9ea9d..ac2611dce 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -238,6 +238,10 @@ def test_default_locale(os_environ): os_environ['LC_MESSAGES'] = 'POSIX' assert default_locale('LC_MESSAGES') == 'en_US_POSIX' + for value in ['C', 'C.UTF-8', 'POSIX']: + os_environ['LANGUAGE'] = value + assert default_locale() == 'en_US_POSIX' + def test_negotiate_locale(): assert (core.negotiate_locale(['de_DE', 'en_US'], ['de_DE', 'de_AT']) == From 514d41c0e73c58d43ace3ec310585bc48775196a Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Wed, 15 Jan 2014 22:12:19 +0200 Subject: [PATCH 034/489] read/write all PO files in binary mode as pointed out in #52 --- babel/messages/frontend.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index cead69499..cd79ebf20 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -128,7 +128,7 @@ def run(self): for idx, (locale, po_file) in enumerate(po_files): mo_file = mo_files[idx] - infile = open(po_file, 'r') + infile = open(po_file, 'rb') try: catalog = read_po(infile, locale) finally: @@ -439,7 +439,7 @@ def run(self): log.info('creating catalog %r based on %r', self.output_file, self.input_file) - infile = open(self.input_file, 'r') + infile = open(self.input_file, 'rb') try: # Although reading from the catalog template, read_po must be fed # the locale in order to correctly calculate plurals @@ -554,7 +554,7 @@ def run(self): if not domain: domain = os.path.splitext(os.path.basename(self.input_file))[0] - infile = open(self.input_file, 'U') + infile = open(self.input_file, 'rb') try: template = read_po(infile) finally: @@ -566,7 +566,7 @@ def run(self): for locale, filename in po_files: log.info('updating catalog %r based on %r', filename, self.input_file) - infile = open(filename, 'U') + infile = open(filename, 'rb') try: catalog = read_po(infile, locale=locale, domain=domain) finally: @@ -577,7 +577,7 @@ def run(self): tmpname = os.path.join(os.path.dirname(filename), tempfile.gettempprefix() + os.path.basename(filename)) - tmpfile = open(tmpname, 'w') + tmpfile = open(tmpname, 'wb') try: try: write_po(tmpfile, catalog, @@ -760,7 +760,7 @@ def compile(self, argv): for idx, (locale, po_file) in enumerate(po_files): mo_file = mo_files[idx] - infile = open(po_file, 'r') + infile = open(po_file, 'rb') try: catalog = read_po(infile, locale) finally: From 546755f77b98a4ae4ddc29cb4c44b81cd88a31b1 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 11:03:52 -0500 Subject: [PATCH 035/489] Update CLDR URL for v24, filename and MD5 --- scripts/download_import_cldr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index 9c82fc88a..fe0105339 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -13,9 +13,9 @@ from urllib import urlretrieve -URL = 'http://unicode.org/Public/cldr/23.1/core.zip' -FILENAME = 'core-23.1.zip' -FILESUM = 'd44ff35f9b9160becbb3a575468d8a5a' +URL = 'http://unicode.org/Public/cldr/24/core.zip' +FILENAME = 'core-24.zip' +FILESUM = 'cd2e8f31baf65c96bfc7e5377b3b793f' BLKSIZE = 131072 From 837a5463dd386748dd76bd1fd5d74687861a1e53 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 11:34:21 -0500 Subject: [PATCH 036/489] Some language aliases, which we do not want now use _ instead of - --- scripts/import_cldr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 3a2f1217c..082393ce7 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -184,7 +184,7 @@ def main(): for alias in sup_metadata.findall('.//alias/languageAlias'): # We don't have a use for those at the moment. They don't # pass our parser anyways. - if '-' in alias.attrib['type']: + if '_' in alias.attrib['type']: continue language_aliases[alias.attrib['type']] = alias.attrib['replacement'] From b892e1941848db601fb699692a34736774bf5b63 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 14:49:14 -0500 Subject: [PATCH 037/489] Add link to rules spec in PluralRule doc --- babel/plural.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/babel/plural.py b/babel/plural.py index 144a0dc02..911475750 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -18,7 +18,7 @@ class PluralRule(object): """Represents a set of language pluralization rules. The constructor - accepts a list of (tag, expr) tuples or a dict of CLDR rules. The + accepts a list of (tag, expr) tuples or a dict of `CLDR rules`_. The resulting object is callable and accepts one parameter with a positive or negative number (both integer and float) for the number that indicates the plural form for a string and returns the tag for the format: @@ -33,6 +33,8 @@ class PluralRule(object): other where other is an implicit default. Rules should be mutually exclusive; for a given numeric value, only one rule should apply (i.e. the condition should only be true for one of the plural rule elements. + + .. _`CLDR rules`: http://www.unicode.org/reports/tr35/tr35-33/tr35-numbers.html#Language_Plural_Rules """ __slots__ = ('abstract', '_func') From dd64686bdabddd155d77146e7af25d6a88f2d189 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 17:07:11 -0500 Subject: [PATCH 038/489] Extract plural rule tokenization function and add tests --- babel/plural.py | 71 ++++++++++++++++++++++++++++---------------- tests/test_plural.py | 28 +++++++++++++++++ 2 files changed, 74 insertions(+), 25 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 911475750..e2eb88b05 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -255,11 +255,56 @@ def cldr_modulo(a, b): class RuleError(Exception): """Raised if a rule is malformed.""" +_RULES = [ + (None, re.compile(r'\s+(?u)')), + ('word', re.compile(r'\b(and|or|is|(?:with)?in|not|mod|[nivwft])\b')), + ('value', re.compile(r'\d+')), + ('symbol', re.compile(r'%|,|!=|=')), + ('ellipsis', re.compile(r'\.\.')) +] + + +def tokenize_rule(s): + s = s.split('@')[0] + result = [] + pos = 0 + end = len(s) + while pos < end: + for tok, rule in _RULES: + match = rule.match(s, pos) + if match is not None: + pos = match.end() + if tok: + result.append((tok, match.group())) + break + else: + raise RuleError('malformed CLDR pluralization rule. ' + 'Got unexpected %r' % s[pos]) + return result[::-1] + class _Parser(object): """Internal parser. This class can translate a single rule into an abstract tree of tuples. It implements the following grammar:: + condition = and_condition ('or' and_condition)* + ('@integer' samples)? + ('@decimal' samples)? + and_condition = relation ('and' relation)* + relation = is_relation | in_relation | within_relation + is_relation = expr 'is' ('not')? value + in_relation = expr (('not')? 'in' | '=' | '!=') range_list + within_relation = expr ('not')? 'within' range_list + expr = operand (('mod' | '%') value)? + operand = 'n' | 'i' | 'f' | 't' | 'v' | 'w' + range_list = (range | value) (',' range_list)* + value = digit+ + digit = 0|1|2|3|4|5|6|7|8|9 + range = value'..'value + samples = sampleRange (',' sampleRange)* (',' ('…'|'...'))? + sampleRange = decimalValue '~' decimalValue + decimalValue = value ('.' value)? + condition = and_condition ('or' and_condition)* and_condition = relation ('and' relation)* relation = is_relation | in_relation | within_relation | 'n' @@ -283,32 +328,8 @@ class _Parser(object): called `ast`. """ - _rules = [ - (None, re.compile(r'\s+(?u)')), - ('word', re.compile(r'\b(and|or|is|(?:with)?in|not|mod|n)\b')), - ('value', re.compile(r'\d+')), - ('comma', re.compile(r',')), - ('ellipsis', re.compile(r'\.\.')) - ] - def __init__(self, string): - string = string.lower() - result = [] - pos = 0 - end = len(string) - while pos < end: - for tok, rule in self._rules: - match = rule.match(string, pos) - if match is not None: - pos = match.end() - if tok: - result.append((tok, match.group())) - break - else: - raise RuleError('malformed CLDR pluralization rule. ' - 'Got unexpected %r' % string[pos]) - self.tokens = result[::-1] - + self.tokens = tokenize_rule(string) self.ast = self.condition() if self.tokens: raise RuleError('Expected end of rule, got %r' % diff --git a/tests/test_plural.py b/tests/test_plural.py index 7f31fd98f..ad6da7030 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -13,6 +13,7 @@ import doctest import unittest +import pytest from babel import plural @@ -98,3 +99,30 @@ def test_locales_with_no_plural_rules_have_default(): assert aa_plural(1) == 'other' assert aa_plural(2) == 'other' assert aa_plural(15) == 'other' + + +WELL_FORMED_TOKEN_TESTS = ( + ('', []), + ('n = 1', [('value', '1'), ('symbol', '='), ('word', 'n'), ]), + ('n = 1 @integer 1', [('value', '1'), ('symbol', '='), ('word', 'n'), ]), + ('n is 1', [('value', '1'), ('word', 'is'), ('word', 'n'), ]), + ('n % 100 = 3..10', [('value', '10'), ('ellipsis', '..'), ('value', '3'), + ('symbol', '='), ('value', '100'), ('symbol', '%'), + ('word', 'n'), ]), +) + + +@pytest.mark.parametrize('rule_text,tokens', WELL_FORMED_TOKEN_TESTS) +def test_tokenize_well_formed(rule_text, tokens): + assert plural.tokenize_rule(rule_text) == tokens + + +MALFORMED_TOKEN_TESTS = ( + ('a = 1'), ('n ! 2'), +) + + +@pytest.mark.parametrize('rule_text', MALFORMED_TOKEN_TESTS) +def test_tokenize_malformed(rule_text): + with pytest.raises(plural.RuleError): + plural.tokenize_rule(rule_text) From ad2ff1b53a2c5c803b6cbdec08fe9ea1a3865333 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 17:29:24 -0500 Subject: [PATCH 039/489] Include Armin Ronacher's work on PluralRule mitsuhiko/babel@774047a --- babel/plural.py | 57 +++++++++++++++++++++++++++++++------------- tests/test_plural.py | 2 +- 2 files changed, 41 insertions(+), 18 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index e2eb88b05..03f92fc35 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -255,9 +255,12 @@ def cldr_modulo(a, b): class RuleError(Exception): """Raised if a rule is malformed.""" +_VARS = 'nivwft' + _RULES = [ (None, re.compile(r'\s+(?u)')), - ('word', re.compile(r'\b(and|or|is|(?:with)?in|not|mod|[nivwft])\b')), + ('word', re.compile(r'\b(and|or|is|(?:with)?in|not|mod|[{0}])\b' + .format(_VARS))), ('value', re.compile(r'\d+')), ('symbol', re.compile(r'%|,|!=|=')), ('ellipsis', re.compile(r'\.\.')) @@ -335,20 +338,20 @@ def __init__(self, string): raise RuleError('Expected end of rule, got %r' % self.tokens[-1][1]) - def test(self, type, value=None): - return self.tokens and self.tokens[-1][0] == type and \ - (value is None or self.tokens[-1][1] == value) + def test(self, type_, value=None): + return self.tokens and self.tokens[-1][0] == type_ and \ + (value is None or self.tokens[-1][1] == value) - def skip(self, type, value=None): - if self.test(type, value): + def skip(self, type_, value=None): + if self.test(type_, value): return self.tokens.pop() - def expect(self, type, value=None, term=None): - token = self.skip(type, value) + def expect(self, type_, value=None, term=None): + token = self.skip(type_, value) if token is not None: return token if term is None: - term = repr(value is None and type or value) + term = repr(value is None and type_ or value) if not self.tokens: raise RuleError('expected %s but end of rule reached' % term) raise RuleError('expected %s but got %r' % (term, self.tokens[-1][1])) @@ -369,36 +372,56 @@ def relation(self): left = self.expr() if self.skip('word', 'is'): return self.skip('word', 'not') and 'isnot' or 'is', \ - (left, self.value()) + (left, self.value()) negated = self.skip('word', 'not') method = 'in' if self.skip('word', 'within'): method = 'within' else: - self.expect('word', 'in', term="'within' or 'in'") + if not self.skip('word', 'in'): + if negated: + raise RuleError('Cannot negate operator based rules.') + return self.newfangled_relation(left) rv = 'relation', (method, left, self.range_list()) if negated: rv = 'not', (rv,) return rv + def newfangled_relation(self, left): + if self.skip('symbol', '='): + negated = False + elif self.skip('symbol', '!='): + negated = True + else: + raise RuleError('Expected "=" or "!=" or legacy relation') + rv = 'relation', ('in', left, self.range_list()) + if negated: + rv = 'not', (rv,) + return rv + def range_or_value(self): left = self.value() if self.skip('ellipsis'): - return((left, self.value())) + return left, self.value() else: - return((left, left)) + return left, left def range_list(self): range_list = [self.range_or_value()] - while self.skip('comma'): + while self.skip('symbol', ','): range_list.append(self.range_or_value()) return 'range_list', range_list def expr(self): - self.expect('word', 'n') + word = self.skip('word') + if word is None or word[1] not in _VARS: + raise RuleError('Expected identifier variable') + name = word[1] if self.skip('word', 'mod'): - return 'mod', (('n', ()), self.value()) - return 'n', () + return 'mod', ((name, ()), self.value()) + elif self.skip('symbol', '%'): + return 'mod', ((name, ()), self.value()) + return name, () def value(self): return 'value', (int(self.expect('value')[1]),) diff --git a/tests/test_plural.py b/tests/test_plural.py index ad6da7030..95e233029 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -18,7 +18,7 @@ from babel import plural -class test_plural_rule(): +def test_plural_rule(): rule = plural.PluralRule({'one': 'n is 1'}) assert rule(1) == 'one' assert rule(2) == 'other' From 9f354e1ff36e0cebdfee88b8eca6bb269a43cae4 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 17:53:16 -0500 Subject: [PATCH 040/489] Extract PluralRule.test and add tests --- babel/plural.py | 11 ++++++----- tests/test_plural.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 03f92fc35..1936c5cc4 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -286,6 +286,11 @@ def tokenize_rule(s): return result[::-1] +def test_next_token(tokens, type_, value=None): + return tokens and tokens[-1][0] == type_ and \ + (value is None or tokens[-1][1] == value) + + class _Parser(object): """Internal parser. This class can translate a single rule into an abstract tree of tuples. It implements the following grammar:: @@ -338,12 +343,8 @@ def __init__(self, string): raise RuleError('Expected end of rule, got %r' % self.tokens[-1][1]) - def test(self, type_, value=None): - return self.tokens and self.tokens[-1][0] == type_ and \ - (value is None or self.tokens[-1][1] == value) - def skip(self, type_, value=None): - if self.test(type_, value): + if test_next_token(self.tokens, type_, value): return self.tokens.pop() def expect(self, type_, value=None, term=None): diff --git a/tests/test_plural.py b/tests/test_plural.py index 95e233029..49bdfa737 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -126,3 +126,20 @@ def test_tokenize_well_formed(rule_text, tokens): def test_tokenize_malformed(rule_text): with pytest.raises(plural.RuleError): plural.tokenize_rule(rule_text) + + +class TestNextTokenTestCase(unittest.TestCase): + def test_empty(self): + assert not plural.test_next_token([], '') + + def test_type_ok_and_no_value(self): + assert plural.test_next_token([('word', 'and')], 'word') + + def test_type_ok_and_not_value(self): + assert not plural.test_next_token([('word', 'and')], 'word', 'or') + + def test_type_ok_and_value_ok(self): + assert plural.test_next_token([('word', 'and')], 'word', 'and') + + def test_type_not_ok_and_value_ok(self): + assert not plural.test_next_token([('abc', 'and')], 'word', 'and') \ No newline at end of file From e9a4518fccace157b97d00d4866147395de09e33 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 17:57:07 -0500 Subject: [PATCH 041/489] Extract PluralRule.skip --- babel/plural.py | 39 ++++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 1936c5cc4..5749bb04e 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -291,6 +291,11 @@ def test_next_token(tokens, type_, value=None): (value is None or tokens[-1][1] == value) +def skip_token(tokens, type_, value=None): + if test_next_token(tokens, type_, value): + return tokens.pop() + + class _Parser(object): """Internal parser. This class can translate a single rule into an abstract tree of tuples. It implements the following grammar:: @@ -343,12 +348,8 @@ def __init__(self, string): raise RuleError('Expected end of rule, got %r' % self.tokens[-1][1]) - def skip(self, type_, value=None): - if test_next_token(self.tokens, type_, value): - return self.tokens.pop() - def expect(self, type_, value=None, term=None): - token = self.skip(type_, value) + token = skip_token(self.tokens, type_, value) if token is not None: return token if term is None: @@ -359,27 +360,27 @@ def expect(self, type_, value=None, term=None): def condition(self): op = self.and_condition() - while self.skip('word', 'or'): + while skip_token(self.tokens, 'word', 'or'): op = 'or', (op, self.and_condition()) return op def and_condition(self): op = self.relation() - while self.skip('word', 'and'): + while skip_token(self.tokens, 'word', 'and'): op = 'and', (op, self.relation()) return op def relation(self): left = self.expr() - if self.skip('word', 'is'): - return self.skip('word', 'not') and 'isnot' or 'is', \ + if skip_token(self.tokens, 'word', 'is'): + return skip_token(self.tokens, 'word', 'not') and 'isnot' or 'is', \ (left, self.value()) - negated = self.skip('word', 'not') + negated = skip_token(self.tokens, 'word', 'not') method = 'in' - if self.skip('word', 'within'): + if skip_token(self.tokens, 'word', 'within'): method = 'within' else: - if not self.skip('word', 'in'): + if not skip_token(self.tokens, 'word', 'in'): if negated: raise RuleError('Cannot negate operator based rules.') return self.newfangled_relation(left) @@ -389,9 +390,9 @@ def relation(self): return rv def newfangled_relation(self, left): - if self.skip('symbol', '='): + if skip_token(self.tokens, 'symbol', '='): negated = False - elif self.skip('symbol', '!='): + elif skip_token(self.tokens, 'symbol', '!='): negated = True else: raise RuleError('Expected "=" or "!=" or legacy relation') @@ -402,25 +403,25 @@ def newfangled_relation(self, left): def range_or_value(self): left = self.value() - if self.skip('ellipsis'): + if skip_token(self.tokens, 'ellipsis'): return left, self.value() else: return left, left def range_list(self): range_list = [self.range_or_value()] - while self.skip('symbol', ','): + while skip_token(self.tokens, 'symbol', ','): range_list.append(self.range_or_value()) return 'range_list', range_list def expr(self): - word = self.skip('word') + word = skip_token(self.tokens, 'word') if word is None or word[1] not in _VARS: raise RuleError('Expected identifier variable') name = word[1] - if self.skip('word', 'mod'): + if skip_token(self.tokens, 'word', 'mod'): return 'mod', ((name, ()), self.value()) - elif self.skip('symbol', '%'): + elif skip_token(self.tokens, 'symbol', '%'): return 'mod', ((name, ()), self.value()) return name, () From c7ae2dbdb153d20fb5e6a49a1edc669a7d02e1fd Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 19:01:48 -0500 Subject: [PATCH 042/489] More extraction in _Parser, more tests --- babel/plural.py | 26 ++++++++++++++++++++------ tests/test_plural.py | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 5749bb04e..e4acd41d6 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -296,6 +296,22 @@ def skip_token(tokens, type_, value=None): return tokens.pop() +def value_node(value): + return 'value', (value, ) + + +def ident_node(name): + return name, () + + +def range_list_node(range_list): + return 'range_list', range_list + + +def negate(rv): + return 'not', (rv,) + + class _Parser(object): """Internal parser. This class can translate a single rule into an abstract tree of tuples. It implements the following grammar:: @@ -385,9 +401,7 @@ def relation(self): raise RuleError('Cannot negate operator based rules.') return self.newfangled_relation(left) rv = 'relation', (method, left, self.range_list()) - if negated: - rv = 'not', (rv,) - return rv + return negate(rv) if negated else rv def newfangled_relation(self, left): if skip_token(self.tokens, 'symbol', '='): @@ -412,7 +426,7 @@ def range_list(self): range_list = [self.range_or_value()] while skip_token(self.tokens, 'symbol', ','): range_list.append(self.range_or_value()) - return 'range_list', range_list + return range_list_node(range_list) def expr(self): word = skip_token(self.tokens, 'word') @@ -423,10 +437,10 @@ def expr(self): return 'mod', ((name, ()), self.value()) elif skip_token(self.tokens, 'symbol', '%'): return 'mod', ((name, ()), self.value()) - return name, () + return ident_node(name) def value(self): - return 'value', (int(self.expect('value')[1]),) + return value_node(int(self.expect('value')[1])) def _binary_compiler(tmpl): diff --git a/tests/test_plural.py b/tests/test_plural.py index 49bdfa737..d20d186a4 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -142,4 +142,37 @@ def test_type_ok_and_value_ok(self): assert plural.test_next_token([('word', 'and')], 'word', 'and') def test_type_not_ok_and_value_ok(self): - assert not plural.test_next_token([('abc', 'and')], 'word', 'and') \ No newline at end of file + assert not plural.test_next_token([('abc', 'and')], 'word', 'and') + + +def make_range_list(*values): + ranges = [] + for v in values: + if isinstance(v, int): + val_node = plural.value_node(v) + ranges.append((val_node, val_node)) + else: + assert isinstance(v, tuple) + ranges.append((plural.value_node(v[0]), + plural.value_node(v[1]))) + return plural.range_list_node(ranges) + + +class PluralRuleParserTestCase(unittest.TestCase): + def setUp(self): + self.n = plural.ident_node('n') + self.n_eq_1 = ('relation', ('in', self.n, make_range_list(1))) + + def test_error_when_unexpected_end(self): + with pytest.raises(plural.RuleError): + plural._Parser('n =') + + def test_eq_relation(self): + assert plural._Parser('n = 1').ast == self.n_eq_1 + + def test_in_range_relation(self): + assert plural._Parser('n = 2..4').ast == \ + ('relation', ('in', self.n, make_range_list((2, 4)))) + + def test_negate(self): + assert plural._Parser('n != 1').ast == plural.negate(self.n_eq_1) \ No newline at end of file From 88b2b2ecd1e0e5d9daf10e455293ebe020d9d7bf Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 19:03:25 -0500 Subject: [PATCH 043/489] Improved _Parser doc --- babel/plural.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index e4acd41d6..297a42282 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -334,24 +334,13 @@ class _Parser(object): sampleRange = decimalValue '~' decimalValue decimalValue = value ('.' value)? - condition = and_condition ('or' and_condition)* - and_condition = relation ('and' relation)* - relation = is_relation | in_relation | within_relation | 'n' - is_relation = expr 'is' ('not')? value - in_relation = expr ('not')? 'in' range_list - within_relation = expr ('not')? 'within' range_list - expr = 'n' ('mod' value)? - range_list = (range | value) (',' range_list)* - value = digit+ - digit = 0|1|2|3|4|5|6|7|8|9 - range = value'..'value - - Whitespace can occur between or around any of the above tokens. - Rules should be mutually exclusive; for a given numeric value, only one rule should apply (i.e. the condition should only be true for one of the plural rule elements). - The in and within relations can take comma-separated lists, such as: 'n in 3,5,7..15'. + - Samples are ignored. The translator parses the expression on instanciation into an attribute called `ast`. @@ -411,9 +400,7 @@ def newfangled_relation(self, left): else: raise RuleError('Expected "=" or "!=" or legacy relation') rv = 'relation', ('in', left, self.range_list()) - if negated: - rv = 'not', (rv,) - return rv + return negate(rv) if negated else rv def range_or_value(self): left = self.value() From e463ce92ff950917ad632c36c07b403d2410df98 Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 19:22:20 -0500 Subject: [PATCH 044/489] More plural._Parser tests --- tests/test_plural.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/test_plural.py b/tests/test_plural.py index d20d186a4..489c0efcb 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -161,18 +161,40 @@ def make_range_list(*values): class PluralRuleParserTestCase(unittest.TestCase): def setUp(self): self.n = plural.ident_node('n') - self.n_eq_1 = ('relation', ('in', self.n, make_range_list(1))) + + def n_eq(self, v): + return 'relation', ('in', self.n, make_range_list(v)) def test_error_when_unexpected_end(self): with pytest.raises(plural.RuleError): plural._Parser('n =') def test_eq_relation(self): - assert plural._Parser('n = 1').ast == self.n_eq_1 + assert plural._Parser('n = 1').ast == self.n_eq(1) def test_in_range_relation(self): assert plural._Parser('n = 2..4').ast == \ ('relation', ('in', self.n, make_range_list((2, 4)))) def test_negate(self): - assert plural._Parser('n != 1').ast == plural.negate(self.n_eq_1) \ No newline at end of file + assert plural._Parser('n != 1').ast == plural.negate(self.n_eq(1)) + + def test_or(self): + assert plural._Parser('n = 1 or n = 2').ast ==\ + ('or', (self.n_eq(1), self.n_eq(2))) + + def test_and(self): + assert plural._Parser('n = 1 and n = 2').ast ==\ + ('and', (self.n_eq(1), self.n_eq(2))) + + def test_or_and(self): + assert plural._Parser('n = 0 or n != 1 and n % 100 = 1..19' ).ast ==\ + ('or', (self.n_eq(0), + ('and', (plural.negate(self.n_eq(1)), + ('relation', ('in', + ('mod', (self.n, + plural.value_node(100))), + (make_range_list((1, 19)))))) + ) + ) + ) \ No newline at end of file From fb808af2f681232c14b15f39149ff2e190dfcaca Mon Sep 17 00:00:00 2001 From: benselme Date: Thu, 8 Jan 2015 19:40:28 -0500 Subject: [PATCH 045/489] More stuff merged from mitsuhiko's cldr-24 branch --- babel/plural.py | 21 ++++++++++++++++++++- tests/test_plural.py | 5 +++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/babel/plural.py b/babel/plural.py index 297a42282..ce6659b47 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -52,6 +52,8 @@ def __init__(self, rules): found = set() self.abstract = [] for key, expr in sorted(list(rules)): + if key == 'other': + continue if key not in _plural_tags: raise ValueError('unknown tag %r' % key) elif key in found: @@ -450,6 +452,11 @@ def compile(self, arg): return getattr(self, 'compile_' + op)(*args) compile_n = lambda x: 'n' + compile_i = lambda x: 'i' + compile_v = lambda x: 'v' + compile_w = lambda x: 'w' + compile_f = lambda x: 'f' + compile_t = lambda x: 't' compile_value = lambda x, v: str(v) compile_and = _binary_compiler('(%s && %s)') compile_or = _binary_compiler('(%s || %s)') @@ -504,18 +511,30 @@ def compile_relation(self, method, expr, range_list): class _JavaScriptCompiler(_GettextCompiler): """Compiles the expression to plain of JavaScript.""" + # XXX: presently javascript does not support any of the + # fraction support and basically only deals with integers. + compile_i = lambda x: 'parseInt(n, 10)' + compile_v = lambda x: '0' + compile_w = lambda x: '0' + compile_f = lambda x: '0' + compile_t = lambda x: '0' + def compile_relation(self, method, expr, range_list): code = _GettextCompiler.compile_relation( self, method, expr, range_list) if method == 'in': expr = self.compile(expr) - code = '(parseInt(%s) == %s && %s)' % (expr, expr, code) + code = '(parseInt(%s, 10) == %s && %s)' % (expr, expr, code) return code class _UnicodeCompiler(_Compiler): """Returns a unicode pluralization rule again.""" + # XXX: this currently spits out the old syntax instead of the new + # one. We can change that, but it will break a whole bunch of stuff + # for users I suppose. + compile_is = _binary_compiler('%s is %s') compile_isnot = _binary_compiler('%s is not %s') compile_and = _binary_compiler('%s and %s') diff --git a/tests/test_plural.py b/tests/test_plural.py index 489c0efcb..166e2276d 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -27,6 +27,11 @@ def test_plural_rule(): assert rule.rules == {'one': 'n is 1'} +def test_plural_other_is_ignored(): + rule = plural.PluralRule({'one': 'n is 1', 'other': '@integer 2'}) + assert rule(1) == 'one' + + def test_to_javascript(): assert (plural.to_javascript({'one': 'n is 1'}) == "(function(n) { return (n == 1) ? 'one' : 'other'; })") From 022b8fd62a5b2e85b3aa254ceee0b5bea15ac1eb Mon Sep 17 00:00:00 2001 From: benselme Date: Fri, 9 Jan 2015 08:51:54 -0500 Subject: [PATCH 046/489] PEP8 --- tests/test_plural.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/test_plural.py b/tests/test_plural.py index 166e2276d..122d64d77 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -81,8 +81,8 @@ def test_plural_within_rules(): assert repr(p) == "" assert plural.to_javascript(p) == ( "(function(n) { " - "return ((n == 2) || (n == 4) || (n >= 7 && n <= 9))" - " ? 'few' : (n == 1) ? 'one' : 'other'; })") + "return ((n == 2) || (n == 4) || (n >= 7 && n <= 9))" + " ? 'few' : (n == 1) ? 'one' : 'other'; })") assert plural.to_gettext(p) == ( 'nplurals=3; plural=(((n == 2) || (n == 4) || (n >= 7 && n <= 9))' ' ? 1 : (n == 1) ? 0 : 2)') @@ -193,13 +193,11 @@ def test_and(self): ('and', (self.n_eq(1), self.n_eq(2))) def test_or_and(self): - assert plural._Parser('n = 0 or n != 1 and n % 100 = 1..19' ).ast ==\ + assert plural._Parser('n = 0 or n != 1 and n % 100 = 1..19').ast == \ ('or', (self.n_eq(0), ('and', (plural.negate(self.n_eq(1)), ('relation', ('in', ('mod', (self.n, plural.value_node(100))), - (make_range_list((1, 19)))))) - ) - ) - ) \ No newline at end of file + (make_range_list((1, 19))))))) + )) From 1beec5d6d5c4c6a14976033661e35c337df22cee Mon Sep 17 00:00:00 2001 From: benselme Date: Fri, 9 Jan 2015 10:33:24 -0500 Subject: [PATCH 047/489] plural.extract_operands function and tests --- babel/plural.py | 39 ++++++++++++++++++++++++++++++++++----- tests/test_plural.py | 23 +++++++++++++++++++++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index ce6659b47..b11b17e51 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -8,7 +8,7 @@ :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. """ - +import decimal import re @@ -16,6 +16,32 @@ _fallback_tag = 'other' +def extract_operands(source): + """Extract operands from a decimal, a float or an int, according to + `CLDR rules`_. + + .. _`CLDR rules`: http://www.unicode.org/reports/tr35/tr35-33/tr35-numbers.html#Operands + """ + n = abs(source) + i = int(n) + if isinstance(n, float): + n = i if i == n else decimal.Decimal(n) + + if isinstance(n, decimal.Decimal): + dec_tuple = n.as_tuple() + exp = dec_tuple.exponent + fraction_digits = dec_tuple.digits[exp:] if exp < 0 else () + trailing = ''.join(str(d) for d in fraction_digits) + no_trailing = trailing.rstrip('0') + v = len(trailing) + w = len(no_trailing) + f = int(trailing or 0) + t = int(no_trailing or 0) + else: + v = w = f = t = 0 + return n, i, v, w, f, t + + class PluralRule(object): """Represents a set of language pluralization rules. The constructor accepts a list of (tag, expr) tuples or a dict of `CLDR rules`_. The @@ -106,7 +132,7 @@ def __setstate__(self, abstract): def __call__(self, n): if not hasattr(self, '_func'): self._func = to_python(self) - return self._func(n) + return self._func(*extract_operands(n)) def to_javascript(rule): @@ -156,12 +182,15 @@ def to_python(rule): 'WITHIN': within_range_list, 'MOD': cldr_modulo } - to_python = _PythonCompiler().compile - result = ['def evaluate(n):'] + to_python_func = _PythonCompiler().compile + result = [ + 'def evaluate(n, v=0, w=0, f=0, t=0):', + ' i = int(n)', + ] for tag, ast in PluralRule.parse(rule).abstract: # the str() call is to coerce the tag to the native string. It's # a limited ascii restricted set of tags anyways so that is fine. - result.append(' if (%s): return %r' % (to_python(ast), str(tag))) + result.append(' if (%s): return %r' % (to_python_func(ast), str(tag))) result.append(' return %r' % _fallback_tag) code = compile('\n'.join(result), '', 'exec') eval(code, namespace) diff --git a/tests/test_plural.py b/tests/test_plural.py index 122d64d77..ece1358c5 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -10,11 +10,12 @@ # 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 decimal import doctest import unittest import pytest - +from decimal import Decimal as Dec from babel import plural @@ -123,7 +124,7 @@ def test_tokenize_well_formed(rule_text, tokens): MALFORMED_TOKEN_TESTS = ( - ('a = 1'), ('n ! 2'), + 'a = 1', 'n ! 2', ) @@ -201,3 +202,21 @@ def test_or_and(self): plural.value_node(100))), (make_range_list((1, 19))))))) )) + + +EXTRACT_OPERANDS_TESTS = ( + (1, 1, 1, 0, 0, 0, 0), + ('1.0', '1.0', 1, 1, 0, 0, 0), + ('1.00', '1.00', 1, 2, 0, 0, 0), + ('1.3', '1.3', 1, 1, 1, 3, 3), + ('1.30', '1.30', 1, 2, 1, 30, 3), + ('1.03', '1.03', 1, 2, 2, 3, 3), + ('1.230', '1.230', 1, 3, 2, 230, 23), + (-1, 1, 1, 0, 0, 0, 0), +) + + +@pytest.mark.parametrize('source,n,i,v,w,f,t', EXTRACT_OPERANDS_TESTS) +def test_extract_operands(source, n, i, v, w, f, t): + assert (plural.extract_operands(decimal.Decimal(source)) == + decimal.Decimal(n), i, v, w, f, t) From 071e38a1c9d882e3290430255a5a707d3d9aa8e0 Mon Sep 17 00:00:00 2001 From: benselme Date: Fri, 9 Jan 2015 11:17:07 -0500 Subject: [PATCH 048/489] Fix to_python when i is not provided to evaluate function --- babel/plural.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index b11b17e51..7ce62e1fb 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -184,8 +184,8 @@ def to_python(rule): } to_python_func = _PythonCompiler().compile result = [ - 'def evaluate(n, v=0, w=0, f=0, t=0):', - ' i = int(n)', + 'def evaluate(n, i=None, v=0, w=0, f=0, t=0):', + ' i = int(n) if i is None else i', ] for tag, ast in PluralRule.parse(rule).abstract: # the str() call is to coerce the tag to the native string. It's From 9327e0824a1bbed538e73d42b971988f8214b490 Mon Sep 17 00:00:00 2001 From: benselme Date: Fri, 9 Jan 2015 14:56:31 -0500 Subject: [PATCH 049/489] Fixed import and format_timedelta to handle new time unit patterns in cldr-24. Fixed tests to account for various minor changes in cldr-24. --- babel/dates.py | 29 +++++++++++++++++++---------- scripts/import_cldr.py | 27 +++++++++++++++++++-------- tests/test_core.py | 2 +- tests/test_dates.py | 17 ++++++++--------- 4 files changed, 47 insertions(+), 28 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 73d54fa57..bb215d1e8 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -19,6 +19,7 @@ from __future__ import division import re +import warnings import pytz as _pytz from datetime import date, datetime, time, timedelta @@ -705,7 +706,7 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): def format_timedelta(delta, granularity='second', threshold=.85, - add_direction=False, format='medium', + add_direction=False, format='long', locale=LC_TIME): """Return a time delta according to the rules of the given locale. @@ -750,25 +751,34 @@ def format_timedelta(delta, granularity='second', threshold=.85, 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 format: the format (currently only "long" and "short" are supported, + "medium" is deprecated, currently converted to "long" to + maintain compatibility) :param locale: a `Locale` object or a locale identifier """ - if format not in ('short', 'medium'): + if format not in ('short', 'medium', 'long'): raise TypeError('Format can only be one of "short" or "medium"') + if format == 'medium': + warnings.warn('"medium" value for format param of format_timedelta' + ' is deprecated. Use "long" instead', + category=DeprecationWarning) + format = 'long' if isinstance(delta, timedelta): seconds = int((delta.days * 86400) + delta.seconds) else: seconds = delta locale = Locale.parse(locale) - def _iter_choices(unit): + def _iter_patterns(a_unit): if add_direction: + unit_rel_patterns = locale._data['date_fields'][a_unit] if seconds >= 0: - yield unit + '-future' + yield unit_rel_patterns['future'] else: - yield unit + '-past' - yield unit + ':' + format - yield unit + yield unit_rel_patterns['past'] + a_unit = 'duration-' + a_unit + yield locale._data['unit_patterns'].get(a_unit + ':' + format) + yield locale._data['unit_patterns'].get(a_unit) for unit, secs_per_unit in TIMEDELTA_UNITS: value = abs(seconds) / secs_per_unit @@ -778,8 +788,7 @@ def _iter_choices(unit): value = int(round(value)) plural_form = locale.plural_form(value) pattern = None - for choice in _iter_choices(unit): - patterns = locale._data['unit_patterns'].get(choice) + for patterns in _iter_patterns(unit): if patterns is not None: pattern = patterns[plural_form] break diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 082393ce7..637804127 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -609,14 +609,25 @@ def main(): # unit_patterns = data.setdefault('unit_patterns', {}) - for elem in tree.findall('.//units/unit'): - unit_type = elem.attrib['type'] - for pattern in elem.findall('unitPattern'): - box = unit_type - if 'alt' in pattern.attrib: - box += ':' + pattern.attrib['alt'] - unit_patterns.setdefault(box, {})[pattern.attrib['count']] = \ - text_type(pattern.text) + for elem in tree.findall('.//units/unitLength'): + unit_length_type = elem.attrib['type'] + for unit in elem.findall('unit'): + unit_type = unit.attrib['type'] + for pattern in unit.findall('unitPattern'): + box = unit_type + box += ':' + unit_length_type + unit_patterns.setdefault(box, {})[pattern.attrib['count']] = \ + text_type(pattern.text) + + date_fields = data.setdefault('date_fields', {}) + for elem in tree.findall('.//dates/fields/field'): + field_type = elem.attrib['type'] + date_fields.setdefault(field_type, {}) + for rel_time in elem.findall('relativeTime'): + rel_time_type = rel_time.attrib['type'] + for pattern in rel_time.findall('relativeTimePattern'): + date_fields[field_type].setdefault(rel_time_type, {})\ + [pattern.attrib['count']] = text_type(pattern.text) outfile = open(data_filename, 'wb') try: diff --git a/tests/test_core.py b/tests/test_core.py index ac2611dce..7e16765fc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -220,7 +220,7 @@ def test_time_formats_property(self): def test_datetime_formats_property(self): assert Locale('en').datetime_formats['full'] == u"{1} 'at' {0}" - assert Locale('th').datetime_formats['medium'] == u'{1}, {0}' + assert Locale('th').datetime_formats['medium'] == u'{1} {0}' def test_plural_form_property(self): assert Locale('en').plural_form(1) == 'one' diff --git a/tests/test_dates.py b/tests/test_dates.py index 7b897a13a..b18bf5076 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -191,25 +191,25 @@ def test_timezone_name(self): tz = timezone('Europe/Paris') dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) fmt = dates.DateTimeFormat(dt, locale='fr_FR') - self.assertEqual('Heure : France', fmt['v']) + self.assertEqual('heure : France', fmt['v']) def test_timezone_location_format(self): tz = timezone('Europe/Paris') dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) fmt = dates.DateTimeFormat(dt, locale='fr_FR') - self.assertEqual('Heure : France', fmt['VVVV']) + self.assertEqual('heure : France', fmt['VVVV']) def test_timezone_walltime_short(self): tz = timezone('Europe/Paris') t = time(15, 30, tzinfo=tz) fmt = dates.DateTimeFormat(t, locale='fr_FR') - self.assertEqual('Heure : France', fmt['v']) + self.assertEqual('heure : France', fmt['v']) def test_timezone_walltime_long(self): tz = timezone('Europe/Paris') t = time(15, 30, tzinfo=tz) fmt = dates.DateTimeFormat(t, locale='fr_FR') - self.assertEqual(u'heure de l\u2019Europe centrale', fmt['vvvv']) + self.assertEqual(u'heure d\u2019Europe centrale', fmt['vvvv']) def test_hour_formatting(self): l = 'en_US' @@ -265,7 +265,6 @@ def test_with_float(self): formatted_time = dates.format_time(epoch, format='long', locale='en_US') self.assertEqual(u'3:30:29 PM +0000', formatted_time) - def test_with_date_fields_in_pattern(self): self.assertRaises(AttributeError, dates.format_time, date(2007, 4, 1), "yyyy-MM-dd HH:mm", locale='en_US') @@ -305,7 +304,7 @@ def test_direction_adding(self): string = dates.format_timedelta(timedelta(hours=1), locale='en', add_direction=True) - self.assertEqual('In 1 hour', string) + self.assertEqual('in 1 hour', string) string = dates.format_timedelta(timedelta(hours=-1), locale='en', add_direction=True) @@ -336,14 +335,14 @@ def test_get_period_names(): def test_get_day_names(): assert dates.get_day_names('wide', locale='en_US')[1] == u'Tuesday' - assert dates.get_day_names('abbreviated', locale='es')[1] == u'mar' + assert dates.get_day_names('abbreviated', locale='es')[1] == u'mar.' de = dates.get_day_names('narrow', context='stand-alone', locale='de_DE') assert de[1] == u'D' def test_get_month_names(): assert dates.get_month_names('wide', locale='en_US')[1] == u'January' - assert dates.get_month_names('abbreviated', locale='es')[1] == u'ene' + assert dates.get_month_names('abbreviated', locale='es')[1] == u'ene.' de = dates.get_month_names('narrow', context='stand-alone', locale='de_DE') assert de[1] == u'J' @@ -477,7 +476,7 @@ def test_format_time(): t = time(15, 30) paris = dates.format_time(t, format='full', tzinfo=timezone('Europe/Paris'), locale='fr_FR') - assert paris == u'15:30:00 heure normale de l\u2019Europe centrale' + assert paris == u'15:30:00 heure normale d\u2019Europe centrale' us_east = dates.format_time(t, format='full', tzinfo=timezone('US/Eastern'), locale='en_US') assert us_east == u'3:30:00 PM Eastern Standard Time' From 98fa4b6ca174e1ef3b0fbd9557b2006d38a52f60 Mon Sep 17 00:00:00 2001 From: benselme Date: Fri, 9 Jan 2015 15:35:21 -0500 Subject: [PATCH 050/489] Make sure extract_operands is always called when using plural.to_python, add tests to check operands eval works --- babel/plural.py | 9 +++++---- tests/test_plural.py | 41 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 7ce62e1fb..918038e61 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -132,7 +132,7 @@ def __setstate__(self, abstract): def __call__(self, n): if not hasattr(self, '_func'): self._func = to_python(self) - return self._func(*extract_operands(n)) + return self._func(n) def to_javascript(rule): @@ -180,12 +180,13 @@ def to_python(rule): namespace = { 'IN': in_range_list, 'WITHIN': within_range_list, - 'MOD': cldr_modulo + 'MOD': cldr_modulo, + 'extract_operands': extract_operands, } to_python_func = _PythonCompiler().compile result = [ - 'def evaluate(n, i=None, v=0, w=0, f=0, t=0):', - ' i = int(n) if i is None else i', + 'def evaluate(n):', + ' n, i, v, w, f, t = extract_operands(n)', ] for tag, ast in PluralRule.parse(rule).abstract: # the str() call is to coerce the tag to the native string. It's diff --git a/tests/test_plural.py b/tests/test_plural.py index ece1358c5..278b5dba8 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -11,11 +11,8 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at http://babel.edgewall.org/log/. import decimal - -import doctest import unittest import pytest -from decimal import Decimal as Dec from babel import plural @@ -28,6 +25,40 @@ def test_plural_rule(): assert rule.rules == {'one': 'n is 1'} +def test_plural_rule_operands_i(): + rule = plural.PluralRule({'one': 'i is 1'}) + assert rule(1.2) == 'one' + assert rule(2) == 'other' + + +def test_plural_rule_operands_v(): + rule = plural.PluralRule({'one': 'v is 2'}) + assert rule(decimal.Decimal('1.20')) == 'one' + assert rule(decimal.Decimal('1.2')) == 'other' + assert rule(2) == 'other' + + +def test_plural_rule_operands_w(): + rule = plural.PluralRule({'one': 'w is 2'}) + assert rule(decimal.Decimal('1.23')) == 'one' + assert rule(decimal.Decimal('1.20')) == 'other' + assert rule(1.2) == 'other' + + +def test_plural_rule_operands_f(): + rule = plural.PluralRule({'one': 'f is 20'}) + assert rule(decimal.Decimal('1.23')) == 'other' + assert rule(decimal.Decimal('1.20')) == 'one' + assert rule(1.2) == 'other' + + +def test_plural_rule_operands_t(): + rule = plural.PluralRule({'one': 't = 5'}) + assert rule(decimal.Decimal('1.53')) == 'other' + assert rule(decimal.Decimal('1.50')) == 'one' + assert rule(1.5) == 'one' + + def test_plural_other_is_ignored(): rule = plural.PluralRule({'one': 'n is 1', 'other': '@integer 2'}) assert rule(1) == 'one' @@ -213,10 +244,12 @@ def test_or_and(self): ('1.03', '1.03', 1, 2, 2, 3, 3), ('1.230', '1.230', 1, 3, 2, 230, 23), (-1, 1, 1, 0, 0, 0, 0), + (1.3, '1.3', 1, 1, 1, 3, 3), ) @pytest.mark.parametrize('source,n,i,v,w,f,t', EXTRACT_OPERANDS_TESTS) def test_extract_operands(source, n, i, v, w, f, t): - assert (plural.extract_operands(decimal.Decimal(source)) == + source = decimal.Decimal(source) if isinstance(source, str) else source + assert (plural.extract_operands(source) == decimal.Decimal(n), i, v, w, f, t) From a6d89445ae01cabbbe09541428018523cf49d579 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 11:54:28 -0500 Subject: [PATCH 051/489] File name and checksum for CLDR-25 --- scripts/download_import_cldr.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index fe0105339..fda5ff48d 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -13,9 +13,9 @@ from urllib import urlretrieve -URL = 'http://unicode.org/Public/cldr/24/core.zip' -FILENAME = 'core-24.zip' -FILESUM = 'cd2e8f31baf65c96bfc7e5377b3b793f' +URL = 'http://unicode.org/Public/cldr/25/core.zip' +FILENAME = 'core-25.zip' +FILESUM = '44bdc6ca189d55eb36063dfbec208927' BLKSIZE = 131072 From 87bab018a1003ca32524c7d98bffea112b9d4fca Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 11:55:24 -0500 Subject: [PATCH 052/489] Don't try and import deprecated BCP-47 timezones --- scripts/import_cldr.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 637804127..7dcd171ca 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -163,13 +163,14 @@ def main(): for key_elem in bcp47_timezone.findall('.//keyword/key'): if key_elem.attrib['name'] == 'tz': for elem in key_elem.findall('type'): - aliases = text_type(elem.attrib['alias']).split() - tzid = aliases.pop(0) - territory = _zone_territory_map.get(tzid, '001') - territory_zones.setdefault(territory, []).append(tzid) - zone_territories[tzid] = territory - for alias in aliases: - zone_aliases[alias] = tzid + if 'deprecated' not in elem.attrib: + aliases = text_type(elem.attrib['alias']).split() + tzid = aliases.pop(0) + territory = _zone_territory_map.get(tzid, '001') + territory_zones.setdefault(territory, []).append(tzid) + zone_territories[tzid] = territory + for alias in aliases: + zone_aliases[alias] = tzid break # Import Metazone mapping From 44942cf1627cd68cdb987be7d67cb40259f65638 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 15:21:16 -0500 Subject: [PATCH 053/489] CLDR-26 support: Minor adjustments to tests. URL, filename and filehash. --- scripts/download_import_cldr.py | 6 +++--- tests/test_dates.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index fda5ff48d..f8ba1fa92 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -13,9 +13,9 @@ from urllib import urlretrieve -URL = 'http://unicode.org/Public/cldr/25/core.zip' -FILENAME = 'core-25.zip' -FILESUM = '44bdc6ca189d55eb36063dfbec208927' +URL = 'http://unicode.org/Public/cldr/26/core.zip' +FILENAME = 'core-26.zip' +FILESUM = '46220170238b092685fd24221f895e3d' BLKSIZE = 131072 diff --git a/tests/test_dates.py b/tests/test_dates.py index b18bf5076..436039378 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -35,10 +35,10 @@ def test_quarter_format(self): def test_month_context(self): d = date(2006, 2, 8) - fmt = dates.DateTimeFormat(d, locale='cs_CZ') - self.assertEqual(u'2', fmt['MMMMM']) # narrow format - fmt = dates.DateTimeFormat(d, locale='cs_CZ') - self.assertEqual(u'ú', fmt['LLLLL']) # narrow standalone + fmt = dates.DateTimeFormat(d, locale='mt_MT') + self.assertEqual(u'F', fmt['MMMMM']) # narrow format + fmt = dates.DateTimeFormat(d, locale='mt_MT') + self.assertEqual(u'Fr', fmt['LLLLL']) # narrow standalone def test_abbreviated_month_alias(self): d = date(2006, 3, 8) @@ -389,7 +389,7 @@ def test_get_timezone_gmt(): def test_get_timezone_location(): tz = timezone('America/St_Johns') assert (dates.get_timezone_location(tz, locale='de_DE') == - u"Kanada (St. John's) Zeit") + u"Kanada (St. John\u2019s) Zeit") tz = timezone('America/Mexico_City') assert (dates.get_timezone_location(tz, locale='de_DE') == u'Mexiko (Mexiko-Stadt) Zeit') @@ -450,7 +450,7 @@ def test_format_datetime(): full = dates.format_datetime(dt, 'full', tzinfo=timezone('Europe/Paris'), locale='fr_FR') assert full == (u'dimanche 1 avril 2007 17:30:00 heure ' - u'avanc\xe9e d\u2019Europe centrale') + u'd\u2019\xe9t\xe9 d\u2019Europe centrale') custom = dates.format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", tzinfo=timezone('US/Eastern'), locale='en') assert custom == u'2007.04.01 AD at 11:30:00 EDT' @@ -468,7 +468,7 @@ def test_format_time(): tzinfo = timezone('Europe/Paris') t = tzinfo.localize(t) fr = dates.format_time(t, format='full', tzinfo=tzinfo, locale='fr_FR') - assert fr == u'15:30:00 heure avanc\xe9e d\u2019Europe centrale' + assert fr == u'15:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale' custom = dates.format_time(t, "hh 'o''clock' a, zzzz", tzinfo=timezone('US/Eastern'), locale='en') assert custom == u"09 o'clock AM, Eastern Daylight Time" From 04f7d471effb948aaec22208a912ea6e36ea2e74 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 15:58:53 -0500 Subject: [PATCH 054/489] Fixed doctests --- babel/core.py | 2 +- babel/dates.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/babel/core.py b/babel/core.py index df3857295..712585856 100644 --- a/babel/core.py +++ b/babel/core.py @@ -722,7 +722,7 @@ def datetime_formats(self): >>> Locale('en').datetime_formats['full'] u"{1} 'at' {0}" >>> Locale('th').datetime_formats['medium'] - u'{1}, {0}' + u'{1} {0}' """ return self._data['datetime_formats'] diff --git a/babel/dates.py b/babel/dates.py index bb215d1e8..4d326d20e 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -167,7 +167,7 @@ def get_day_names(width='wide', context='format', locale=LC_TIME): >>> get_day_names('wide', locale='en_US')[1] u'Tuesday' >>> get_day_names('abbreviated', locale='es')[1] - u'mar' + u'mar.' >>> get_day_names('narrow', context='stand-alone', locale='de_DE')[1] u'D' @@ -184,7 +184,7 @@ def get_month_names(width='wide', context='format', locale=LC_TIME): >>> get_month_names('wide', locale='en_US')[1] u'January' >>> get_month_names('abbreviated', locale='es')[1] - u'ene' + u'ene.' >>> get_month_names('narrow', context='stand-alone', locale='de_DE')[1] u'J' @@ -661,7 +661,7 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): >>> t = time(15, 30) >>> format_time(t, format='full', tzinfo=get_timezone('Europe/Paris'), ... locale='fr_FR') - u'15:30:00 heure normale de l\u2019Europe centrale' + u'15:30:00 heure normale d\u2019Europe centrale' >>> format_time(t, format='full', tzinfo=get_timezone('US/Eastern'), ... locale='en_US') u'3:30:00 PM Eastern Standard Time' @@ -735,7 +735,7 @@ def format_timedelta(delta, granularity='second', threshold=.85, the user if the date is in the past or in the future: >>> format_timedelta(timedelta(hours=1), add_direction=True, locale='en') - u'In 1 hour' + u'in 1 hour' >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en') u'1 hour ago' From bd3dec313a8dc9a94eaba87662c21bfc84b37d59 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 17:12:47 -0500 Subject: [PATCH 055/489] Fix doctests --- babel/dates.py | 10 +++++----- babel/numbers.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 4d326d20e..63c6eb16e 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -320,14 +320,14 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): - """Return a representation of the given timezone using "location format". + u"""Return a representation of the given timezone using "location format". The result depends on both the local display name of the country and the city associated with the time zone: >>> tz = get_timezone('America/St_Johns') - >>> get_timezone_location(tz, locale='de_DE') - u"Kanada (St. John's) Zeit" + >>> print(get_timezone_location(tz, locale='de_DE')) + Kanada (St. John’s) Zeit >>> tz = get_timezone('America/Mexico_City') >>> get_timezone_location(tz, locale='de_DE') u'Mexiko (Mexiko-Stadt) Zeit' @@ -582,7 +582,7 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, >>> 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' + u'dimanche 1 avril 2007 17:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale' >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", ... tzinfo=get_timezone('US/Eastern'), locale='en') u'2007.04.01 AD at 11:30:00 EDT' @@ -640,7 +640,7 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): >>> 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' + u'15:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale' >>> format_time(t, "hh 'o''clock' a, zzzz", tzinfo=get_timezone('US/Eastern'), ... locale='en') u"09 o'clock AM, Eastern Daylight Time" diff --git a/babel/numbers.py b/babel/numbers.py index 587d64070..01af774dd 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -98,7 +98,8 @@ def get_territory_currencies(territory, start_date=None, end_date=None, >>> get_territory_currencies('US') ['USD'] - >>> get_territory_currencies('US', tender=False, non_tender=True) + >>> get_territory_currencies('US', tender=False, non_tender=True, + ... start_date=date(2014, 1, 1)) ['USN', 'USS'] .. versionadded:: 2.0 From 21d6efe915a6b41630878805fcfa06e50c601d13 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 18:18:12 -0500 Subject: [PATCH 056/489] Fixed 2.6 bug (Decimal cannot convert floats) --- babel/plural.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/babel/plural.py b/babel/plural.py index 918038e61..50bf54181 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -10,6 +10,7 @@ """ import decimal import re +import sys _plural_tags = ('zero', 'one', 'two', 'few', 'many', 'other') @@ -25,7 +26,13 @@ def extract_operands(source): n = abs(source) i = int(n) if isinstance(n, float): - n = i if i == n else decimal.Decimal(n) + if i == n: + n = i + else: + # 2.6's Decimal cannot convert from float directly + if sys.version_info < (2, 7): + n = str(n) + n = decimal.Decimal(n) if isinstance(n, decimal.Decimal): dec_tuple = n.as_tuple() From 7eb5a8d1f2a1cc810dc44f171fdc1c29374e1b48 Mon Sep 17 00:00:00 2001 From: benselme Date: Sun, 11 Jan 2015 18:27:20 -0500 Subject: [PATCH 057/489] Fixed doctest failing randomly --- babel/messages/pofile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 4aaf406f9..3d7dc3283 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -98,13 +98,13 @@ def read_po(fileobj, locale=None, domain=None, ignore_obsolete=False, charset=No >>> for message in catalog: ... if message.id: ... print (message.id, message.string) - ... print ' ', (message.locations, message.flags) + ... print ' ', (message.locations, sorted(list(message.flags))) ... print ' ', (message.user_comments, message.auto_comments) (u'foo %(name)s', u'quux %(name)s') - ([(u'main.py', 1)], set([u'fuzzy', u'python-format'])) + ([(u'main.py', 1)], [u'fuzzy', u'python-format']) ([], []) ((u'bar', u'baz'), (u'bar', u'baaz')) - ([(u'main.py', 3)], set([])) + ([(u'main.py', 3)], []) ([u'A user comment'], [u'An auto comment']) .. versionadded:: 1.0 From aa4165df073239a9f6ff01c0b5f6cb430a5b9d10 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 27 Jul 2015 13:22:31 +0200 Subject: [PATCH 058/489] Fixed a bunch of broken timezone tests. --- babel/dates.py | 8 ++++---- tests/test_dates.py | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 73d54fa57..6761bcae1 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -281,17 +281,17 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): u'GMT+00:00' >>> tz = get_timezone('America/Los_Angeles') - >>> dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) + >>> dt = tz.localize(datetime(2007, 4, 1, 15, 30)) >>> get_timezone_gmt(dt, locale='en') - u'GMT-08:00' + u'GMT-07:00' >>> get_timezone_gmt(dt, 'short', locale='en') - u'-0800' + u'-0700' The long format depends on the locale, for example in France the acronym UTC string is used instead of GMT: >>> get_timezone_gmt(dt, 'long', locale='fr_FR') - u'UTC-08:00' + u'UTC-07:00' .. versionadded:: 0.9 diff --git a/tests/test_dates.py b/tests/test_dates.py index 7b897a13a..25719299a 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -177,19 +177,19 @@ def test_milliseconds_in_day_zero(self): def test_timezone_rfc822(self): tz = timezone('Europe/Berlin') - t = time(15, 30, tzinfo=tz) + t = tz.localize(datetime(2015, 1, 1, 15, 30)) 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) + t = tz.localize(datetime(2015, 1, 1, 15, 30)) fmt = dates.DateTimeFormat(t, locale='de_DE') self.assertEqual('GMT+01:00', fmt['ZZZZ']) def test_timezone_name(self): tz = timezone('Europe/Paris') - dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) + dt = tz.localize(datetime(2007, 4, 1, 15, 30)) fmt = dates.DateTimeFormat(dt, locale='fr_FR') self.assertEqual('Heure : France', fmt['v']) @@ -380,11 +380,11 @@ def test_get_timezone_gmt(): assert dates.get_timezone_gmt(dt, locale='en') == u'GMT+00:00' tz = timezone('America/Los_Angeles') - dt = datetime(2007, 4, 1, 15, 30, tzinfo=tz) - assert dates.get_timezone_gmt(dt, locale='en') == u'GMT-08:00' - assert dates.get_timezone_gmt(dt, 'short', locale='en') == u'-0800' + dt = tz.localize(datetime(2007, 4, 1, 15, 30)) + assert dates.get_timezone_gmt(dt, locale='en') == u'GMT-07:00' + assert dates.get_timezone_gmt(dt, 'short', locale='en') == u'-0700' - assert dates.get_timezone_gmt(dt, 'long', locale='fr_FR') == u'UTC-08:00' + assert dates.get_timezone_gmt(dt, 'long', locale='fr_FR') == u'UTC-07:00' def test_get_timezone_location(): From caf1a1f0fe8a395d4ba9404d7f629b7917b697de Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 27 Jul 2015 13:27:44 +0200 Subject: [PATCH 059/489] Updated changelog --- CHANGES | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 7f44c5f9a..9dc814d48 100644 --- a/CHANGES +++ b/CHANGES @@ -4,11 +4,14 @@ Babel Changelog Version 2.0 ----------- -(release date to be decided, codename to be selected) +(Released on July 27th 2015, codename Second Coming) - Added support for looking up currencies that belong to a territory through the :func:`babel.numbers.get_territory_currencies` function. +- Improved Python 3 support. +- Fixed some broken tests for timezone behavior. +- Improved various smaller things for dealing with dates. Version 1.4 ----------- From 5e9ae0996d6c502a04a338a7fa3c0c1c9024e734 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 27 Jul 2015 13:27:47 +0200 Subject: [PATCH 060/489] Bump version number to 2.0 --- babel/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/__init__.py b/babel/__init__.py index addf71aec..6854931a5 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '2.0-dev' +__version__ = '2.0' diff --git a/setup.py b/setup.py index b89a4ec4e..2c27ce40e 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='Babel', - version='2.0-dev', + version='2.0', description='Internationalization utilities', long_description=\ """A collection of tools for internationalizing Python applications.""", From 169d622246465ab4b97e9cbdcc895a55531cfe69 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 27 Jul 2015 13:28:25 +0200 Subject: [PATCH 061/489] This is 3.0-dev --- babel/__init__.py | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/__init__.py b/babel/__init__.py index 6854931a5..c977c732d 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '2.0' +__version__ = '3.0-dev' diff --git a/setup.py b/setup.py index 2c27ce40e..bc2ed02a5 100755 --- a/setup.py +++ b/setup.py @@ -32,7 +32,7 @@ def run(self): setup( name='Babel', - version='2.0', + version='3.0-dev', description='Internationalization utilities', long_description=\ """A collection of tools for internationalizing Python applications.""", From b306b8acbe95f9c47a4f3b177e9af596245d2f38 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 27 Jul 2015 13:39:24 +0200 Subject: [PATCH 062/489] Added changelog entry for 3.0 --- CHANGES | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES b/CHANGES index 9dc814d48..60fef7d7c 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,13 @@ Babel Changelog =============== +Version 3.0 +----------- + +(release date to be decided; codename to be picked) + +- Upgraded data to CLDR 26 + Version 2.0 ----------- From 073fd1381d07433d7a6b23191ec358ae97ec48f6 Mon Sep 17 00:00:00 2001 From: Erick Wilder Date: Mon, 27 Jul 2015 17:02:28 -0300 Subject: [PATCH 063/489] Force file deletion at cleaning tasks - Fresh install tests will fail if there's no file inside babel/localedata and/or babel/global.dat --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 5bb68a9a9..c0adaa10d 100644 --- a/Makefile +++ b/Makefile @@ -18,8 +18,8 @@ import-cldr: @python scripts/download_import_cldr.py clean-cldr: - @rm babel/localedata/*.dat - @rm babel/global.dat + @rm -f babel/localedata/*.dat + @rm -f babel/global.dat clean-pyc: @find . -name '*.pyc' -exec rm {} \; From 5c86c18c5d2673ef56eeb6c1f2109e46313a234d Mon Sep 17 00:00:00 2001 From: Erick Wilder Date: Mon, 27 Jul 2015 18:01:47 -0300 Subject: [PATCH 064/489] Add Python 3.4 to tox stack --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 6d4eb0341..311da6e6f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26, py27, pypy, py33 +envlist = py26, py27, pypy, py33, py34 [testenv] deps = From 1e93326e0afd3d1305bdfaf75d94ef5182e9eb93 Mon Sep 17 00:00:00 2001 From: Erick Wilder Date: Mon, 27 Jul 2015 18:21:29 -0300 Subject: [PATCH 065/489] Add Python 3.4 to travis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 94425a607..4c965dad3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,6 +5,7 @@ python: - "2.7" - "pypy" - "3.3" + - "3.4" install: - pip install --upgrade pip From 40872406281cac1fd2f832705fc5d4c2d407852a Mon Sep 17 00:00:00 2001 From: Erick Wilder Date: Mon, 27 Jul 2015 18:27:12 -0300 Subject: [PATCH 066/489] Update CHANGES with py3.4 support --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 60fef7d7c..4258e5e8f 100644 --- a/CHANGES +++ b/CHANGES @@ -7,6 +7,7 @@ Version 3.0 (release date to be decided; codename to be picked) - Upgraded data to CLDR 26 +- Add official support for Python 3.4 Version 2.0 ----------- From f51005ad508ea6880fe9b9d6966129435d634429 Mon Sep 17 00:00:00 2001 From: Jeremy Weinstein Date: Mon, 27 Jul 2015 17:45:35 -0700 Subject: [PATCH 067/489] Update locale.rst --- docs/locale.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/locale.rst b/docs/locale.rst index f30e2fe87..5e9b2487c 100644 --- a/docs/locale.rst +++ b/docs/locale.rst @@ -109,7 +109,7 @@ want. You can also ask for the information in parts: u'Alemanha' -Calender Display Names +Calendar Display Names ====================== The :class:`~babel.core.Locale` class provides access to many locale From cb3c845bea332ea6d4173ac155d2bc2ce83f2313 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 28 Jul 2015 18:07:25 +0200 Subject: [PATCH 068/489] travis: Use docker infrastructure See http://blog.travis-ci.com/2014-12-17-faster-builds-with-container-based-infrastructure/ for related documentation. --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 4c965dad3..a876247cc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,6 +12,9 @@ install: - pip install pytest - pip install --editable . +# Use travis docker infrastructure for greater speed +sudo: false + script: make test notifications: From bff595933069dc859a05a2380894148a82c14605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jes=C3=BAs=20Espino?= Date: Fri, 20 Mar 2015 12:15:11 +0100 Subject: [PATCH 069/489] Fix typo on mapping filename Now the setup.cfg example for extract_messages configuration is equivalent to the command line example. --- docs/setup.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup.rst b/docs/setup.rst index ef203e9d5..fd7ee754d 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -202,7 +202,7 @@ underscore characters instead of dashes, for example: [extract_messages] keywords = _ gettext ngettext - mapping_file = babel.cfg + mapping_file = mapping.cfg width = 80 This would be equivalent to invoking the command from the command-line as From 7d387f95f4f41ed646bcb2b4fa39dae4a601b6ce Mon Sep 17 00:00:00 2001 From: Philip_Tzou Date: Sat, 1 Aug 2015 14:03:14 -0700 Subject: [PATCH 070/489] Fixed issue #109: `ImportWarning` warned when `import babel` `localedata.py` uses the same name with folder `localedata/`, which Python will try to load `localdata/__init__.py` and warn `ImportWarning` if the file not found. If try to filter the `ImportWarning` as an error like this: ```python import warnings warnings.filterwarnings('error', category=ImportWarning) import babel ``` An `ImportWarning` exception will be raised. --- babel/localedata/.gitignore | 1 + babel/{localedata.py => localedata/__init__.py} | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) rename babel/{localedata.py => localedata/__init__.py} (94%) diff --git a/babel/localedata/.gitignore b/babel/localedata/.gitignore index 72e8ffc0d..2cbf0fc75 100644 --- a/babel/localedata/.gitignore +++ b/babel/localedata/.gitignore @@ -1 +1,2 @@ * +!__init__.py diff --git a/babel/localedata.py b/babel/localedata/__init__.py similarity index 94% rename from babel/localedata.py rename to babel/localedata/__init__.py index 88883ac80..e6df0c4d1 100644 --- a/babel/localedata.py +++ b/babel/localedata/__init__.py @@ -5,8 +5,8 @@ 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. + :note: The `Locale` class, which uses this module under the hood, provides + a more convenient interface for accessing the locale data. :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. @@ -21,7 +21,7 @@ _cache = {} _cache_lock = threading.RLock() -_dirname = os.path.join(os.path.dirname(__file__), 'localedata') +_dirname = os.path.dirname(__file__) def exists(name): @@ -187,13 +187,13 @@ def __iter__(self): def __getitem__(self, key): orig = val = self._data[key] - if isinstance(val, Alias): # resolve an alias + if isinstance(val, Alias): # resolve an alias val = val.resolve(self.base) - if isinstance(val, tuple): # Merge a partial dict with an alias + if isinstance(val, tuple): # Merge a partial dict with an alias alias, others = val val = alias.resolve(self.base).copy() merge(val, others) - if type(val) is dict: # Return a nested alias-resolving dict + if type(val) is dict: # Return a nested alias-resolving dict val = LocaleDataDict(val, base=self.base) if val is not orig: self._data[key] = val From bb75d7147a5f09e0f14b78766b8e2260bff73fe0 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 10:42:44 +0200 Subject: [PATCH 071/489] CI: Add windows builds --- .ci/appveyor.yml | 52 ++++++++++++++++++++++++++++++++++++++++++++ .ci/run_with_env.cmd | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .ci/appveyor.yml create mode 100644 .ci/run_with_env.cmd diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml new file mode 100644 index 000000000..67ca84b14 --- /dev/null +++ b/.ci/appveyor.yml @@ -0,0 +1,52 @@ +# From https://github.com/ogrisel/python-appveyor-demo/blob/master/appveyor.yml + +environment: + global: + # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the + # /E:ON and /V:ON options are not enabled in the batch script intepreter + # See: http://stackoverflow.com/a/13751649/163740 + CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\.ci\\run_with_env.cmd" + + matrix: + - PYTHON: "C:\\Python27" + PYTHON_VERSION: "2.7.x" + PYTHON_ARCH: "32" + + - PYTHON: "C:\\Python27-x64" + PYTHON_VERSION: "2.7.x" + PYTHON_ARCH: "64" + + - PYTHON: "C:\\Python33" + PYTHON_VERSION: "3.3.x" + PYTHON_ARCH: "32" + + - PYTHON: "C:\\Python33-x64" + PYTHON_VERSION: "3.3.x" + PYTHON_ARCH: "64" + + - PYTHON: "C:\\Python34" + PYTHON_VERSION: "3.4.x" + PYTHON_ARCH: "32" + + - PYTHON: "C:\\Python34-x64" + PYTHON_VERSION: "3.4.x" + PYTHON_ARCH: "64" + +branches: # Only build official branches, PRs are built anyway. + only: + - master + - /release.*/ + +install: + - "SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%" + # Check that we have the expected version and architecture for Python + - "python --version" + - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" + # Build data files + - "pip install . pytest" + - "python setup.py import_cldr" + +build: false # Not a C# project, build stuff at the test step instead. + +test_script: + - "%CMD_IN_ENV% python -m pytest" diff --git a/.ci/run_with_env.cmd b/.ci/run_with_env.cmd new file mode 100644 index 000000000..3a472bc83 --- /dev/null +++ b/.ci/run_with_env.cmd @@ -0,0 +1,47 @@ +:: To build extensions for 64 bit Python 3, we need to configure environment +:: variables to use the MSVC 2010 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 4 (SDK v7.1) +:: +:: To build extensions for 64 bit Python 2, we need to configure environment +:: variables to use the MSVC 2008 C++ compilers from GRMSDKX_EN_DVD.iso of: +:: MS Windows SDK for Windows 7 and .NET Framework 3.5 (SDK v7.0) +:: +:: 32 bit builds do not require specific environment configurations. +:: +:: Note: this script needs to be run with the /E:ON and /V:ON flags for the +:: cmd interpreter, at least for (SDK v7.0) +:: +:: More details at: +:: https://github.com/cython/cython/wiki/64BitCythonExtensionsOnWindows +:: http://stackoverflow.com/a/13751649/163740 +:: +:: Author: Olivier Grisel +:: License: CC0 1.0 Universal: http://creativecommons.org/publicdomain/zero/1.0/ +@ECHO OFF + +SET COMMAND_TO_RUN=%* +SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows + +SET MAJOR_PYTHON_VERSION="%PYTHON_VERSION:~0,1%" +IF %MAJOR_PYTHON_VERSION% == "2" ( + SET WINDOWS_SDK_VERSION="v7.0" +) ELSE IF %MAJOR_PYTHON_VERSION% == "3" ( + SET WINDOWS_SDK_VERSION="v7.1" +) ELSE ( + ECHO Unsupported Python version: "%MAJOR_PYTHON_VERSION%" + EXIT 1 +) + +IF "%PYTHON_ARCH%"=="64" ( + ECHO Configuring Windows SDK %WINDOWS_SDK_VERSION% for Python %MAJOR_PYTHON_VERSION% on a 64 bit architecture + SET DISTUTILS_USE_SDK=1 + SET MSSdk=1 + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% + "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) ELSE ( + ECHO Using default MSVC build environment for 32 bit architecture + ECHO Executing: %COMMAND_TO_RUN% + call %COMMAND_TO_RUN% || EXIT 1 +) From d333ae7e085a718d4460cd6629ee2bbf51c5f45c Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 11:02:14 +0200 Subject: [PATCH 072/489] travis: Submit coverage to codecov --- .travis.yml | 6 ++++-- Makefile | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index a876247cc..cda41362d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,13 +9,15 @@ python: install: - pip install --upgrade pip - - pip install pytest + - pip install pytest pytest-cov - pip install --editable . # Use travis docker infrastructure for greater speed sudo: false -script: make test +script: + - make test-cov + - bash <(curl -s https://codecov.io/bash) notifications: email: false diff --git a/Makefile b/Makefile index c0adaa10d..ea4d75f91 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ test: import-cldr @PYTHONWARNINGS=default py.test +test-cov: import-cldr + @PYTHONWARNINGS=default py.test --cov=babel + test-env: @virtualenv test-env @test-env/bin/pip install pytest From 6f6ed17f9920bf513147c8f3218b331395017fc3 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 13:08:26 +0200 Subject: [PATCH 073/489] gitignore: Add temporary editor files .idea stores settings for PyCharm, ~ files are temporary files e.g. used by gedit. .swp is used by vim. We don't want any of those to be accidentally committed in the repo. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7effb2fcc..0006e76c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +*~ +*.swp +.idea *.so docs/_build *.pyc From 9e3b5441ed7a68a7a5b553351b6baf6c5f20f13d Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 13:02:30 +0200 Subject: [PATCH 074/489] Makefile: Use platform independent pytest invocation --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index ea4d75f91..12dbb5eb3 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ test: import-cldr - @PYTHONWARNINGS=default py.test + @PYTHONWARNINGS=default python -m pytest test-cov: import-cldr - @PYTHONWARNINGS=default py.test --cov=babel + @PYTHONWARNINGS=default python -m pytest --cov=babel test-env: @virtualenv test-env From 1caf790121bae3e0698c06004cd4c6916f32a398 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 11:46:04 +0200 Subject: [PATCH 075/489] CI: Add mac builds --- .ci/deploy.linux.sh | 4 ++++ .ci/deploy.osx.sh | 4 ++++ .ci/deps.linux.sh | 4 ++++ .ci/deps.osx.sh | 11 +++++++++ .travis.yml | 55 +++++++++++++++++++++++++++++++++++++++------ 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 .ci/deploy.linux.sh create mode 100644 .ci/deploy.osx.sh create mode 100644 .ci/deps.linux.sh create mode 100644 .ci/deps.osx.sh diff --git a/.ci/deploy.linux.sh b/.ci/deploy.linux.sh new file mode 100644 index 000000000..4d59382d7 --- /dev/null +++ b/.ci/deploy.linux.sh @@ -0,0 +1,4 @@ +set -x +set -e + +bash <(curl -s https://codecov.io/bash) diff --git a/.ci/deploy.osx.sh b/.ci/deploy.osx.sh new file mode 100644 index 000000000..c44550eff --- /dev/null +++ b/.ci/deploy.osx.sh @@ -0,0 +1,4 @@ +set -x +set -e + +echo "Due to a bug in codecov, coverage cannot be deployed for Mac builds." diff --git a/.ci/deps.linux.sh b/.ci/deps.linux.sh new file mode 100644 index 000000000..13cc9e1ef --- /dev/null +++ b/.ci/deps.linux.sh @@ -0,0 +1,4 @@ +set -x +set -e + +echo "No dependencies to install for linux." diff --git a/.ci/deps.osx.sh b/.ci/deps.osx.sh new file mode 100644 index 000000000..b52a84f6d --- /dev/null +++ b/.ci/deps.osx.sh @@ -0,0 +1,11 @@ +set -e +set -x + +# Install packages with brew +brew update >/dev/null +brew outdated pyenv || brew upgrade --quiet pyenv + +# Install required python version for this build +pyenv install -ks $PYTHON_VERSION +pyenv global $PYTHON_VERSION +python --version diff --git a/.travis.yml b/.travis.yml index cda41362d..95b1695f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,13 +1,54 @@ language: python -python: - - "2.6" - - "2.7" - - "pypy" - - "3.3" - - "3.4" +sudo: false + +cache: false + +matrix: + include: + - os: linux + python: 2.6 + - os: linux + python: 2.7 + - os: linux + python: pypy + - os: linux + python: 3.3 + - os: linux + python: 3.4 + - os: osx + language: generic + env: + - PYTHON_VERSION=2.6.6 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=2.7.10 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=pypy-2.6.0 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=3.3.6 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=3.4.3 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin install: + - bash .ci/deps.${TRAVIS_OS_NAME}.sh - pip install --upgrade pip - pip install pytest pytest-cov - pip install --editable . @@ -17,7 +58,7 @@ sudo: false script: - make test-cov - - bash <(curl -s https://codecov.io/bash) + - bash .ci/deploy.${TRAVIS_OS_NAME}.sh notifications: email: false From 79500992b4bc99fe1e6fdb122fa545943831a423 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 13:01:34 +0200 Subject: [PATCH 076/489] travis: Cache cldr This should speed linux builds up a bit. Caching is not supported for mac builds though. --- .travis.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 95b1695f2..6ba4fbd97 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,9 @@ language: python sudo: false -cache: false +cache: + directories: + - cldr matrix: include: From 819eaa9103fbc0e47ce9337c9ea566b3dbab0b72 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 4 Aug 2015 14:43:20 +0200 Subject: [PATCH 077/489] Added dev docs --- docs/dev.rst | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 79 insertions(+) create mode 100644 docs/dev.rst diff --git a/docs/dev.rst b/docs/dev.rst new file mode 100644 index 000000000..b1fde576c --- /dev/null +++ b/docs/dev.rst @@ -0,0 +1,78 @@ +Babel Development +================= + +Babel as a library has a long history that goes back to the Trac project. +Since then it has evolved into a independently developed project that +implements data access for the CLDR project. + +This document tries to explain as best as possible the general rules of +the project in case you want to help out developing. + +Tracking the CLDR +----------------- + +Generally the goal of the project is to work as closely as possible with +the CLDR data. This has in the past caused some frustrating problems +because the data is entirely out of our hand. To minimize the frustration +we generally deal with CLDR updates the following way: + +* bump the CLDR data only with a major release of Babel. +* never perform custom bugfixes on the CLDR data. +* never work around CLDR bugs within Babel. If you find a problem in + the data, report it upstream. +* adjust the parsing of the data as soon as possible, otherwise this + will spiral out of control later. This is especially the case for + bigger updates that change pluralization and more. +* try not to test against specific CLDR data that is likely to change. + +Python Versions +--------------- + +At the moment the following Python versions should be supported: + +* Python 2.6 +* Python 2.7 +* Python 3.3 and up +* PyPy tracking 2.7 and 3.2 and up + +While PyPy does not currently support 3.3, it does support traditional +unicode literals which simplifies the entire situation tremendously. + +Documentation must build on Python 2, Python 3 support for the +documentation is an optional goal. Code examples in the docs preferrably +are written in a style that makes them work on both 2.x and 3.x with +preference to the former. + +Unicode +------- + +Unicode is a big deal in Babel. Here is how the rules are set up: + +* internally everything is unicode that makes sense to have as unicode. + The exception to this rule are things which on Python 2 traditionally + have been bytes. For example file names on Python 2 should be treated + as bytes wherever possible. +* Encode / decode at boundaries explicitly. Never assume an encoding in + a way it cannot be overridden. utf-8 should be generally considered + the default encoding. +* Dot not use ``unicode_literals``, instead use the ``u''`` string + syntax. The reason for this is that the former introduces countless + of unicode problems by accidentally upgrading strings to unicode which + should not be. (docstrings for instance). + +Dates and Timezones +------------------- + +Generally all timezone support in Babel is based on pytz which it just +depends on. Babel should assume that timezone objects are pytz based +because those are the only ones with an API that actually work correctly +(due to the API problems with non UTC based timezones). + +Assumptions to make: + +* use UTC where possible. +* be super careful with local time. Do not use local time without + knowing the exact timezone. +* `time` without date is a very useless construct. Do not try to + support timezones for it. If you do, assume that the current local + date is assumed and not utc date. diff --git a/docs/index.rst b/docs/index.rst index 861384d62..082174133 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,5 +42,6 @@ Additional Notes .. toctree:: :maxdepth: 2 + dev changelog license From 40d640f6d414446f906359f93d207413eb311bbc Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 4 Aug 2015 16:32:16 +0200 Subject: [PATCH 078/489] Add rultor configuration This allows merging via github comments. The comment `@rultor merge` will execute the script (which currently doesn't do anything) and if it succeeds will perform the merge. I plan to use rultor later for: * Automatic deployment to PyPI (development releases directly from master, we do this already in coala, see https://github.com/coala-analyzer/coala/blob/master/.rultor.yml) * Automatic releasing with deployment to PyPI. * Veryfy that all CI services pass before merging (see https://github.com/yegor256/rultor/issues/869) With this any manual pushes to master are disallowed, all pushes to master have to be validated by continuous integration and reviewed by a non-committer. --- .rultor.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .rultor.yml diff --git a/.rultor.yml b/.rultor.yml new file mode 100644 index 000000000..087129698 --- /dev/null +++ b/.rultor.yml @@ -0,0 +1,2 @@ +merge: + script: echo "Nothing to do (yet)." From 3ce842bb9a25d180a436d443b2376b7162161c22 Mon Sep 17 00:00:00 2001 From: Felix Yan Date: Wed, 26 Mar 2014 14:43:14 +0000 Subject: [PATCH 079/489] Support 'Language' header field of PO files (#76) GNU gettext has support for the 'Language' field in header entry since version 0.18 (May 2010). This commit adds support for the field and addresses #76. --- babel/messages/catalog.py | 3 +++ tests/messages/test_catalog.py | 1 + tests/messages/test_frontend.py | 9 +++++++++ 3 files changed, 13 insertions(+) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 67c542591..12e878348 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -374,6 +374,8 @@ def _get_mime_headers(self): else: headers.append(('PO-Revision-Date', self.revision_date)) headers.append(('Last-Translator', self.last_translator)) + if self.locale is not None: + headers.append(('Language', str(self.locale))) if (self.locale is not None) and ('LANGUAGE' in self.language_team): headers.append(('Language-Team', self.language_team.replace('LANGUAGE', @@ -457,6 +459,7 @@ def _set_mime_headers(self, headers): POT-Creation-Date: 1990-04-01 15:30+0000 PO-Revision-Date: 1990-08-03 12:00+0000 Last-Translator: John Doe + Language: de_DE Language-Team: de_DE Plural-Forms: nplurals=2; plural=(n != 1) MIME-Version: 1.0 diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index aac71eeac..31bb1d140 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -380,6 +380,7 @@ def test_catalog_mime_headers_set_locale(): ('POT-Creation-Date', '1990-04-01 15:30+0000'), ('PO-Revision-Date', '1990-08-03 12:00+0000'), ('Last-Translator', 'John Doe '), + ('Language', 'de_DE'), ('Language-Team', 'de_DE '), ('Plural-Forms', 'nplurals=2; plural=(n != 1)'), ('MIME-Version', '1.0'), diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 882cb00d3..4d26df50e 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -359,6 +359,7 @@ def test_with_output_dir(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: en_US\n" "Language-Team: en_US \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" @@ -409,6 +410,7 @@ def test_keeps_catalog_non_fuzzy(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: en_US\n" "Language-Team: en_US \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" @@ -459,6 +461,7 @@ def test_correct_init_more_than_2_plurals(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: lv_LV\n" "Language-Team: lv_LV \n" "Plural-Forms: nplurals=3; plural=(n%%10==1 && n%%100!=11 ? 0 : n != 0 ? 1 :" " 2)\n" @@ -511,6 +514,7 @@ def test_correct_init_singular_plural_forms(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: ja_JP\n" "Language-Team: ja_JP \n" "Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" @@ -568,6 +572,7 @@ def test_supports_no_wrap(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: en_US\n" "Language-Team: en_US \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" @@ -626,6 +631,7 @@ def test_supports_width(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: en_US\n" "Language-Team: en_US \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" @@ -884,6 +890,7 @@ def test_init_with_output_dir(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: en_US\n" "Language-Team: en_US \n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" "MIME-Version: 1.0\n" @@ -934,6 +941,7 @@ def test_init_singular_plural_forms(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: ja_JP\n" "Language-Team: ja_JP \n" "Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" @@ -980,6 +988,7 @@ def test_init_more_than_2_plural_forms(self): "POT-Creation-Date: 2007-04-01 15:30+0200\n" "PO-Revision-Date: %(date)s\n" "Last-Translator: FULL NAME \n" +"Language: lv_LV\n" "Language-Team: lv_LV \n" "Plural-Forms: nplurals=3; plural=(n%%10==1 && n%%100!=11 ? 0 : n != 0 ? 1 :" " 2)\n" From 504aafe5395061c6a2c9fbbb92bc6646a379633f Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Wed, 5 Nov 2014 16:40:04 +0100 Subject: [PATCH 080/489] Import parent locale exceptions Process and save the element, which contains the inheritance exceptions to the standard CLDR locale inheritance algorithm. --- scripts/import_cldr.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 7dcd171ca..576370140 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -145,6 +145,7 @@ def main(): variant_aliases = global_data.setdefault('variant_aliases', {}) likely_subtags = global_data.setdefault('likely_subtags', {}) territory_currencies = global_data.setdefault('territory_currencies', {}) + parent_exceptions = global_data.setdefault('parent_exceptions', {}) # create auxiliary zone->territory map from the windows zones (we don't set # the 'zones_territories' map directly here, because there are some zones @@ -223,6 +224,12 @@ def main(): region_currencies.sort(key=_currency_sort_key) territory_currencies[region_code] = region_currencies + # Explicit parent locales + for paternity in sup.findall('.//parentLocales/parentLocale'): + parent = paternity.attrib['parent'] + for child in paternity.attrib['locales'].split(): + parent_exceptions[child] = parent + outfile = open(global_path, 'wb') try: pickle.dump(global_data, outfile, 2) From 3ef0d6daaff4e9e2b60706e27034c9043b725483 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Wed, 5 Nov 2014 17:15:08 +0100 Subject: [PATCH 081/489] localedata: Check inheritance exceptions first When deriving the parent locale from the given name, look first in the inheritance exception list. This will cover cases like "es_MX", which parent is "es_419" and not "es". Fixes https://github.com/mitsuhiko/babel/issues/97 --- babel/localedata/__init__.py | 13 ++++++++----- babel/numbers.py | 2 +- tests/test_numbers.py | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/babel/localedata/__init__.py b/babel/localedata/__init__.py index e6df0c4d1..c48b9f586 100644 --- a/babel/localedata/__init__.py +++ b/babel/localedata/__init__.py @@ -81,11 +81,14 @@ def load(name, merge_inherited=True): if name == 'root' or not merge_inherited: data = {} else: - parts = name.split('_') - if len(parts) == 1: - parent = 'root' - else: - parent = '_'.join(parts[:-1]) + from babel.core import get_global + parent = get_global('parent_exceptions').get(name) + if not parent: + 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') diff --git a/babel/numbers.py b/babel/numbers.py index 01af774dd..d1f647252 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -258,7 +258,7 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC): >>> format_currency(1099.98, 'USD', locale='en_US') u'$1,099.98' >>> format_currency(1099.98, 'USD', locale='es_CO') - u'1.099,98\\xa0US$' + u'US$1.099,98' >>> format_currency(1099.98, 'EUR', locale='de_DE') u'1.099,98\\xa0\\u20ac' diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 02332fbc7..6fe67c04e 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -243,7 +243,7 @@ def test_format_currency(): assert (numbers.format_currency(1099.98, 'USD', locale='en_US') == u'$1,099.98') assert (numbers.format_currency(1099.98, 'USD', locale='es_CO') - == u'1.099,98\xa0US$') + == u'US$1.099,98') assert (numbers.format_currency(1099.98, 'EUR', locale='de_DE') == u'1.099,98\xa0\u20ac') assert (numbers.format_currency(1099.98, 'EUR', u'\xa4\xa4 #,##0.00', From 94f68302796149898fe99c14edc2a5519baad5e8 Mon Sep 17 00:00:00 2001 From: Jun Omae Date: Tue, 4 Aug 2015 20:15:25 +0900 Subject: [PATCH 082/489] Removed uses of datetime.date class from *.dat files (#174) To avoid incompatible *.dat files between Python 2 and 3. --- babel/numbers.py | 4 ++++ scripts/import_cldr.py | 6 ++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 01af774dd..35705b59f 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -135,6 +135,10 @@ def _is_active(start, end): result = [] for currency_code, start, end, is_tender in curs: + if start: + start = date_(*start) + if end: + end = date_(*end) if ((is_tender and tender) or \ (not is_tender and non_tender)) and _is_active(start, end): if include_details: diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 7dcd171ca..1b0092345 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -21,8 +21,6 @@ except ImportError: from xml.etree import ElementTree -from datetime import date - # Make sure we're using Babel source, and not some previously installed version sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..')) @@ -103,12 +101,12 @@ def _parse_currency_date(s): if not s: return None parts = s.split('-', 2) - return date(*map(int, parts + [1] * (3 - len(parts)))) + return tuple(map(int, parts + [1] * (3 - len(parts)))) def _currency_sort_key(tup): code, start, end, tender = tup - return int(not tender), start or date(1, 1, 1) + return int(not tender), start or (1, 1, 1) def main(): From 6acf7ae1c95156d2adb956da77a8ec51b7e064da Mon Sep 17 00:00:00 2001 From: Jun Omae Date: Wed, 5 Aug 2015 07:21:34 +0900 Subject: [PATCH 083/489] Added unit tests for that *.dat files have only babel classes --- tests/test_core.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index 7e16765fc..a4253d806 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -269,3 +269,29 @@ def test_parse_locale(): assert core.parse_locale('en_US.UTF-8') == ('en', 'US', None, None) assert (core.parse_locale('de_DE.iso885915@euro') == ('de', 'DE', None, None)) + +def test_compatible_classes_in_global_and_localedata(): + # Use pickle module rather than cPickle since cPickle.Unpickler is a method + # on Python 2 + import pickle + + class Unpickler(pickle.Unpickler): + def find_class(self, module, name): + # *.dat files must have compatible classes between Python 2 and 3 + if module.split('.')[0] == 'babel': + return pickle.Unpickler.find_class(self, module, name) + raise pickle.UnpicklingError("global '%s.%s' is forbidden" % + (module, name)) + + def load(filename): + with open(filename, 'rb') as f: + return Unpickler(f).load() + + load('babel/global.dat') + load('babel/localedata/root.dat') + load('babel/localedata/en.dat') + load('babel/localedata/en_US.dat') + load('babel/localedata/en_US_POSIX.dat') + load('babel/localedata/zh_Hans_CN.dat') + load('babel/localedata/zh_Hant_TW.dat') + load('babel/localedata/es_419.dat') From a1cc3f1ca8149cddd97bd6823857cc9e3eba544e Mon Sep 17 00:00:00 2001 From: astaric Date: Thu, 12 Jun 2014 09:33:36 +0200 Subject: [PATCH 084/489] Add __copy__ and __deepcopy__ to LazyProxy. Python's copy.copy and copy.deepcopy do not call objects __init__, resulting in endless recursion. --- babel/support.py | 17 +++++++++++++++++ tests/test_support.py | 27 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/babel/support.py b/babel/support.py index c720c747c..5ab97a5b2 100644 --- a/babel/support.py +++ b/babel/support.py @@ -263,6 +263,23 @@ def __getitem__(self, key): def __setitem__(self, key, value): self.value[key] = value + def __copy__(self): + return LazyProxy( + self._func, + enable_cache=self._is_cache_enabled, + *self._args, + **self._kwargs + ) + + def __deepcopy__(self, memo): + from copy import deepcopy + return LazyProxy( + deepcopy(self._func, memo), + enable_cache=deepcopy(self._is_cache_enabled, memo), + *deepcopy(self._args, memo), + **deepcopy(self._kwargs, memo) + ) + class NullTranslations(gettext.NullTranslations, object): diff --git a/tests/test_support.py b/tests/test_support.py index 8c182fc77..4647f6b13 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -243,6 +243,33 @@ def add_one(): self.assertEqual(1, proxy.value) self.assertEqual(2, proxy.value) + def test_can_copy_proxy(self): + from copy import copy + + numbers = [1,2] + def first(xs): + return xs[0] + + proxy = support.LazyProxy(first, numbers) + proxy_copy = copy(proxy) + + numbers.pop(0) + self.assertEqual(2, proxy.value) + self.assertEqual(2, proxy_copy.value) + + def test_can_deepcopy_proxy(self): + from copy import deepcopy + numbers = [1,2] + def first(xs): + return xs[0] + + proxy = support.LazyProxy(first, numbers) + proxy_deepcopy = deepcopy(proxy) + + numbers.pop(0) + self.assertEqual(2, proxy.value) + self.assertEqual(1, proxy_deepcopy.value) + def test_format_date(): fmt = support.Format('en_US') From 87c0456afa984be383fa2d1b640315bab7645990 Mon Sep 17 00:00:00 2001 From: Julen Ruiz Aizpuru Date: Tue, 27 May 2014 10:40:39 +0200 Subject: [PATCH 085/489] Docs: minor fixes --- docs/dates.rst | 4 ++-- docs/locale.rst | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/dates.rst b/docs/dates.rst index f03c21ace..818a3941b 100644 --- a/docs/dates.rst +++ b/docs/dates.rst @@ -50,8 +50,8 @@ Core Time Concepts Working with dates and time can be a complicated thing. Babel attempts to simplify working with them by making some decisions for you. Python's -datetime module knows to different ways to deal with times and dates: -naive and timezone aware datetime objects. +datetime module has different ways to deal with times and dates: naive and +timezone-aware datetime objects. Babel generally recommends you to store all your time in naive datetime objects and treat them as UTC at all times. This simplifies dealing with diff --git a/docs/locale.rst b/docs/locale.rst index 5e9b2487c..cf4f6d5c5 100644 --- a/docs/locale.rst +++ b/docs/locale.rst @@ -80,9 +80,9 @@ Locale Display Names ==================== Locales itself can be used to describe the locale itself or other locales. -This mainly means that given a locale object you can ask it for it's +This mainly means that given a locale object you can ask it for its canonical display name, the name of the language and other things. Since -the locales cross reference each other you can ask for locale names in any +the locales cross-reference each other you can ask for locale names in any language supported by the CLDR: .. code-block:: pycon @@ -113,7 +113,7 @@ Calendar Display Names ====================== The :class:`~babel.core.Locale` class provides access to many locale -display names related to calendar display, such as the names of week days +display names related to calendar display, such as the names of weekdays or months. These display names are of course used for date formatting, but can also be From da87edd1e5ff71ff7d072318b664f2940b12e3f2 Mon Sep 17 00:00:00 2001 From: Leonardo Pistone Date: Fri, 7 Aug 2015 16:43:41 +0200 Subject: [PATCH 086/489] allow utf8 BOM + magic comment, closes #189 --- babel/util.py | 8 +++++--- tests/messages/test_extract.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/babel/util.py b/babel/util.py index a65fce36e..dd7378725 100644 --- a/babel/util.py +++ b/babel/util.py @@ -77,9 +77,11 @@ def parse_encoding(fp): if has_bom: if m: - raise SyntaxError( - "python refuses to compile code with both a UTF8 " - "byte-order-mark and a magic encoding comment") + magic_comment_encoding = m.group(1).decode('latin-1') + if magic_comment_encoding != 'utf-8': + raise SyntaxError( + 'encoding problem: {0} with BOM'.format( + magic_comment_encoding)) return 'utf-8' elif m: return m.group(1).decode('latin-1') diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index 62c722773..ded697f7f 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -343,6 +343,23 @@ def test_utf8_message_with_utf8_bom(self): self.assertEqual(u'Bonjour à tous', messages[0][2]) self.assertEqual([u'NOTE: hello'], messages[0][3]) + def test_utf8_message_with_utf8_bom_and_magic_comment(self): + buf = BytesIO(codecs.BOM_UTF8 + u"""# -*- coding: utf-8 -*- +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + messages = list(extract.extract_python(buf, ('_',), ['NOTE:'], {})) + self.assertEqual(u'Bonjour à tous', messages[0][2]) + self.assertEqual([u'NOTE: hello'], messages[0][3]) + + def test_utf8_bom_with_latin_magic_comment_fails(self): + buf = BytesIO(codecs.BOM_UTF8 + u"""# -*- coding: latin-1 -*- +# NOTE: hello +msg = _('Bonjour à tous') +""".encode('utf-8')) + self.assertRaises(SyntaxError, list, + extract.extract_python(buf, ('_',), ['NOTE:'], {})) + def test_utf8_raw_strings_match_unicode_strings(self): buf = BytesIO(codecs.BOM_UTF8 + u""" msg = _('Bonjour à tous') From 424ea406381c775aff5abdd89c444b503c547d87 Mon Sep 17 00:00:00 2001 From: Leonardo Pistone Date: Tue, 25 Aug 2015 17:34:16 +0200 Subject: [PATCH 087/489] update dead link to the Python Language Reference --- babel/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/util.py b/babel/util.py index dd7378725..3bfebc91a 100644 --- a/babel/util.py +++ b/babel/util.py @@ -46,7 +46,7 @@ def parse_encoding(fp): It does this in the same way as the `Python interpreter`__ - .. __: http://docs.python.org/ref/encodings.html + .. __: https://docs.python.org/3.4/reference/lexical_analysis.html#encoding-declarations The ``fp`` argument should be a seekable file object. From 6822b7fa7d12be1db8433a157345045dadebe5eb Mon Sep 17 00:00:00 2001 From: Arturas Moskvinas Date: Fri, 7 Aug 2015 19:17:31 +0300 Subject: [PATCH 088/489] Improve odict performance by making key search O(1) --- babel/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/babel/util.py b/babel/util.py index a65fce36e..8d88fc537 100644 --- a/babel/util.py +++ b/babel/util.py @@ -172,8 +172,9 @@ def __delitem__(self, key): self._keys.remove(key) def __setitem__(self, key, item): + new_key = key not in self dict.__setitem__(self, key, item) - if key not in self._keys: + if new_key: self._keys.append(key) def __iter__(self): From 3c4e8ee15c5abc040eb94f2cca7cee09e9331795 Mon Sep 17 00:00:00 2001 From: Arturas Moskvinas Date: Wed, 26 Aug 2015 09:11:47 +0300 Subject: [PATCH 089/489] Add change --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 4258e5e8f..4dbe07cf7 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,8 @@ Version 3.0 - Upgraded data to CLDR 26 - Add official support for Python 3.4 +- Improved odict performance which is used during localization file + build, should improve compilation time for large projects Version 2.0 ----------- From 0a9e97e3ae4ffb7431406a5c7166e337573dfa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Behmo?= Date: Wed, 12 Nov 2014 18:09:34 +0100 Subject: [PATCH 090/489] odict: Fix pop method The items() and iteritems() methods did not contain correct values after a call to `pop(i)`. Fixes https://github.com/mitsuhiko/babel/issues/196 --- babel/util.py | 15 +++++++++------ tests/test_util.py | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/babel/util.py b/babel/util.py index a65fce36e..a428bca37 100644 --- a/babel/util.py +++ b/babel/util.py @@ -199,12 +199,15 @@ def keys(self): return self._keys[:] def pop(self, key, default=missing): - if default is missing: - return dict.pop(self, key) - elif key not in self: - return default - self._keys.remove(key) - return dict.pop(self, key, default) + try: + value = dict.pop(self, key) + self._keys.remove(key) + return value + except KeyError as e: + if default == missing: + raise e + else: + return default def popitem(self, key): self._keys.remove(key) diff --git a/tests/test_util.py b/tests/test_util.py index 321014c90..6ec73dc52 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,8 +11,6 @@ # 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 @@ -28,3 +26,16 @@ def test_pathmatch(): assert not util.pathmatch('**.py', 'templates/index.html') assert util.pathmatch('**/templates/*.html', 'templates/index.html') assert not util.pathmatch('**/templates/*.html', 'templates/foo/bar.html') + +def test_odict_pop(): + odict = util.odict() + odict[0] = 1 + value = odict.pop(0) + assert 1 == value + assert [] == list(odict.items()) + assert odict.pop(2, None) is None + try: + odict.pop(2) + assert False + except KeyError: + assert True From c17ff4108ec51c0944f0c8c0f88fc56af2f15978 Mon Sep 17 00:00:00 2001 From: The Gitter Badger Date: Mon, 7 Sep 2015 21:50:44 +0000 Subject: [PATCH 091/489] Added Gitter link --- README | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README b/README index 8e783e1d0..56b36a902 100644 --- a/README +++ b/README @@ -10,3 +10,5 @@ Details can be found in the HTML files in the `docs` folder. For more information please visit the Babel web site: + +Join the chat at https://gitter.im/mitsuhiko/babel From edc5eb57b2f7c36bb419be4d23396746d233767b Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Thu, 10 Sep 2015 01:41:15 +0100 Subject: [PATCH 092/489] Add format_timedelta(format='narrow') support --- babel/dates.py | 13 ++++++++++--- tests/test_dates.py | 8 ++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 388da6d25..f9498d80a 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -739,6 +739,13 @@ def format_timedelta(delta, granularity='second', threshold=.85, >>> format_timedelta(timedelta(hours=-1), add_direction=True, locale='en') u'1 hour ago' + The format parameter controls how compact or wide the presentation is: + + >>> format_timedelta(timedelta(hours=3), format='short', locale='en') + u'3 hrs' + >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en') + u'3h' + :param delta: a ``timedelta`` object representing the time difference to format, or the delta in seconds as an `int` value :param granularity: determines the smallest unit that should be displayed, @@ -751,13 +758,13 @@ def format_timedelta(delta, granularity='second', threshold=.85, 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 "long" and "short" are supported, + :param format: the format, can be "narrow", "short" or "long". ( "medium" is deprecated, currently converted to "long" to maintain compatibility) :param locale: a `Locale` object or a locale identifier """ - if format not in ('short', 'medium', 'long'): - raise TypeError('Format can only be one of "short" or "medium"') + if format not in ('narrow', 'short', 'medium', 'long'): + raise TypeError('Format must be one of "narrow", "short" or "long"') if format == 'medium': warnings.warn('"medium" value for format param of format_timedelta' ' is deprecated. Use "long" instead', diff --git a/tests/test_dates.py b/tests/test_dates.py index 1f6e7e7dd..34b8a89a2 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -310,6 +310,14 @@ def test_direction_adding(self): add_direction=True) self.assertEqual('1 hour ago', string) + def test_format_narrow(self): + string = dates.format_timedelta(timedelta(hours=1), + locale='en', format='narrow') + self.assertEqual('1h', string) + string = dates.format_timedelta(timedelta(hours=-2), + locale='en', format='narrow') + self.assertEqual('2h', string) + class TimeZoneAdjustTestCase(unittest.TestCase): def _utc(self): From bdeaf59aaac98a0ac00a616cddf0474efd4a31b2 Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Thu, 10 Sep 2015 13:54:22 +0100 Subject: [PATCH 093/489] Test invalid format_timedelta() format parameters --- tests/test_dates.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_dates.py b/tests/test_dates.py index 34b8a89a2..1b03cbf00 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -318,6 +318,14 @@ def test_format_narrow(self): locale='en', format='narrow') self.assertEqual('2h', string) + def test_format_invalid(self): + self.assertRaises(TypeError, dates.format_timedelta, + timedelta(hours=1), format='') + self.assertRaises(TypeError, dates.format_timedelta, + timedelta(hours=1), format='bold italic') + self.assertRaises(TypeError, dates.format_timedelta, + timedelta(hours=1), format=None) + class TimeZoneAdjustTestCase(unittest.TestCase): def _utc(self): From d9b20136c8314c56c7f7c26cc93d3eebcdfae29e Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Thu, 10 Sep 2015 19:31:16 +0100 Subject: [PATCH 094/489] Remove unneeded relpath() polyfill It looks like this dates back all the way to 2007, and the initial import of the code in commit 00f16f9. Since Python 2.6 is the minimum supported version, and that has os.path.relpath this is no longer needed. --- babel/util.py | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/babel/util.py b/babel/util.py index c36621418..b5a3eabda 100644 --- a/babel/util.py +++ b/babel/util.py @@ -12,6 +12,7 @@ import codecs from datetime import timedelta, tzinfo import os +from os.path import relpath import re import textwrap from babel._compat import izip, imap @@ -232,29 +233,6 @@ def itervalues(self): return imap(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' - """ - 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) - - class FixedOffsetTimezone(tzinfo): """Fixed offset in minutes east from UTC.""" From cb496dd9d1aafd28869a100f1a20a405b8b5ffca Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Thu, 10 Sep 2015 20:54:59 +0200 Subject: [PATCH 095/489] CHANGES: Add "narrow" support for format_timedelta This was introduced in https://github.com/moreati/babel/commit/edc5eb57b2f7c36bb419be4d23396746d233767b and forgotten to add to CHANGES. --- CHANGES | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGES b/CHANGES index 4dbe07cf7..16151b77d 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,7 @@ Version 3.0 - Add official support for Python 3.4 - Improved odict performance which is used during localization file build, should improve compilation time for large projects +- Add support for "narrow" format for format_timedelta Version 2.0 ----------- From d816803400444d116ec285c38d6367d08a17b374 Mon Sep 17 00:00:00 2001 From: Alex Willmer Date: Thu, 10 Sep 2015 20:25:45 +0100 Subject: [PATCH 096/489] FixedOffsetTimezone: fix display of negative offsets --- babel/util.py | 2 +- tests/test_util.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/babel/util.py b/babel/util.py index c36621418..495e94312 100644 --- a/babel/util.py +++ b/babel/util.py @@ -261,7 +261,7 @@ class FixedOffsetTimezone(tzinfo): def __init__(self, offset, name=None): self._offset = timedelta(minutes=offset) if name is None: - name = 'Etc/GMT+%d' % offset + name = 'Etc/GMT%+d' % offset self.zone = name def __str__(self): diff --git a/tests/test_util.py b/tests/test_util.py index 6ec73dc52..bb2bfdb4b 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -11,6 +11,7 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at http://babel.edgewall.org/log/. +import unittest from babel import util @@ -39,3 +40,15 @@ def test_odict_pop(): assert False except KeyError: assert True + + +class FixedOffsetTimezoneTestCase(unittest.TestCase): + def test_zone_negative_offset(self): + self.assertEqual('Etc/GMT-60', util.FixedOffsetTimezone(-60).zone) + + def test_zone_zero_offset(self): + self.assertEqual('Etc/GMT+0', util.FixedOffsetTimezone(0).zone) + + def test_zone_positive_offset(self): + self.assertEqual('Etc/GMT+330', util.FixedOffsetTimezone(330).zone) + From e0bdf686d8b895fe997cb154a9a1b27cdf0cb6c1 Mon Sep 17 00:00:00 2001 From: Ryan J Ollos Date: Wed, 16 Sep 2015 20:51:50 -0700 Subject: [PATCH 097/489] Remove duplicate `sudo: false` entry. --- .travis.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6ba4fbd97..c7a79b1be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,6 @@ language: python +# Use travis docker infrastructure for greater speed sudo: false cache: @@ -55,9 +56,6 @@ install: - pip install pytest pytest-cov - pip install --editable . -# Use travis docker infrastructure for greater speed -sudo: false - script: - make test-cov - bash .ci/deploy.${TRAVIS_OS_NAME}.sh From fb084c1be1731805f2bc8cd00a86b176cfa6f906 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Mon, 21 Sep 2015 13:47:09 +0200 Subject: [PATCH 098/489] docs: Change repository adress The repository moved. --- README | 2 +- docs/_templates/sidebar-links.html | 4 ++-- docs/conf.py | 2 +- docs/installation.rst | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README b/README index 56b36a902..f329291b3 100644 --- a/README +++ b/README @@ -11,4 +11,4 @@ For more information please visit the Babel web site: -Join the chat at https://gitter.im/mitsuhiko/babel +Join the chat at https://gitter.im/python-babel/babel diff --git a/docs/_templates/sidebar-links.html b/docs/_templates/sidebar-links.html index 856aa1bab..a55b2dd96 100644 --- a/docs/_templates/sidebar-links.html +++ b/docs/_templates/sidebar-links.html @@ -10,6 +10,6 @@

    Useful Links

    diff --git a/docs/conf.py b/docs/conf.py index c84ebaef4..ed75a531f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -257,6 +257,6 @@ } extlinks = { - 'gh': ('https://github.com/mitsuhiko/babel/issues/%s', '#'), + 'gh': ('https://github.com/python-babel/babel/issues/%s', '#'), 'trac': ('http://babel.edgewall.org/ticket/%s', 'ticket #'), } diff --git a/docs/installation.rst b/docs/installation.rst index 07f0306d9..0aea3abfe 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -80,7 +80,7 @@ use a git checkout. Get the git checkout in a new virtualenv and run in development mode:: - $ git clone http://github.com/mitsuhiko/babel.git + $ git clone http://github.com/python-babel/babel.git Initialized empty Git repository in ~/dev/babel/.git/ $ cd babel $ virtualenv venv From 6a9f60f98a593014837dbb0625c85e219d842be7 Mon Sep 17 00:00:00 2001 From: Ryan J Ollos Date: Mon, 21 Sep 2015 10:33:47 -0700 Subject: [PATCH 099/489] Enforce Python version in `setup.py` Print error message and exit if Python version requirement not satisfied. --- setup.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bc2ed02a5..fa77fcc45 100755 --- a/setup.py +++ b/setup.py @@ -1,10 +1,16 @@ # -*- coding: utf-8 -*- -import os import sys +if sys.version_info < (2, 6) or (3,) <= sys.version_info < (3, 3): + print("Babel requires Python 2.6, 2.7 or 3.3+") + sys.exit(1) + + +import os import subprocess from setuptools import setup + sys.path.append(os.path.join('doc', 'common')) try: from doctools import build_doc, test_doc From 5ab419202016167252d14e5bd5ae47c8f5916d33 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Mon, 21 Sep 2015 19:24:10 +0200 Subject: [PATCH 100/489] setup.cfg: Use universal wheel --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index ffe7c8f94..61207e66d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,3 +6,6 @@ release = egg_info -RDb '' [pytest] norecursedirs = .* _* scripts {args} + +[bdist_wheel] +universal = 1 From d1a6f1a3f2e4e0d11643782f36ed1259337dfcfb Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Thu, 6 Aug 2015 08:10:44 +0200 Subject: [PATCH 101/489] rultor: Linearize commit history on merge This will make rultor rebase a PR and then fastforwarding the master branch if a merge action is requested. --- .rultor.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.rultor.yml b/.rultor.yml index 087129698..9f313a88f 100644 --- a/.rultor.yml +++ b/.rultor.yml @@ -1,2 +1,4 @@ merge: + fast-forward: only + rebase: true script: echo "Nothing to do (yet)." From e0cd108ddc56fbc95f28a3d76e55df21ddee34f7 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Thu, 6 Aug 2015 08:16:21 +0200 Subject: [PATCH 102/489] rultor: Run tests on merge This will revalidate a PR at least naively (i.e. only for one platform and python version) right before doing the actual merge and thus may prevent breaking master. --- .rultor.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.rultor.yml b/.rultor.yml index 9f313a88f..d2b7106dd 100644 --- a/.rultor.yml +++ b/.rultor.yml @@ -1,4 +1,14 @@ +install: + - pip install pytest + +docker: + as_root: true # for pip installation + image: "coala/rultor-python" + merge: fast-forward: only rebase: true - script: echo "Nothing to do (yet)." + script: + - pip install . + - python setup.py import_cldr + - py.test From 1174bff582e4a8c41bec10b7114aa202a32c6b48 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 22 Sep 2015 23:21:20 +0200 Subject: [PATCH 103/489] Add CONTRIBUTING This finally fixes our review process and gives users a bug template. --- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..2fa0a6584 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Babel Contribution Guidelines + +Welcome to Babel! These guidelines will give you a short overview over how we +handle issues and PRs in this repository. Note that they are preliminary and +still need proper phrasing - if you'd like to help - be sure to make a PR. + +Please know that we do appreciate all contributions - bug reports as well as +Pull Requests. + +## Filing Issues + +When filing an issue, please use this template: + +``` +# Overview Description + +# Steps to Reproduce + +1. +2. +3. + +# Actual Results + +# Expected Results + +# Reproducibility + +# Additional Information: + +``` + +## PR Merge Criteria + +For a PR to be merged, the following statements must hold true: + +- All CI services pass. (Windows build, linux build, sufficient test coverage.) +- All commits must have been reviewed and approved by a babel maintainer who is + not the author of the PR. Commits shall comply to the "Good Commits" standards + outlined below. + +## Correcting PRs + +Rebasing PRs is preferred over merging master into the source branches again +and again cluttering our history. If a reviewer has suggestions, the commit +shall be amended so the history is not cluttered by "fixup commits". + +## Writing Good Commits + +Please see +http://coala.readthedocs.org/en/latest/Getting_Involved/Writing_Good_Commits/ +for guidelines on how to write good commits and proper commit messages. From b6d10186309e6c6bee6292c93c2245d30c9ff989 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Thu, 24 Sep 2015 16:05:14 +0200 Subject: [PATCH 104/489] rultor: Remove test execution Something's wrong here and we do need to debug that first. So in order for us being able to do rultor merges, let's just not execute tests for now. --- .rultor.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.rultor.yml b/.rultor.yml index d2b7106dd..6267e49ac 100644 --- a/.rultor.yml +++ b/.rultor.yml @@ -9,6 +9,4 @@ merge: fast-forward: only rebase: true script: - - pip install . - - python setup.py import_cldr - - py.test + - echo "Nothing to do (yet!)" From 88c978c33c3510e7624d786aabc95dac20ad714f Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Fri, 25 Sep 2015 14:54:47 +0200 Subject: [PATCH 105/489] CHANGES: Update changes from last release --- CHANGES | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 16151b77d..d28b0f715 100644 --- a/CHANGES +++ b/CHANGES @@ -8,9 +8,31 @@ Version 3.0 - Upgraded data to CLDR 26 - Add official support for Python 3.4 + +Version 2.2 +----------- + +(Bugfix/minor feature release, to be released.) + +- TODO + +Version 2.1 +----------- + +(Bugfix/minor feature release, released on September 25th 2015) + +- Fix Locale.parse using ``global.dat`` incompatible types + (https://github.com/python-babel/babel/issues/174) +- Fix display of negative offsets in ``FixedOffsetTimezone`` + (https://github.com/python-babel/babel/issues/214) - Improved odict performance which is used during localization file build, should improve compilation time for large projects -- Add support for "narrow" format for format_timedelta +- Add support for "narrow" format for ``format_timedelta`` +- Add universal wheel support +- Support 'Language' header field in .PO files + (fixes https://github.com/python-babel/babel/issues/76) +- Test suite enhancements (coverage, broken tests fixed, etc) +- Documentation updated Version 2.0 ----------- From d36d495ca8ff740fcc69aaf4fc9cadac59ce9c91 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Fri, 25 Sep 2015 14:48:12 +0200 Subject: [PATCH 106/489] setup.cfg: Update release alias We'll want to do a source distribution and a wheel when the release is triggered. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 61207e66d..40a113522 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,7 @@ tag_date = true [aliases] -release = egg_info -RDb '' +release = sdist bdist_wheel [pytest] norecursedirs = .* _* scripts {args} From 68e0720e3cd4696c76f0f5f38ad86bc96b445ab9 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Fri, 25 Sep 2015 14:49:09 +0200 Subject: [PATCH 107/489] setup: Use version from babel package DRY --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index fa77fcc45..ed46467a3 100755 --- a/setup.py +++ b/setup.py @@ -10,6 +10,7 @@ import subprocess from setuptools import setup +from babel import __version__ sys.path.append(os.path.join('doc', 'common')) try: @@ -38,7 +39,7 @@ def run(self): setup( name='Babel', - version='3.0-dev', + version=__version__, description='Internationalization utilities', long_description=\ """A collection of tools for internationalizing Python applications.""", From 43638b27a0a2abf71d636c8c16c1ce0b3c010cc4 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Fri, 25 Sep 2015 19:15:15 +0200 Subject: [PATCH 108/489] babel: Change version to 2.2.0.dev0 We will from now on use the maintenance branches only for bugfix only releases (i.e. micro versions) to avoid all this backporting. Because of this, what we develop on master will be the 2.2 version. --- babel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/__init__.py b/babel/__init__.py index c977c732d..d2e52599c 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '3.0-dev' +__version__ = '2.2.0.dev0' From 665212002e923500c7d40efc845024ff7a039d19 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Fri, 25 Sep 2015 19:16:48 +0200 Subject: [PATCH 109/489] setup.cfg: Remove date tagging This would tag every release with the date which is not what we want for the production releases. We can use dev0 for local releases and generate a version number including a date for development releases we will introduce later. --- setup.cfg | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 40a113522..38a441db4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,3 @@ -[egg_info] -tag_date = true - [aliases] release = sdist bdist_wheel From d4a1a20de73069ba305820a4cbf934d684d505ca Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 22 Sep 2015 23:36:20 +0200 Subject: [PATCH 110/489] Revert "Fixed issue #109: `ImportWarning` warned when `import babel`" This reverts commit 7d387f95f4f41ed646bcb2b4fa39dae4a601b6ce. Another fix will be applied to get rid of #109 because of https://github.com/python-babel/babel/issues/240 . Fixes https://github.com/python-babel/babel/issues/240 --- babel/{localedata/__init__.py => localedata.py} | 12 ++++++------ babel/localedata/.gitignore | 1 - 2 files changed, 6 insertions(+), 7 deletions(-) rename babel/{localedata/__init__.py => localedata.py} (94%) diff --git a/babel/localedata/__init__.py b/babel/localedata.py similarity index 94% rename from babel/localedata/__init__.py rename to babel/localedata.py index c48b9f586..1d3af9bf8 100644 --- a/babel/localedata/__init__.py +++ b/babel/localedata.py @@ -5,8 +5,8 @@ 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. + :note: The `Locale` class, which uses this module under the hood, provides a + more convenient interface for accessing the locale data. :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. @@ -21,7 +21,7 @@ _cache = {} _cache_lock = threading.RLock() -_dirname = os.path.dirname(__file__) +_dirname = os.path.join(os.path.dirname(__file__), 'localedata') def exists(name): @@ -190,13 +190,13 @@ def __iter__(self): def __getitem__(self, key): orig = val = self._data[key] - if isinstance(val, Alias): # resolve an alias + if isinstance(val, Alias): # resolve an alias val = val.resolve(self.base) - if isinstance(val, tuple): # Merge a partial dict with an alias + if isinstance(val, tuple): # Merge a partial dict with an alias alias, others = val val = alias.resolve(self.base).copy() merge(val, others) - if type(val) is dict: # Return a nested alias-resolving dict + if type(val) is dict: # Return a nested alias-resolving dict val = LocaleDataDict(val, base=self.base) if val is not orig: self._data[key] = val diff --git a/babel/localedata/.gitignore b/babel/localedata/.gitignore index 2cbf0fc75..72e8ffc0d 100644 --- a/babel/localedata/.gitignore +++ b/babel/localedata/.gitignore @@ -1,2 +1 @@ * -!__init__.py From 2d1882edeef621f3bacbfa6af2d7b5a5d34a81f1 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Tue, 22 Sep 2015 23:46:08 +0200 Subject: [PATCH 111/489] localedata: Rename to locale-data To fix the ImportError because of the name clash with localedata.py. locale-data is no valid python identifier and thus a nice indicator that this directory actually contains data. --- MANIFEST.in | 2 +- Makefile | 2 +- babel/{localedata => locale-data}/.gitignore | 0 babel/localedata.py | 2 +- scripts/import_cldr.py | 2 +- setup.py | 2 +- tests/test_core.py | 14 +++++++------- 7 files changed, 12 insertions(+), 12 deletions(-) rename babel/{localedata => locale-data}/.gitignore (100%) diff --git a/MANIFEST.in b/MANIFEST.in index a53a51f14..6c1e7aff4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,7 @@ include Makefile CHANGES LICENSE AUTHORS include conftest.py tox.ini include babel/global.dat -include babel/localedata/*.dat +include babel/locale-data/*.dat recursive-include docs * recursive-exclude docs/_build * include scripts/* diff --git a/Makefile b/Makefile index 12dbb5eb3..74b9a673d 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ import-cldr: @python scripts/download_import_cldr.py clean-cldr: - @rm -f babel/localedata/*.dat + @rm -f babel/locale-data/*.dat @rm -f babel/global.dat clean-pyc: diff --git a/babel/localedata/.gitignore b/babel/locale-data/.gitignore similarity index 100% rename from babel/localedata/.gitignore rename to babel/locale-data/.gitignore diff --git a/babel/localedata.py b/babel/localedata.py index 1d3af9bf8..79b4d8639 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -21,7 +21,7 @@ _cache = {} _cache_lock = threading.RLock() -_dirname = os.path.join(os.path.dirname(__file__), 'localedata') +_dirname = os.path.join(os.path.dirname(__file__), 'locale-data') def exists(name): diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 789924c20..85132c7e1 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -271,7 +271,7 @@ def main(): continue full_filename = os.path.join(srcdir, 'main', filename) - data_filename = os.path.join(destdir, 'localedata', stem + '.dat') + data_filename = os.path.join(destdir, 'locale-data', stem + '.dat') data = {} if not need_conversion(data_filename, data, full_filename): diff --git a/setup.py b/setup.py index ed46467a3..4b8db0270 100755 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ def run(self): 'Topic :: Software Development :: Libraries :: Python Modules', ], packages=['babel', 'babel.messages', 'babel.localtime'], - package_data={'babel': ['global.dat', 'localedata/*.dat']}, + package_data={'babel': ['global.dat', 'locale-data/*.dat']}, install_requires=[ # This version identifier is currently necessary as # pytz otherwise does not install on pip 1.4 or diff --git a/tests/test_core.py b/tests/test_core.py index a4253d806..a643b36a9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -288,10 +288,10 @@ def load(filename): return Unpickler(f).load() load('babel/global.dat') - load('babel/localedata/root.dat') - load('babel/localedata/en.dat') - load('babel/localedata/en_US.dat') - load('babel/localedata/en_US_POSIX.dat') - load('babel/localedata/zh_Hans_CN.dat') - load('babel/localedata/zh_Hant_TW.dat') - load('babel/localedata/es_419.dat') + load('babel/locale-data/root.dat') + load('babel/locale-data/en.dat') + load('babel/locale-data/en_US.dat') + load('babel/locale-data/en_US_POSIX.dat') + load('babel/locale-data/zh_Hans_CN.dat') + load('babel/locale-data/zh_Hant_TW.dat') + load('babel/locale-data/es_419.dat') From b88e2308972034562e084e06835ad80a8ded83d5 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sat, 26 Sep 2015 18:38:42 +0200 Subject: [PATCH 112/489] CHANGES: Make 2.2 next version We recently changed branching and this was missing from 43638b27a0a2abf71d636c8c16c1ce0b3c010cc4 --- CHANGES | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index d28b0f715..61875654b 100644 --- a/CHANGES +++ b/CHANGES @@ -1,21 +1,14 @@ Babel Changelog =============== -Version 3.0 +Version 2.2 ----------- -(release date to be decided; codename to be picked) +(Feature release, release date to be decided) - Upgraded data to CLDR 26 - Add official support for Python 3.4 -Version 2.2 ------------ - -(Bugfix/minor feature release, to be released.) - -- TODO - Version 2.1 ----------- From 4be8d50bbc21b4eba228b2f1e8706681c9b4354e Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sat, 26 Sep 2015 21:35:06 +0200 Subject: [PATCH 113/489] CHANGES: Add an older change that was skipped When merging https://github.com/python-babel/babel/pull/180, the CHANGES file modification was not performed. --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 61875654b..79723f7e6 100644 --- a/CHANGES +++ b/CHANGES @@ -14,6 +14,8 @@ Version 2.1 (Bugfix/minor feature release, released on September 25th 2015) +- Parse and honour the locale inheritance exceptions + (https://github.com/python-babel/babel/issues/97) - Fix Locale.parse using ``global.dat`` incompatible types (https://github.com/python-babel/babel/issues/174) - Fix display of negative offsets in ``FixedOffsetTimezone`` From a7e80a22c77371069e3d06b70520d9576e69db3d Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Mon, 17 Nov 2014 17:51:49 +0100 Subject: [PATCH 114/489] Import currency fraction and rounding information Process and save the element of the supplemental data, which indicates how many decimal places should be displayed for each currency. --- scripts/import_cldr.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 85132c7e1..59dfeac6d 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -144,6 +144,7 @@ def main(): likely_subtags = global_data.setdefault('likely_subtags', {}) territory_currencies = global_data.setdefault('territory_currencies', {}) parent_exceptions = global_data.setdefault('parent_exceptions', {}) + currency_fractions = global_data.setdefault('currency_fractions', {}) # create auxiliary zone->territory map from the windows zones (we don't set # the 'zones_territories' map directly here, because there are some zones @@ -228,6 +229,15 @@ def main(): for child in paternity.attrib['locales'].split(): parent_exceptions[child] = parent + # Currency decimal and rounding digits + for fraction in sup.findall('.//currencyData/fractions/info'): + cur_code = fraction.attrib['iso4217'] + cur_digits = int(fraction.attrib['digits']) + cur_rounding = int(fraction.attrib['rounding']) + cur_cdigits = int(fraction.attrib.get('cashDigits', cur_digits)) + cur_crounding = int(fraction.attrib.get('cashRounding', cur_rounding)) + currency_fractions[cur_code] = (cur_digits, cur_rounding, cur_cdigits, cur_crounding) + outfile = open(global_path, 'wb') try: pickle.dump(global_data, outfile, 2) From f7673904a17cbbd4a661a00642602d6b5bb597a7 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Mon, 17 Nov 2014 18:42:33 +0100 Subject: [PATCH 115/489] numbers: New parameter to override precision The NumberFormat class uses the amount of decimal digits specified by the given format. We want to be able to ignore the amount of decimal digits under some special circumstances. Care must be taken since the NumberFormat instances seem to be cached. --- babel/numbers.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 0841d2c56..2117ce938 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -602,7 +602,8 @@ def __init__(self, pattern, prefix, suffix, grouping, def __repr__(self): return '<%s %r>' % (type(self).__name__, self.pattern) - def apply(self, value, locale, currency=None): + def apply(self, value, locale, currency=None, force_frac=None): + frac_prec = force_frac or self.frac_prec if isinstance(value, float): value = Decimal(str(value)) value *= self.scale @@ -632,8 +633,7 @@ def apply(self, value, locale, currency=None): exp_sign = get_plus_sign_symbol(locale) exp = abs(exp) number = u'%s%s%s%s' % \ - (self._format_sigdig(value, self.frac_prec[0], - self.frac_prec[1]), + (self._format_sigdig(value, frac_prec[0], frac_prec[1]), get_exponential_symbol(locale), exp_sign, self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale)) @@ -650,12 +650,11 @@ def apply(self, value, locale, currency=None): else: number = self._format_int(text, 0, 1000, locale) else: # A normal number pattern - a, b = split_number(bankersround(abs(value), - self.frac_prec[1])) + a, b = split_number(bankersround(abs(value), frac_prec[1])) b = b or '0' a = self._format_int(a, self.int_prec[0], self.int_prec[1], locale) - b = self._format_frac(b, locale) + b = self._format_frac(b, locale, force_frac) number = a + b retval = u'%s%s%s' % (self.prefix[is_negative], number, self.suffix[is_negative]) @@ -705,8 +704,8 @@ def _format_int(self, value, min, max, locale): gsize = self.grouping[1] return value + ret - def _format_frac(self, value, locale): - min, max = self.frac_prec + def _format_frac(self, value, locale, force_frac=None): + min, max = force_frac or self.frac_prec if len(value) < min: value += ('0' * (min - len(value))) if max == 0 or (min == 0 and int(value) == 0): From 201ed50bc0d8a7baf332eb0596d3c2ac944b7b43 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Mon, 17 Nov 2014 18:24:47 +0100 Subject: [PATCH 116/489] numbers: Use currency decimal digits by default When formatting a price, the number of decimals to use should be defined by the currency. For example, JPY do not use decimal numbers at all. However, we still allow the user to override the currency decimal digits setting with the one from the number format. Fixes https://github.com/mitsuhiko/babel/issues/139 --- babel/numbers.py | 30 ++++++++++++++++++++++++++++-- tests/test_numbers.py | 11 +++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 2117ce938..19a376bea 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -256,7 +256,7 @@ def format_decimal(number, format=None, locale=LC_NUMERIC): return pattern.apply(number, locale) -def format_currency(number, currency, format=None, locale=LC_NUMERIC): +def format_currency(number, currency, format=None, locale=LC_NUMERIC, currency_digits=True): u"""Return formatted currency value. >>> format_currency(1099.98, 'USD', locale='en_US') @@ -276,15 +276,41 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC): >>> format_currency(1099.98, 'EUR', u'#,##0.00 \xa4\xa4\xa4', locale='en_US') u'1,099.98 euros' + Currencies usually have a specific number of decimal digits. This function + favours that information over the given format: + + >>> format_currency(1099.98, 'JPY', locale='en_US') + u'\\xa51,100' + >>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES') + u'1.100' + + However, the number of decimal digits can be overriden from the currency + information, by setting the last parameter to ``True``: + + >>> format_currency(1099.98, 'JPY', locale='en_US', currency_digits=False) + u'\\xa51,099.98' + >>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES', currency_digits=False) + u'1.099,98' + :param number: the number to format :param currency: the currency code :param locale: the `Locale` object or locale identifier + :param currency_digits: use the currency's number of decimal digits """ locale = Locale.parse(locale) if not format: format = locale.currency_formats.get(format) pattern = parse_pattern(format) - return pattern.apply(number, locale, currency=currency) + if currency_digits: + fractions = get_global('currency_fractions') + try: + digits = fractions[currency][0] + except KeyError: + digits = fractions['DEFAULT'][0] + frac = (digits, digits) + else: + frac = None + return pattern.apply(number, locale, currency=currency, force_frac=frac) def format_percent(number, format=None, locale=LC_NUMERIC): diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 6fe67c04e..741b75378 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -252,6 +252,17 @@ def test_format_currency(): assert (numbers.format_currency(1099.98, 'EUR', locale='nl_NL') != numbers.format_currency(-1099.98, 'EUR', locale='nl_NL')) + assert (numbers.format_currency(1099.98, 'JPY', locale='en_US') + == u'\xa51,100') + assert (numbers.format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES') + == u'1.100') + assert (numbers.format_currency(1099.98, 'JPY', locale='en_US', + currency_digits=False) + == u'\xa51,099.98') + assert (numbers.format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES', + currency_digits=False) + == u'1.099,98') + def test_format_percent(): assert numbers.format_percent(0.34, locale='en_US') == u'34%' From 50fe9c91555a2afa88a13ece617d1e697182501a Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Fri, 28 Aug 2015 21:21:01 +0200 Subject: [PATCH 117/489] Update CHANGES --- CHANGES | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGES b/CHANGES index 79723f7e6..87394d48a 100644 --- a/CHANGES +++ b/CHANGES @@ -8,6 +8,8 @@ Version 2.2 - Upgraded data to CLDR 26 - Add official support for Python 3.4 +- Use the CLDR recommended amount of decimal digits when formatting + currencies (https://github.com/python-babel/babel/issues/139) Version 2.1 ----------- From df676ab8dbeabb48e2a60ff8da74fc3bee21a979 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 16 Aug 2015 11:27:03 +0200 Subject: [PATCH 118/489] numbers: Properly load and expose currency format types The type of the currency format (e.g. "standard", "accounting") was not interpreted correctly from the CLDR data. Now there should not be any currency format identified by "None". Fixes https://github.com/mitsuhiko/babel/issues/201 --- babel/core.py | 4 +++- babel/numbers.py | 6 ++---- scripts/import_cldr.py | 15 +++++++++++---- tests/test_core.py | 4 +++- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/babel/core.py b/babel/core.py index 712585856..43d3bc323 100644 --- a/babel/core.py +++ b/babel/core.py @@ -544,7 +544,9 @@ def decimal_formats(self): def currency_formats(self): """Locale patterns for currency number formatting. - >>> print Locale('en', 'US').currency_formats[None] + >>> print Locale('en', 'US').currency_formats['standard'] + + >>> print Locale('en', 'US').currency_formats['accounting'] """ return self._data['currency_formats'] diff --git a/babel/numbers.py b/babel/numbers.py index 19a376bea..672fd226b 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -256,7 +256,7 @@ def format_decimal(number, format=None, locale=LC_NUMERIC): return pattern.apply(number, locale) -def format_currency(number, currency, format=None, locale=LC_NUMERIC, currency_digits=True): +def format_currency(number, currency, format='standard', locale=LC_NUMERIC, currency_digits=True): u"""Return formatted currency value. >>> format_currency(1099.98, 'USD', locale='en_US') @@ -298,9 +298,7 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC, currency_d :param currency_digits: use the currency's number of decimal digits """ locale = Locale.parse(locale) - if not format: - format = locale.currency_formats.get(format) - pattern = parse_pattern(format) + pattern = parse_pattern(locale.currency_formats.get(format, format)) if currency_digits: fractions = get_global('currency_fractions') try: diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 59dfeac6d..b56d07789 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -586,13 +586,20 @@ def main(): numbers.parse_pattern(pattern) currency_formats = data.setdefault('currency_formats', {}) - for elem in tree.findall('.//currencyFormats/currencyFormatLength'): + for elem in tree.findall('.//currencyFormats/currencyFormatLength/currencyFormat'): if ('draft' in elem.attrib or 'alt' in elem.attrib) \ and elem.attrib.get('type') in currency_formats: continue - pattern = text_type(elem.findtext('currencyFormat/pattern')) - currency_formats[elem.attrib.get('type')] = \ - numbers.parse_pattern(pattern) + for child in elem.getiterator(): + if child.tag == 'alias': + currency_formats[elem.attrib.get('type')] = Alias( + _translate_alias(['currency_formats', elem.attrib['type']], + child.attrib['path']) + ) + elif child.tag == 'pattern': + pattern = text_type(child.text) + currency_formats[elem.attrib.get('type')] = \ + numbers.parse_pattern(pattern) percent_formats = data.setdefault('percent_formats', {}) for elem in tree.findall('.//percentFormats/percentFormatLength'): diff --git a/tests/test_core.py b/tests/test_core.py index a643b36a9..9d9548194 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -157,7 +157,9 @@ def test_decimal_formats(self): assert Locale('en', 'US').decimal_formats[None].pattern == '#,##0.###' def test_currency_formats_property(self): - assert (Locale('en', 'US').currency_formats[None].pattern == + assert (Locale('en', 'US').currency_formats['standard'].pattern == + u'\xa4#,##0.00') + assert (Locale('en', 'US').currency_formats['accounting'].pattern == u'\xa4#,##0.00') def test_percent_formats_property(self): From 15ad946567a35132912329a35225176a8e34d525 Mon Sep 17 00:00:00 2001 From: Craig Loftus Date: Mon, 17 Aug 2015 10:34:59 +0100 Subject: [PATCH 119/489] Retain the behaviour of format in numbers.format_currency Previous commit introduced an API change by changing the behaviour of the format param, instead this commit adds a format_type param and the documentation and tests to accompany it. Note that currency_formats returns a NumberPattern already, so there is no need to call parse_pattern on the value returned. --- babel/numbers.py | 34 +++++++++++++++++++++++++++++++--- tests/test_numbers.py | 18 ++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index 672fd226b..bd2e99030 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -256,7 +256,12 @@ def format_decimal(number, format=None, locale=LC_NUMERIC): return pattern.apply(number, locale) -def format_currency(number, currency, format='standard', locale=LC_NUMERIC, currency_digits=True): +class UnknownCurrencyFormatError(KeyError): + """Exception raised when an unknown currency format is requested.""" + + +def format_currency(number, currency, format=None, locale=LC_NUMERIC, + currency_digits=True, format_type='standard'): u"""Return formatted currency value. >>> format_currency(1099.98, 'USD', locale='en_US') @@ -266,7 +271,7 @@ def format_currency(number, currency, format='standard', locale=LC_NUMERIC, curr >>> format_currency(1099.98, 'EUR', locale='de_DE') u'1.099,98\\xa0\\u20ac' - The pattern can also be specified explicitly. The currency is + The format 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): @@ -292,13 +297,36 @@ def format_currency(number, currency, format='standard', locale=LC_NUMERIC, curr >>> format_currency(1099.98, 'COP', u'#,##0.00', locale='es_ES', currency_digits=False) u'1.099,98' + If a format is not specified the type of currency format to use + from the locale can be specified: + + >>> format_currency(1099.98, 'EUR', locale='en_US', format_type='standard') + u'\\u20ac1,099.98' + + When the given currency format type is not available, an exception is + raised: + + >>> format_currency('1099.98', 'EUR', locale='root', format_type='unknown') + Traceback (most recent call last): + ... + UnknownCurrencyFormatError: "'unknown' is not a known currency format type" + :param number: the number to format :param currency: the currency code + :param format: the format string to use :param locale: the `Locale` object or locale identifier :param currency_digits: use the currency's number of decimal digits + :param format_type: the currency format type to use """ locale = Locale.parse(locale) - pattern = parse_pattern(locale.currency_formats.get(format, format)) + if format: + pattern = parse_pattern(format) + else: + try: + pattern = locale.currency_formats[format_type] + except KeyError: + raise UnknownCurrencyFormatError("%r is not a known currency format" + " type" % format_type) if currency_digits: fractions = get_global('currency_fractions') try: diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 741b75378..a773f48f8 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -251,6 +251,24 @@ def test_format_currency(): == u'EUR 1,099.98') assert (numbers.format_currency(1099.98, 'EUR', locale='nl_NL') != numbers.format_currency(-1099.98, 'EUR', locale='nl_NL')) + assert (numbers.format_currency(1099.98, 'USD', format=None, + locale='en_US') + == u'$1,099.98') + + +def test_format_currency_format_type(): + assert (numbers.format_currency(1099.98, 'USD', locale='en_US', + format_type="standard") + == u'$1,099.98') + + assert (numbers.format_currency(1099.98, 'USD', locale='en_US', + format_type="accounting") + == u'$1,099.98') + + with pytest.raises(numbers.UnknownCurrencyFormatError) as excinfo: + numbers.format_currency(1099.98, 'USD', locale='en_US', + format_type='unknown') + assert excinfo.value.args[0] == "'unknown' is not a known currency format type" assert (numbers.format_currency(1099.98, 'JPY', locale='en_US') == u'\xa51,100') From f1195832ffde046c94565b6a47b39312fbca2a6e Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Fri, 28 Aug 2015 20:50:52 +0200 Subject: [PATCH 120/489] Update CHANGES and AUTHORS information --- AUTHORS | 2 ++ CHANGES | 2 ++ 2 files changed, 4 insertions(+) diff --git a/AUTHORS b/AUTHORS index 09d0bc03b..b9208fe59 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,6 +18,8 @@ Contributors: - Nick Retallack - Thomas Waldmann - Lennart Regebro +- Isaac Jurado +- Craig Loftus Babel was previously developed under the Copyright of Edgewall Software. The following copyright notice holds true for releases before 2013: "Copyright (c) diff --git a/CHANGES b/CHANGES index 87394d48a..77c426c2c 100644 --- a/CHANGES +++ b/CHANGES @@ -10,6 +10,8 @@ Version 2.2 - Add official support for Python 3.4 - Use the CLDR recommended amount of decimal digits when formatting currencies (https://github.com/python-babel/babel/issues/139) +- Properly load and use currency format types + (https://github.com/python-babel/babel/issues/201) Version 2.1 ----------- From 0e4af920834722c249f06c7f4b9f897250cdbbe2 Mon Sep 17 00:00:00 2001 From: Kevin Deldycke Date: Mon, 28 Sep 2015 12:31:26 +0200 Subject: [PATCH 121/489] numbers: Sync docstring with actual examples. --- babel/numbers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/numbers.py b/babel/numbers.py index bd2e99030..af9413f57 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -290,7 +290,7 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC, u'1.100' However, the number of decimal digits can be overriden from the currency - information, by setting the last parameter to ``True``: + information, by setting the last parameter to ``False``: >>> format_currency(1099.98, 'JPY', locale='en_US', currency_digits=False) u'\\xa51,099.98' From 37ce4faed19243a7c2a0b7554d3138ae06980c6a Mon Sep 17 00:00:00 2001 From: Michael Birtwell Date: Wed, 30 Sep 2015 19:01:30 +0100 Subject: [PATCH 122/489] Add list_patterns to Locale --- babel/core.py | 13 +++++++++++++ scripts/import_cldr.py | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/babel/core.py b/babel/core.py index 43d3bc323..7d2425714 100644 --- a/babel/core.py +++ b/babel/core.py @@ -743,6 +743,19 @@ def plural_form(self): """ return self._data.get('plural_form', _default_plural_rule) + @property + def list_patterns(self): + """Patterns for generating lists + + >>> Locale('en').list_patterns['start'] + u'{0}, {1}' + >>> Locale('en').list_patterns['end'] + u'{0}, and {1}' + >>> Locale('en_GB').list_patterns['end'] + u'{0} and {1}' + """ + return self._data['list_patterns'] + def default_locale(category=None, aliases=LOCALE_ALIASES): """Returns the system default locale for a given category, based on diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index b56d07789..68bf454b6 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -343,6 +343,13 @@ def main(): continue scripts[elem.attrib['type']] = _text(elem) + list_patterns = data.setdefault('list_patterns', {}) + for listType in tree.findall('.//listPatterns/listPattern'): + if 'type' in listType.attrib: + continue + for listPattern in listType.findall('listPatternPart'): + list_patterns[listPattern.attrib['type']] = _text(listPattern) + # week_data = data.setdefault('week_data', {}) From 6e495cd58364976ac67ffc3e2368298818b95132 Mon Sep 17 00:00:00 2001 From: Joseph Breihan Date: Mon, 5 Oct 2015 01:17:32 -0400 Subject: [PATCH 123/489] documentation: Correct timezone in example. Update example in Date and Time documentation to reflect metazone translation. Fixes https://github.com/python-babel/babel/issues/108 --- docs/dates.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dates.rst b/docs/dates.rst index 818a3941b..f35ee15f7 100644 --- a/docs/dates.rst +++ b/docs/dates.rst @@ -352,7 +352,7 @@ functions in the ``babel.dates`` module, most importantly the >>> tz = get_timezone('Europe/Berlin') >>> get_timezone_name(tz, locale=Locale.parse('pt_PT')) - u'Hor\xe1rio Alemanha' + u'Hora da Europa Central' You can pass the function either a ``datetime.tzinfo`` object, or a ``datetime.date`` or ``datetime.datetime`` object. If you pass an actual date, From 6ec5679c6d647243b3cefe15af23486c573544a3 Mon Sep 17 00:00:00 2001 From: "Todd M. Guerra" Date: Wed, 30 Sep 2015 16:11:06 -0400 Subject: [PATCH 124/489] setup: change to using include_package_data Instead of manually writing includes to various package data, we now just set include_package_data to True to make it more efficient. Reference: http://pythonhosted.org/setuptools/setuptools.html#new-and-changed-setup-keywords Fixes https://github.com/python-babel/babel/issues/260 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4b8db0270..83cd65170 100755 --- a/setup.py +++ b/setup.py @@ -62,7 +62,7 @@ def run(self): 'Topic :: Software Development :: Libraries :: Python Modules', ], packages=['babel', 'babel.messages', 'babel.localtime'], - package_data={'babel': ['global.dat', 'locale-data/*.dat']}, + include_package_data=True, install_requires=[ # This version identifier is currently necessary as # pytz otherwise does not install on pip 1.4 or From be6e23d1bd14d74ded228fab6f640fab51970fce Mon Sep 17 00:00:00 2001 From: Michael Birtwell Date: Thu, 1 Oct 2015 14:38:58 +0100 Subject: [PATCH 125/489] Add format_list function --- babel/lists.py | 48 +++++++++++++++++++++++++++++++++++++++++++++ tests/test_lists.py | 14 +++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 babel/lists.py create mode 100644 tests/test_lists.py diff --git a/babel/lists.py b/babel/lists.py new file mode 100644 index 000000000..6e6fbd192 --- /dev/null +++ b/babel/lists.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" + babel.lists + ~~~~~~~~~~~ + + Locale dependent formatting of lists. + + The default locale for the functions in this module is determined by the + following environment variables, in that order: + + * ``LC_ALL``, and + * ``LANG`` + + :copyright: (c) 2015 by the Babel Team. + :license: BSD, see LICENSE for more details. +""" + +from babel.core import Locale, default_locale + +DEFAULT_LOCALE = default_locale() + + +def format_list(lst, locale=DEFAULT_LOCALE): + """ Formats `lst` as a list + + e.g. + >>> format_list(['apples', 'oranges', 'pears'], 'en') + u'apples, oranges, and pears' + >>> format_list(['apples', 'oranges', 'pears'], 'zh') + u'apples\u3001oranges\u548cpears' + + :param lst: a sequence of items to format in to a list + :param locale: the locale + """ + locale = Locale.parse(locale) + if not lst: + return '' + if len(lst) == 1: + return lst[0] + if len(lst) == 2: + return locale.list_patterns['2'].format(*lst) + + result = locale.list_patterns['start'].format(lst[0], lst[1]) + for elem in lst[2:-1]: + result = locale.list_patterns['middle'].format(result, elem) + result = locale.list_patterns['end'].format(result, lst[-1]) + + return result diff --git a/tests/test_lists.py b/tests/test_lists.py new file mode 100644 index 000000000..f5021ea50 --- /dev/null +++ b/tests/test_lists.py @@ -0,0 +1,14 @@ +# coding=utf-8 +from babel import lists + + +def test_format_list(): + for list, locale, expected in [ + ([], 'en', ''), + ([u'string'], 'en', u'string'), + (['string1', 'string2'], 'en', u'string1 and string2'), + (['string1', 'string2', 'string3'], 'en', u'string1, string2, and string3'), + (['string1', 'string2', 'string3'], 'zh', u'string1、string2和string3'), + (['string1', 'string2', 'string3', 'string4'], 'ne', u'string1 र string2, string3 र string4'), + ]: + assert lists.format_list(list, locale=locale) == expected From 85f8eba6122c10bc6b19aa964cf485b366690884 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Wed, 14 Oct 2015 12:56:27 +0200 Subject: [PATCH 126/489] README: Make it markdown This way GitHub (and many other UIs and editors) will render the document more nicely. --- README => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README => README.md (100%) diff --git a/README b/README.md similarity index 100% rename from README rename to README.md From 417ec3c695c80e6d88e09fed22c0495fa838f561 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Wed, 14 Oct 2015 13:08:24 +0200 Subject: [PATCH 127/489] README: Add contribution note --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index f329291b3..4fa81e609 100644 --- a/README.md +++ b/README.md @@ -12,3 +12,13 @@ For more information please visit the Babel web site: Join the chat at https://gitter.im/python-babel/babel + +Contributing to Babel +===================== + +If you want to contribute code to Babel, please take a look at our +CONTRIBUTING.md. + +If you know your way around Babels codebase a bit and like to help further, we +would appreciate any help in reviewing pull requests. Please contact us at + if you're interested! From 41f8faac068e5ca296f92f735f33fb58db5aff6a Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 4 Oct 2015 20:36:02 +0200 Subject: [PATCH 128/489] numbers: Implement rounding with Decimal Drop the old bankersround related code and implement rounding using the decimal module instead. This change will enable some other goodies such as: use the drop-in replacement cdecimal when available, or allow for more rounding algorithms by exposing one more parameter. --- babel/numbers.py | 205 +++++++++++++----------------------------- tests/test_numbers.py | 20 ----- 2 files changed, 60 insertions(+), 165 deletions(-) diff --git a/babel/numbers.py b/babel/numbers.py index af9413f57..f92c714ca 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -18,10 +18,9 @@ # TODO: # Padding and rounding increments in pattern: # - http://www.unicode.org/reports/tr35/ (Appendix G.6) -from decimal import Decimal, InvalidOperation -import math import re from datetime import date as date_, datetime as datetime_ +from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN from babel.core import default_locale, Locale, get_global from babel._compat import range_type @@ -455,94 +454,6 @@ def parse_decimal(string, locale=LC_NUMERIC): number_re = re.compile(r"%s%s%s" % (PREFIX_PATTERN, NUMBER_PATTERN, SUFFIX_PATTERN)) -def split_number(value): - """Convert a number into a (intasstring, fractionasstring) tuple""" - if isinstance(value, Decimal): - # NB can't just do text = str(value) as str repr of Decimal may be - # in scientific notation, e.g. for small numbers. - - sign, digits, exp = value.as_tuple() - # build list of digits in reverse order, then reverse+join - # as per http://docs.python.org/library/decimal.html#recipes - int_part = [] - frac_part = [] - - digits = list(map(str, digits)) - - # get figures after decimal point - for i in range(-exp): - # add digit if available, else 0 - if digits: - frac_part.append(digits.pop()) - else: - frac_part.append('0') - - # add in some zeroes... - for i in range(exp): - int_part.append('0') - - # and the rest - while digits: - int_part.append(digits.pop()) - - # if < 1, int_part must be set to '0' - if len(int_part) == 0: - int_part = '0', - - if sign: - int_part.append('-') - - return ''.join(reversed(int_part)), ''.join(reversed(frac_part)) - text = ('%.9f' % value).rstrip('0') - if '.' in text: - a, b = text.split('.', 1) - if b == '0': - b = '' - else: - a, b = text, '' - return a, b - - -def bankersround(value, ndigits=0): - """Round a number to a given precision. - - Works like round() except that the round-half-even (banker's rounding) - algorithm is used instead of round-half-up. - - >>> bankersround(5.5, 0) - 6.0 - >>> bankersround(6.5, 0) - 6.0 - >>> bankersround(-6.5, 0) - -6.0 - >>> bankersround(1234.0, -2) - 1200.0 - """ - sign = int(value < 0) and -1 or 1 - value = abs(value) - a, b = split_number(value) - digits = a + b - add = 0 - i = len(a) + ndigits - if i < 0 or i >= len(digits): - pass - elif digits[i] > '5': - add = 1 - elif digits[i] == '5' and digits[i-1] in '13579': - add = 1 - elif digits[i] == '5': # previous digit is even - # We round up unless all following digits are zero. - for j in range_type(i + 1, len(digits)): - if digits[j] != '0': - add = 1 - break - - scale = 10**ndigits - if isinstance(value, Decimal): - return Decimal(int(value * scale + add)) / scale * sign - else: - return float(int(value * scale + add)) / scale * sign - def parse_grouping(p): """Parse primary and secondary digit grouping @@ -645,35 +556,30 @@ def __init__(self, pattern, prefix, suffix, grouping, self.exp_prec = exp_prec self.exp_plus = exp_plus if '%' in ''.join(self.prefix + self.suffix): - self.scale = 100 + self.scale = 2 elif u'‰' in ''.join(self.prefix + self.suffix): - self.scale = 1000 + self.scale = 3 else: - self.scale = 1 + self.scale = 0 def __repr__(self): return '<%s %r>' % (type(self).__name__, self.pattern) def apply(self, value, locale, currency=None, force_frac=None): frac_prec = force_frac or self.frac_prec - if isinstance(value, float): + if not isinstance(value, Decimal): value = Decimal(str(value)) - value *= self.scale - is_negative = int(value < 0) + value = value.scaleb(self.scale) + is_negative = int(value.is_signed()) if self.exp_prec: # Scientific notation + exp = value.adjusted() value = abs(value) - if value: - exp = int(math.floor(math.log(value, 10))) - else: - exp = 0 # Minimum number of integer digits if self.int_prec[0] == self.int_prec[1]: exp -= self.int_prec[0] - 1 # Exponent grouping elif self.int_prec[1]: exp = int(exp / self.int_prec[1]) * self.int_prec[1] - if not isinstance(value, Decimal): - value = float(value) if exp < 0: value = value * 10**(-exp) else: @@ -685,29 +591,25 @@ def apply(self, value, locale, currency=None, force_frac=None): exp_sign = get_plus_sign_symbol(locale) exp = abs(exp) number = u'%s%s%s%s' % \ - (self._format_sigdig(value, frac_prec[0], frac_prec[1]), + (self._format_significant(value, frac_prec[0], frac_prec[1]), get_exponential_symbol(locale), exp_sign, self._format_int(str(exp), self.exp_prec[0], self.exp_prec[1], locale)) elif '@' in self.pattern: # Is it a siginificant digits pattern? - text = self._format_sigdig(abs(value), - self.int_prec[0], - self.int_prec[1]) - if '.' in text: - a, b = text.split('.') - a = self._format_int(a, 0, 1000, locale) - if b: - b = get_decimal_symbol(locale) + b - number = a + b - else: - number = self._format_int(text, 0, 1000, locale) + text = self._format_significant(abs(value), + self.int_prec[0], + self.int_prec[1]) + a, sep, b = text.partition(".") + number = self._format_int(a, 0, 1000, locale) + if sep: + number += get_decimal_symbol(locale) + b else: # A normal number pattern - a, b = split_number(bankersround(abs(value), frac_prec[1])) - b = b or '0' - a = self._format_int(a, self.int_prec[0], - self.int_prec[1], locale) - b = self._format_frac(b, locale, force_frac) - number = a + b + precision = Decimal('1.' + '1' * frac_prec[1]) + rounded = value.quantize(precision, ROUND_HALF_EVEN) + a, sep, b = str(abs(rounded)).partition(".") + number = (self._format_int(a, self.int_prec[0], + self.int_prec[1], locale) + + self._format_frac(b or '0', locale, force_frac)) retval = u'%s%s%s' % (self.prefix[is_negative], number, self.suffix[is_negative]) if u'¤' in retval: @@ -717,31 +619,44 @@ def apply(self, value, locale, currency=None, force_frac=None): retval = retval.replace(u'¤', get_currency_symbol(currency, locale)) return retval - def _format_sigdig(self, value, min, max): - """Convert value to a string. - - The resulting string will contain between (min, max) number of - significant digits. - """ - a, b = split_number(value) - ndecimals = len(a) - if a == '0' and b != '': - ndecimals = 0 - while b.startswith('0'): - b = b[1:] - ndecimals -= 1 - a, b = split_number(bankersround(value, max - ndecimals)) - digits = len((a + b).lstrip('0')) - if not digits: - digits = 1 - # Figure out if we need to add any trailing '0':s - if len(a) >= max and a != '0': - return a - if digits < min: - b += ('0' * (min - digits)) - if b: - return '%s.%s' % (a, b) - return a + # + # This is one tricky piece of code. The idea is to rely as much as possible + # on the decimal module to minimize the amount of code. + # + # Conceptually, the implementation of this method can be summarized in the + # following steps: + # + # - Move or shift the decimal point (i.e. the exponent) so the maximum + # amount of significant digits fall into the integer part (i.e. to the + # left of the decimal point) + # + # - Round the number to the nearest integer, discarding all the fractional + # part which contained extra digits to be eliminated + # + # - Convert the rounded integer to a string, that will contain the final + # sequence of significant digits already trimmed to the maximum + # + # - Restore the original position of the decimal point, potentially + # padding with zeroes on either side + # + def _format_significant(self, value, minimum, maximum): + exp = value.adjusted() + scale = maximum - 1 - exp + digits = str(value.scaleb(scale).quantize(Decimal(1), ROUND_HALF_EVEN)) + if scale <= 0: + result = digits + '0' * -scale + else: + intpart = digits[:-scale] + i = len(intpart) + j = i + max(minimum - i, 0) + result = "{intpart}.{pad:0<{fill}}{fracpart}{fracextra}".format( + intpart=intpart or '0', + pad='', + fill=-min(exp + 1, 0), + fracpart=digits[i:j], + fracextra=digits[j:].rstrip('0'), + ).rstrip('.') + return result def _format_int(self, value, min, max, locale): width = len(value) diff --git a/tests/test_numbers.py b/tests/test_numbers.py index a773f48f8..fd3e7c815 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -151,19 +151,6 @@ def test_formatting_of_very_small_decimals(self): self.assertEqual('0.000000700', fmt) -class BankersRoundTestCase(unittest.TestCase): - def test_round_to_nearest_integer(self): - self.assertEqual(1, numbers.bankersround(Decimal('0.5001'))) - - def test_round_to_even_for_two_nearest_integers(self): - self.assertEqual(0, numbers.bankersround(Decimal('0.5'))) - self.assertEqual(2, numbers.bankersround(Decimal('1.5'))) - self.assertEqual(-2, numbers.bankersround(Decimal('-2.5'))) - - self.assertEqual(0, numbers.bankersround(Decimal('0.05'), ndigits=1)) - self.assertEqual(Decimal('0.2'), numbers.bankersround(Decimal('0.15'), ndigits=1)) - - class NumberParsingTestCase(unittest.TestCase): def test_can_parse_decimals(self): self.assertEqual(Decimal('1099.98'), @@ -320,13 +307,6 @@ def test_parse_decimal(): assert excinfo.value.args[0] == "'2,109,998' is not a valid decimal number" -def test_bankersround(): - assert numbers.bankersround(5.5, 0) == 6.0 - assert numbers.bankersround(6.5, 0) == 6.0 - assert numbers.bankersround(-6.5, 0) == -6.0 - assert numbers.bankersround(1234.0, -2) == 1200.0 - - def test_parse_grouping(): assert numbers.parse_grouping('##') == (1000, 1000) assert numbers.parse_grouping('#,###') == (3, 3) From 4dc13017aaaaa1dd63fe87e7dbb41e4462be8f88 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Wed, 14 Oct 2015 19:49:22 +0200 Subject: [PATCH 129/489] tests: Add more testing coverage to format_percent --- tests/test_numbers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_numbers.py b/tests/test_numbers.py index fd3e7c815..0d8fa00fc 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -271,6 +271,8 @@ def test_format_currency_format_type(): def test_format_percent(): assert numbers.format_percent(0.34, locale='en_US') == u'34%' + assert numbers.format_percent(0.34, u'##0%', locale='en_US') == u'34%' + assert numbers.format_percent(34, u'##0', locale='en_US') == u'34' assert numbers.format_percent(25.1234, locale='en_US') == u'2,512%' assert (numbers.format_percent(25.1234, locale='sv_SE') == u'2\xa0512\xa0%') From d177d5eb746693e0e7bb861ece87a4824008c649 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 11 Oct 2015 14:20:01 +0200 Subject: [PATCH 130/489] travis: Add new environments that install cdecimal For the next change, we will need alternate environments for Python 2.6 and 2.7 where the cdecimal module can be installed and tested. This commit adds new environments to Tox and Travis to automate the process. --- .travis.yml | 24 +++++++++++++++++++++++- tox.ini | 5 ++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index c7a79b1be..d25ad5f2b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,8 +11,16 @@ matrix: include: - os: linux python: 2.6 + - os: linux + python: 2.6 + env: + - CDECIMAL=cdecimal - os: linux python: 2.7 + - os: linux + python: 2.7 + env: + - CDECIMAL=cdecimal - os: linux python: pypy - os: linux @@ -25,12 +33,26 @@ matrix: - PYTHON_VERSION=2.6.6 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=2.6.6 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - CDECIMAL=cdecimal + - os: osx + language: generic + env: + - PYTHON_VERSION=2.7.10 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin - os: osx language: generic env: - PYTHON_VERSION=2.7.10 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - CDECIMAL=cdecimal - os: osx language: generic env: @@ -53,7 +75,7 @@ matrix: install: - bash .ci/deps.${TRAVIS_OS_NAME}.sh - pip install --upgrade pip - - pip install pytest pytest-cov + - pip install --allow-external cdecimal pytest pytest-cov $CDECIMAL - pip install --editable . script: diff --git a/tox.ini b/tox.ini index 311da6e6f..a5b23e41d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,11 @@ [tox] -envlist = py26, py27, pypy, py33, py34 +envlist = py26, py27, pypy, py33, py34, py26-cdecimal, py27-cdecimal [testenv] +install_command = + pip install --allow-external cdecimal {opts} {packages} deps = pytest + cdecimal: cdecimal whitelist_externals = make commands = make clean-cldr test From b6169be329e121111dc9c3242c3538ee3b0a3aaa Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 4 Oct 2015 20:49:23 +0200 Subject: [PATCH 131/489] numbers: Use cdecimal by default when available The drop-in replacement cdecimal is a CPython extension that implements the same decimal interface with a much better performance. Whenever it is installed, we favour its use. --- babel/_compat.py | 19 +++++++++++++++++++ babel/numbers.py | 3 +-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/babel/_compat.py b/babel/_compat.py index 0f7640de1..78cd35a37 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -54,3 +54,22 @@ number_types = integer_types + (float,) + + +# +# Use cdecimal when available +# +from decimal import (Decimal as _dec, + InvalidOperation as _invop, + ROUND_HALF_EVEN as _RHE) +try: + from cdecimal import (Decimal as _cdec, + InvalidOperation as _cinvop, + ROUND_HALF_EVEN as _CRHE) + Decimal = _cdec + InvalidOperation = (_invop, _cinvop) + ROUND_HALF_EVEN = _CRHE +except ImportError: + Decimal = _dec + InvalidOperation = _invop + ROUND_HALF_EVEN = _RHE diff --git a/babel/numbers.py b/babel/numbers.py index f92c714ca..a15f399bb 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -20,10 +20,9 @@ # - http://www.unicode.org/reports/tr35/ (Appendix G.6) import re from datetime import date as date_, datetime as datetime_ -from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN from babel.core import default_locale, Locale, get_global -from babel._compat import range_type +from babel._compat import range_type, Decimal, InvalidOperation, ROUND_HALF_EVEN LC_NUMERIC = default_locale('LC_NUMERIC') From fe77bb3e66a7838b119e6b8ca92c2e0396ed84dd Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Sun, 11 Oct 2015 14:26:04 +0200 Subject: [PATCH 132/489] tests: Use the automatically chosen Decimal class Now that the babel._compat module can automatically select a faster decimal implementation if available, be more consistent across the rest of the code when dealing with Decimal instances. --- babel/plural.py | 7 ++++--- tests/test_numbers.py | 2 +- tests/test_plural.py | 23 ++++++++++++----------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 50bf54181..b5ce9ba65 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -8,10 +8,11 @@ :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. """ -import decimal import re import sys +from babel._compat import Decimal + _plural_tags = ('zero', 'one', 'two', 'few', 'many', 'other') _fallback_tag = 'other' @@ -32,9 +33,9 @@ def extract_operands(source): # 2.6's Decimal cannot convert from float directly if sys.version_info < (2, 7): n = str(n) - n = decimal.Decimal(n) + n = Decimal(n) - if isinstance(n, decimal.Decimal): + if isinstance(n, Decimal): dec_tuple = n.as_tuple() exp = dec_tuple.exponent fraction_digits = dec_tuple.digits[exp:] if exp < 0 else () diff --git a/tests/test_numbers.py b/tests/test_numbers.py index 0d8fa00fc..f042833b3 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -11,13 +11,13 @@ # individuals. For the exact contribution history, see the revision # history and logs, available at http://babel.edgewall.org/log/. -from decimal import Decimal import unittest import pytest from datetime import date from babel import numbers +from babel._compat import Decimal class FormatDecimalTestCase(unittest.TestCase): diff --git a/tests/test_plural.py b/tests/test_plural.py index 278b5dba8..b0cad8da2 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -10,10 +10,11 @@ # 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 decimal import unittest import pytest + from babel import plural +from babel._compat import Decimal def test_plural_rule(): @@ -33,29 +34,29 @@ def test_plural_rule_operands_i(): def test_plural_rule_operands_v(): rule = plural.PluralRule({'one': 'v is 2'}) - assert rule(decimal.Decimal('1.20')) == 'one' - assert rule(decimal.Decimal('1.2')) == 'other' + assert rule(Decimal('1.20')) == 'one' + assert rule(Decimal('1.2')) == 'other' assert rule(2) == 'other' def test_plural_rule_operands_w(): rule = plural.PluralRule({'one': 'w is 2'}) - assert rule(decimal.Decimal('1.23')) == 'one' - assert rule(decimal.Decimal('1.20')) == 'other' + assert rule(Decimal('1.23')) == 'one' + assert rule(Decimal('1.20')) == 'other' assert rule(1.2) == 'other' def test_plural_rule_operands_f(): rule = plural.PluralRule({'one': 'f is 20'}) - assert rule(decimal.Decimal('1.23')) == 'other' - assert rule(decimal.Decimal('1.20')) == 'one' + assert rule(Decimal('1.23')) == 'other' + assert rule(Decimal('1.20')) == 'one' assert rule(1.2) == 'other' def test_plural_rule_operands_t(): rule = plural.PluralRule({'one': 't = 5'}) - assert rule(decimal.Decimal('1.53')) == 'other' - assert rule(decimal.Decimal('1.50')) == 'one' + assert rule(Decimal('1.53')) == 'other' + assert rule(Decimal('1.50')) == 'one' assert rule(1.5) == 'one' @@ -250,6 +251,6 @@ def test_or_and(self): @pytest.mark.parametrize('source,n,i,v,w,f,t', EXTRACT_OPERANDS_TESTS) def test_extract_operands(source, n, i, v, w, f, t): - source = decimal.Decimal(source) if isinstance(source, str) else source + source = Decimal(source) if isinstance(source, str) else source assert (plural.extract_operands(source) == - decimal.Decimal(n), i, v, w, f, t) + Decimal(n), i, v, w, f, t) From 531f666c163cfa406dfcb6545027001018995051 Mon Sep 17 00:00:00 2001 From: Michael Birtwell Date: Wed, 21 Oct 2015 22:01:59 +0100 Subject: [PATCH 133/489] plurals: Fix selection for chinese Provide only one option in chinese. The 3 previous options where all the same any how and I've checked with a chinese colleague she thinks that applies to all variants on the chinese language. Refactor the get_plural tests a bit so they are split up to test specific things --- babel/messages/plurals.py | 6 ++---- tests/messages/test_plurals.py | 27 ++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/babel/messages/plurals.py b/babel/messages/plurals.py index c00a21108..05f91102b 100644 --- a/babel/messages/plurals.py +++ b/babel/messages/plurals.py @@ -192,10 +192,8 @@ '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'), + # Chinese - From Pootle's PO's (modified) + 'zh': (1, '0'), } diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py index 9466decfc..8c11745ff 100644 --- a/tests/messages/test_plurals.py +++ b/tests/messages/test_plurals.py @@ -10,17 +10,34 @@ # 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 pytest -import doctest -import unittest - +from babel import Locale from babel.messages import plurals -def test_get_plural(): - assert plurals.get_plural(locale='en') == (2, '(n != 1)') +@pytest.mark.parametrize(('locale', 'num_plurals', 'plural_expr'), [ + (Locale('en'), 2, '(n != 1)'), + (Locale('en', 'US'), 2, '(n != 1)'), + (Locale('zh'), 1, '0'), + (Locale('zh', script='Hans'), 1, '0'), + (Locale('zh', script='Hant'), 1, '0'), + (Locale('zh', 'CN', 'Hans'), 1, '0'), + (Locale('zh', 'TW', 'Hant'), 1, '0'), +]) +def test_get_plural_selection(locale, num_plurals, plural_expr): + assert plurals.get_plural(locale) == (num_plurals, plural_expr) + + +def test_get_plural_accpets_strings(): assert plurals.get_plural(locale='ga') == (3, '(n==1 ? 0 : n==2 ? 1 : 2)') + +def test_get_plural_falls_back_to_default(): + assert plurals.get_plural('aa') == (2, '(n != 1)') + + +def test_plural_tuple_attributes(): tup = plurals.get_plural("ja") assert tup.num_plurals == 1 assert tup.plural_expr == '0' From b3f3430d2e77871fdca0b119adf0b22b9140ffa2 Mon Sep 17 00:00:00 2001 From: Michael Birtwell Date: Wed, 30 Sep 2015 18:57:30 +0100 Subject: [PATCH 134/489] Add an ordinal_form property to Locale --- babel/core.py | 17 +++++++++++++++++ scripts/import_cldr.py | 26 +++++++++++++++++--------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/babel/core.py b/babel/core.py index 7d2425714..0b314cd3a 100644 --- a/babel/core.py +++ b/babel/core.py @@ -756,6 +756,23 @@ def list_patterns(self): """ return self._data['list_patterns'] + @property + def ordinal_form(self): + """Plural rules for the locale. + + >>> Locale('en').ordinal_form(1) + 'one' + >>> Locale('en').ordinal_form(2) + 'two' + >>> Locale('en').ordinal_form(3) + 'few' + >>> Locale('fr').ordinal_form(2) + 'other' + >>> Locale('ru').ordinal_form(100) + 'other' + """ + return self._data.get('ordinal_form', _default_plural_rule) + def default_locale(category=None, aliases=LOCALE_ALIASES): """Returns the system default locale for a given category, based on diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 68bf454b6..d179604c1 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -109,6 +109,19 @@ def _currency_sort_key(tup): return int(not tender), start or (1, 1, 1) +def _extract_plural_rules(file_path): + rule_dict = {} + prsup = parse(file_path) + for elem in prsup.findall('.//plurals/pluralRules'): + rules = [] + for rule in elem.findall('pluralRule'): + rules.append((rule.attrib['count'], text_type(rule.text))) + pr = PluralRule(rules) + for locale in elem.attrib['locales'].split(): + rule_dict[locale] = pr + return rule_dict + + def main(): parser = OptionParser(usage='%prog path/to/cldr') options, args = parser.parse_args() @@ -260,15 +273,8 @@ def main(): containers.add(group) # prepare the per-locale plural rules definitions - plural_rules = {} - prsup = parse(os.path.join(srcdir, 'supplemental', 'plurals.xml')) - for elem in prsup.findall('.//plurals/pluralRules'): - rules = [] - for rule in elem.findall('pluralRule'): - rules.append((rule.attrib['count'], text_type(rule.text))) - pr = PluralRule(rules) - for locale in elem.attrib['locales'].split(): - plural_rules[locale] = pr + plural_rules = _extract_plural_rules(os.path.join(srcdir, 'supplemental', 'plurals.xml')) + ordinal_rules = _extract_plural_rules(os.path.join(srcdir, 'supplemental', 'ordinals.xml')) filenames = os.listdir(os.path.join(srcdir, 'main')) filenames.remove('root.xml') @@ -312,6 +318,8 @@ def main(): ])) if locale_id in plural_rules: data['plural_form'] = plural_rules[locale_id] + if locale_id in ordinal_rules: + data['ordinal_form'] = ordinal_rules[locale_id] # From 79bc78182f82c44ea40b5d182b69f321e8da4373 Mon Sep 17 00:00:00 2001 From: Florian Schulze Date: Wed, 4 Nov 2015 19:08:10 +0100 Subject: [PATCH 135/489] Allow file locations without line numbers. --- babel/messages/pofile.py | 12 +++++++++--- tests/messages/test_pofile.py | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 3d7dc3283..41fad0a8a 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -218,6 +218,8 @@ def _process_message_line(lineno, line): except ValueError: continue locations.append((location[:pos], lineno)) + else: + locations.append((location, None)) elif line[1:].startswith(','): for flag in line[2:].lstrip().split(','): flags.append(flag.strip()) @@ -449,9 +451,13 @@ def _write_message(message, prefix=''): _write_comment(comment, prefix='.') if not no_location: - locs = u' '.join([u'%s:%d' % (filename.replace(os.sep, '/'), lineno) - for filename, lineno in message.locations]) - _write_comment(locs, prefix=':') + locs = [] + for filename, lineno in message.locations: + if lineno: + locs.append(u'%s:%d' % (filename.replace(os.sep, '/'), lineno)) + else: + locs.append(u'%s' % filename.replace(os.sep, '/')) + _write_comment(' '.join(locs), prefix=':') if message.flags: _write('#%s\n' % ', '.join([''] + sorted(message.flags))) diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py index 3bb055714..4e991c63c 100644 --- a/tests/messages/test_pofile.py +++ b/tests/messages/test_pofile.py @@ -513,6 +513,21 @@ def test_sorted_po(self): msgstr[1] "Voeh"''' in value assert value.find(b'msgid ""') < value.find(b'msgid "bar"') < value.find(b'msgid "foo"') + def test_file_with_no_lineno(self): + catalog = Catalog() + catalog.add(u'bar', locations=[('utils.py', None)], + user_comments=['Comment About `bar` with', + 'multiple lines.']) + buf = BytesIO() + pofile.write_po(buf, catalog, sort_output=True) + value = buf.getvalue().strip() + assert b'''\ +# Comment About `bar` with +# multiple lines. +#: utils.py +msgid "bar" +msgstr ""''' in value + def test_silent_location_fallback(self): buf = BytesIO(b'''\ #: broken_file.py @@ -523,7 +538,7 @@ def test_silent_location_fallback(self): msgid "broken line number" msgstr ""''') catalog = pofile.read_po(buf) - self.assertEqual(catalog['missing line number'].locations, []) + self.assertEqual(catalog['missing line number'].locations, [(u'broken_file.py', None)]) self.assertEqual(catalog['broken line number'].locations, []) From 3f585165bc75f293c67d75146aebad3a11c202e1 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 18:16:18 +0200 Subject: [PATCH 136/489] Allow passing a callable to `extract()` --- babel/messages/extract.py | 8 ++++++-- tests/messages/test_extract.py | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 2f8084af5..a0608d797 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -212,7 +212,8 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), ... print message (3, u'Hello, world!', [], None) - :param method: a string specifying the extraction method (.e.g. "python"); + :param method: an extraction method (a callable), or + a string specifying the extraction method (.e.g. "python"); if this is a simple name, the extraction function will be looked up by entry point; if it is an explicit reference to a function (of the form ``package.module:funcname`` or @@ -231,7 +232,9 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), :raise ValueError: if the extraction method is not registered """ func = None - if ':' in method or '.' in method: + if callable(method): + func = method + elif ':' in method or '.' in method: if ':' not in method: lastdot = method.rfind('.') module, attrname = method[:lastdot], method[lastdot + 1:] @@ -258,6 +261,7 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), 'javascript': extract_javascript } func = builtin.get(method) + if func is None: raise ValueError('Unknown extraction method %r' % method) diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index ded697f7f..fa03207c4 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -546,3 +546,9 @@ def test_warn_if_empty_string_msgid_found_in_context_aware_extraction_method(sel assert 'warning: Empty msgid.' in sys.stderr.getvalue() finally: sys.stderr = stderr + + def test_extract_allows_callable(self): + def arbitrary_extractor(fileobj, keywords, comment_tags, options): + return [(1, None, (), ())] + for x in extract.extract(arbitrary_extractor, BytesIO(b"")): + assert x[0] == 1 From 9ab038fb0556d20788397a450f52f6cd6e94b6c7 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 21 Dec 2015 00:43:39 +0200 Subject: [PATCH 137/489] Test/CI: Add requisite doctest flags; ignore setup.py and venvs This requires a newer version of py.test, so the requirement is pinned in the CI configuration. See https://pytest.org/latest/doctest.html See https://pytest.org/latest/example/pythoncollection.html#customizing-test-collection-to-find-all-py-files --- .ci/appveyor.yml | 3 ++- .gitignore | 1 + .travis.yml | 3 ++- conftest.py | 2 +- setup.cfg | 3 ++- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml index 67ca84b14..5a9fd008b 100644 --- a/.ci/appveyor.yml +++ b/.ci/appveyor.yml @@ -43,7 +43,8 @@ install: - "python --version" - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" # Build data files - - "pip install . pytest" + - "pip install --upgrade pytest==2.8.5" + - "pip install --editable ." - "python setup.py import_cldr" build: false # Not a C# project, build stuff at the test step instead. diff --git a/.gitignore b/.gitignore index 0006e76c9..7c27f0e60 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ babel/global.dat tests/messages/data/project/i18n/long_messages.pot tests/messages/data/project/i18n/temp.pot tests/messages/data/project/i18n/en_US +/venv* diff --git a/.travis.yml b/.travis.yml index d25ad5f2b..7bf5e9c4d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,7 @@ sudo: false cache: directories: - cldr + - "$HOME/.cache/pip" matrix: include: @@ -75,7 +76,7 @@ matrix: install: - bash .ci/deps.${TRAVIS_OS_NAME}.sh - pip install --upgrade pip - - pip install --allow-external cdecimal pytest pytest-cov $CDECIMAL + - pip install --allow-external cdecimal --upgrade pytest==2.8.5 pytest-cov==2.2.0 $CDECIMAL - pip install --editable . script: diff --git a/conftest.py b/conftest.py index 55e27a2ca..15a589a6c 100644 --- a/conftest.py +++ b/conftest.py @@ -6,7 +6,7 @@ PY2 = sys.version_info[0] < 3 -collect_ignore = ['tests/messages/data'] +collect_ignore = ['tests/messages/data', 'setup.py'] def pytest_collect_file(path, parent): diff --git a/setup.cfg b/setup.cfg index 38a441db4..8069749f5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,7 +2,8 @@ release = sdist bdist_wheel [pytest] -norecursedirs = .* _* scripts {args} +norecursedirs = venv* .* _* scripts {args} +doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE ALLOW_UNICODE [bdist_wheel] universal = 1 From 62a368c31e5136ee1c33b4cf305fbb2a82778887 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 21 Dec 2015 10:15:07 +0200 Subject: [PATCH 138/489] Test/CI: Make doctests run on both Py2 and Py3 Fixes #293 --- babel/core.py | 4 ++-- babel/dates.py | 4 ++-- babel/localedata.py | 2 +- babel/messages/catalog.py | 10 +++++----- babel/messages/extract.py | 10 +++++----- babel/messages/frontend.py | 13 ++++++------- babel/messages/mofile.py | 10 +++++++--- babel/messages/pofile.py | 33 +++++++++++++++++---------------- babel/support.py | 4 ++-- babel/util.py | 4 ++-- conftest.py | 15 ++++----------- setup.cfg | 2 +- 12 files changed, 54 insertions(+), 57 deletions(-) diff --git a/babel/core.py b/babel/core.py index 0b314cd3a..84ee18920 100644 --- a/babel/core.py +++ b/babel/core.py @@ -544,9 +544,9 @@ def decimal_formats(self): def currency_formats(self): """Locale patterns for currency number formatting. - >>> print Locale('en', 'US').currency_formats['standard'] + >>> Locale('en', 'US').currency_formats['standard'] - >>> print Locale('en', 'US').currency_formats['accounting'] + >>> Locale('en', 'US').currency_formats['accounting'] """ return self._data['currency_formats'] diff --git a/babel/dates.py b/babel/dates.py index f9498d80a..56677030f 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -539,7 +539,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, def format_date(date=None, format='medium', locale=LC_TIME): """Return a date formatted according to the given pattern. - >>> d = date(2007, 04, 01) + >>> d = date(2007, 4, 1) >>> format_date(d, locale='en_US') u'Apr 1, 2007' >>> format_date(d, format='full', locale='de_DE') @@ -573,7 +573,7 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, locale=LC_TIME): r"""Return a date formatted according to the given pattern. - >>> dt = datetime(2007, 04, 01, 15, 30) + >>> dt = datetime(2007, 4, 1, 15, 30) >>> format_datetime(dt, locale='en_US') u'Apr 1, 2007, 3:30:00 PM' diff --git a/babel/localedata.py b/babel/localedata.py index 79b4d8639..437f49fae 100644 --- a/babel/localedata.py +++ b/babel/localedata.py @@ -111,7 +111,7 @@ def merge(dict1, dict2): >>> d = {1: 'foo', 3: 'baz'} >>> merge(d, {1: 'Foo', 2: 'Bar'}) - >>> items = d.items(); items.sort(); items + >>> sorted(d.items()) [(1, 'Foo'), (2, 'Bar'), (3, 'baz')] :param dict1: the dictionary to merge into diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 12e878348..e289bef76 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -331,7 +331,7 @@ def _set_header_comment(self, string): >>> catalog = Catalog(project='Foobar', version='1.0', ... copyright_holder='Foo Company') - >>> print catalog.header_comment #doctest: +ELLIPSIS + >>> print(catalog.header_comment) #doctest: +ELLIPSIS # Translations template for Foobar. # Copyright (C) ... Foo Company # This file is distributed under the same license as the Foobar project. @@ -349,7 +349,7 @@ def _set_header_comment(self, string): ... # This file is distributed under the same license as the PROJECT ... # project. ... #''' - >>> print catalog.header_comment + >>> 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 @@ -433,7 +433,7 @@ def _set_mime_headers(self, headers): >>> catalog = Catalog(project='Foobar', version='1.0', ... creation_date=created) >>> for name, value in catalog.mime_headers: - ... print '%s: %s' % (name, value) + ... 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 @@ -453,7 +453,7 @@ def _set_mime_headers(self, headers): ... last_translator='John Doe ', ... language_team='de_DE ') >>> for name, value in catalog.mime_headers: - ... print '%s: %s' % (name, value) + ... 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 @@ -720,7 +720,7 @@ def update(self, template, no_fuzzy_matching=False): >>> 'head' in catalog False - >>> catalog.obsolete.values() + >>> list(catalog.obsolete.values()) [] :param template: the reference catalog, usually read from a POT file diff --git a/babel/messages/extract.py b/babel/messages/extract.py index a0608d797..be2e6303c 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -202,14 +202,14 @@ def extract(method, fileobj, keywords=DEFAULT_KEYWORDS, comment_tags=(), The implementation dispatches the actual extraction to plugins, based on the value of the ``method`` parameter. - >>> source = '''# foo module + >>> source = b'''# foo module ... def run(argv): - ... print _('Hello, world!') + ... print(_('Hello, world!')) ... ''' - >>> from StringIO import StringIO - >>> for message in extract('python', StringIO(source)): - ... print message + >>> from babel._compat import BytesIO + >>> for message in extract('python', BytesIO(source)): + ... print(message) (3, u'Hello, world!', [], None) :param method: an extraction method (a callable), or diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index cd79ebf20..56f1b7677 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -8,7 +8,7 @@ :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. """ - +from __future__ import print_function try: from ConfigParser import RawConfigParser except ImportError: @@ -35,7 +35,7 @@ from babel.messages.mofile import write_mo from babel.messages.pofile import read_po, write_po from babel.util import odict, LOCALTZ -from babel._compat import string_types, BytesIO, PY2 +from babel._compat import string_types, StringIO, PY2 class compile_catalog(Command): @@ -332,7 +332,7 @@ def _get_mappings(self): message_extractors = self.distribution.message_extractors for dirname, mapping in message_extractors.items(): if isinstance(mapping, string_types): - method_map, options_map = parse_mapping(BytesIO(mapping)) + method_map, options_map = parse_mapping(StringIO(mapping)) else: method_map, options_map = [], {} for pattern, method, options in mapping: @@ -1154,7 +1154,7 @@ def main(): def parse_mapping(fileobj, filename=None): """Parse an extraction method mapping from a file-like object. - >>> buf = BytesIO(b''' + >>> buf = StringIO(''' ... [extractors] ... custom = mypackage.module:myfunc ... @@ -1227,10 +1227,9 @@ def parse_mapping(fileobj, filename=None): def parse_keywords(strings=[]): """Parse keywords specifications from the given list of strings. - >>> kw = parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items() - >>> kw.sort() + >>> kw = sorted(parse_keywords(['_', 'dgettext:2', 'dngettext:2,3', 'pgettext:1c,2']).items()) >>> for keyword, indices in kw: - ... print (keyword, indices) + ... print((keyword, indices)) ('_', None) ('dgettext', (2,)) ('dngettext', (2, 3)) diff --git a/babel/messages/mofile.py b/babel/messages/mofile.py index 18503287b..2ab96d523 100644 --- a/babel/messages/mofile.py +++ b/babel/messages/mofile.py @@ -108,9 +108,10 @@ def write_mo(fileobj, catalog, use_fuzzy=False): """Write a catalog to the specified file-like object using the GNU MO file format. + >>> import sys >>> from babel.messages import Catalog >>> from gettext import GNUTranslations - >>> from StringIO import StringIO + >>> from babel._compat import BytesIO >>> catalog = Catalog(locale='en_US') >>> catalog.add('foo', 'Voh') @@ -123,11 +124,14 @@ def write_mo(fileobj, catalog, use_fuzzy=False): >>> catalog.add(('Fuzz', 'Fuzzes'), ('', '')) - >>> buf = StringIO() + >>> buf = BytesIO() >>> write_mo(buf, catalog) - >>> buf.seek(0) + >>> x = buf.seek(0) >>> translations = GNUTranslations(fp=buf) + >>> if sys.version_info[0] >= 3: + ... translations.ugettext = translations.gettext + ... translations.ungettext = translations.ngettext >>> translations.ugettext('foo') u'Voh' >>> translations.ungettext('bar', 'baz', 1) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 41fad0a8a..226ac1ce9 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -10,6 +10,7 @@ :license: BSD, see LICENSE for more details. """ +from __future__ import print_function import os import re @@ -21,7 +22,7 @@ def unescape(string): r"""Reverse `escape` the given string. - >>> print unescape('"Say:\\n \\"hello, world!\\"\\n"') + >>> print(unescape('"Say:\\n \\"hello, world!\\"\\n"')) Say: "hello, world!" @@ -44,18 +45,18 @@ def replace_escapes(match): def denormalize(string): r"""Reverse the normalization done by the `normalize` function. - >>> print denormalize(r'''"" + >>> print(denormalize(r'''"" ... "Say:\n" - ... " \"hello, world!\"\n"''') + ... " \"hello, world!\"\n"''')) Say: "hello, world!" - >>> print denormalize(r'''"" + >>> print(denormalize(r'''"" ... "Say:\n" ... " \"Lorem ipsum dolor sit " ... "amet, consectetur adipisicing" - ... " elit, \"\n"''') + ... " elit, \"\n"''')) Say: "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " @@ -77,7 +78,7 @@ def read_po(fileobj, locale=None, domain=None, ignore_obsolete=False, charset=No file-like object and return a `Catalog`. >>> from datetime import datetime - >>> from StringIO import StringIO + >>> from babel._compat import StringIO >>> buf = StringIO(''' ... #: main.py:1 ... #, fuzzy, python-format @@ -93,13 +94,13 @@ def read_po(fileobj, locale=None, domain=None, ignore_obsolete=False, charset=No ... msgstr[1] "baaz" ... ''') >>> catalog = read_po(buf) - >>> catalog.revision_date = datetime(2007, 04, 01) + >>> catalog.revision_date = datetime(2007, 4, 1) >>> for message in catalog: ... if message.id: - ... print (message.id, message.string) - ... print ' ', (message.locations, sorted(list(message.flags))) - ... print ' ', (message.user_comments, message.auto_comments) + ... print((message.id, message.string)) + ... print(' ', (message.locations, sorted(list(message.flags)))) + ... print(' ', (message.user_comments, message.auto_comments)) (u'foo %(name)s', u'quux %(name)s') ([(u'main.py', 1)], [u'fuzzy', u'python-format']) ([], []) @@ -278,16 +279,16 @@ def escape(string): def normalize(string, prefix='', width=76): r"""Convert a string into a format that is appropriate for .po files. - >>> print normalize('''Say: + >>> print(normalize('''Say: ... "hello, world!" - ... ''', width=None) + ... ''', width=None)) "" "Say:\n" " \"hello, world!\"\n" - >>> print normalize('''Say: + >>> print(normalize('''Say: ... "Lorem ipsum dolor sit amet, consectetur adipisicing elit, " - ... ''', width=32) + ... ''', width=32)) "" "Say:\n" " \"Lorem ipsum dolor sit " @@ -348,10 +349,10 @@ def write_po(fileobj, catalog, width=76, no_location=False, omit_header=False, >>> catalog.add((u'bar', u'baz'), locations=[('main.py', 3)]) - >>> from io import BytesIO + >>> from babel._compat import BytesIO >>> buf = BytesIO() >>> write_po(buf, catalog, omit_header=True) - >>> print buf.getvalue() + >>> print(buf.getvalue().decode("utf8")) #: main.py:1 #, fuzzy, python-format msgid "foo %(name)s" diff --git a/babel/support.py b/babel/support.py index 5ab97a5b2..3b4869cdb 100644 --- a/babel/support.py +++ b/babel/support.py @@ -137,7 +137,7 @@ class LazyProxy(object): >>> def greeting(name='world'): ... return 'Hello, %s!' % name >>> lazy_greeting = LazyProxy(greeting, name='Joe') - >>> print lazy_greeting + >>> print(lazy_greeting) Hello, Joe! >>> u' ' + lazy_greeting u' Hello, Joe!' @@ -160,7 +160,7 @@ class LazyProxy(object): ... ] >>> greetings.sort() >>> for greeting in greetings: - ... print greeting + ... print(greeting) Hello, Joe! Hello, universe! Hello, world! diff --git a/babel/util.py b/babel/util.py index 1c7264594..0f3fb9947 100644 --- a/babel/util.py +++ b/babel/util.py @@ -26,9 +26,9 @@ def distinct(iterable): Unlike when using sets for a similar effect, the original ordering of the items in the collection is preserved by this function. - >>> print list(distinct([1, 2, 1, 3, 4, 4])) + >>> print(list(distinct([1, 2, 1, 3, 4, 4]))) [1, 2, 3, 4] - >>> print list(distinct('foobar')) + >>> print(list(distinct('foobar'))) ['f', 'o', 'b', 'a', 'r'] :param iterable: the iterable collection providing the data diff --git a/conftest.py b/conftest.py index 15a589a6c..32bd1362a 100644 --- a/conftest.py +++ b/conftest.py @@ -1,18 +1,11 @@ -import sys from _pytest.doctest import DoctestModule from py.path import local - -PY2 = sys.version_info[0] < 3 - - collect_ignore = ['tests/messages/data', 'setup.py'] +babel_path = local(__file__).dirpath().join('babel') def pytest_collect_file(path, parent): - babel_path = local(__file__).dirpath().join('babel') - config = parent.config - if PY2: - if babel_path.common(path) == babel_path: - if path.ext == ".py": - return DoctestModule(path, parent) + if babel_path.common(path) == babel_path: + if path.ext == ".py": + return DoctestModule(path, parent) diff --git a/setup.cfg b/setup.cfg index 8069749f5..c2d8f87e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,7 +3,7 @@ release = sdist bdist_wheel [pytest] norecursedirs = venv* .* _* scripts {args} -doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE ALLOW_UNICODE +doctest_optionflags = ELLIPSIS NORMALIZE_WHITESPACE ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL [bdist_wheel] universal = 1 From 5a633c8fdb39d563ba7b95d1a9d118c2f22dbdad Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 21:53:27 +0200 Subject: [PATCH 139/489] Parametrize test_compatible_classes_in_global_and_localedata test --- tests/test_core.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 9d9548194..4ce92ddbc 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -272,7 +272,18 @@ def test_parse_locale(): assert (core.parse_locale('de_DE.iso885915@euro') == ('de', 'DE', None, None)) -def test_compatible_classes_in_global_and_localedata(): + +@pytest.mark.parametrize('filename', [ + 'babel/global.dat', + 'babel/locale-data/root.dat', + 'babel/locale-data/en.dat', + 'babel/locale-data/en_US.dat', + 'babel/locale-data/en_US_POSIX.dat', + 'babel/locale-data/zh_Hans_CN.dat', + 'babel/locale-data/zh_Hant_TW.dat', + 'babel/locale-data/es_419.dat', +]) +def test_compatible_classes_in_global_and_localedata(filename): # Use pickle module rather than cPickle since cPickle.Unpickler is a method # on Python 2 import pickle @@ -285,15 +296,5 @@ def find_class(self, module, name): raise pickle.UnpicklingError("global '%s.%s' is forbidden" % (module, name)) - def load(filename): - with open(filename, 'rb') as f: - return Unpickler(f).load() - - load('babel/global.dat') - load('babel/locale-data/root.dat') - load('babel/locale-data/en.dat') - load('babel/locale-data/en_US.dat') - load('babel/locale-data/en_US_POSIX.dat') - load('babel/locale-data/zh_Hans_CN.dat') - load('babel/locale-data/zh_Hant_TW.dat') - load('babel/locale-data/es_419.dat') + with open(filename, 'rb') as f: + return Unpickler(f).load() From 03b0a7babfa67020e9af574b9a8dff2e35f0ea60 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 23:33:50 +0200 Subject: [PATCH 140/489] Test that Lithuanian long-form dates are formatted with the correct genitive form of the month Fixes #288 --- tests/test_dates.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_dates.py b/tests/test_dates.py index 1b03cbf00..0f099f31f 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -540,3 +540,10 @@ def test_parse_pattern(): assert (dates.parse_pattern("H:mm' Uhr 'z").format == u'%(H)s:%(mm)s Uhr %(z)s') assert dates.parse_pattern("hh' o''clock'").format == u"%(hh)s o'clock" + + +def test_lithuanian_long_format(): + assert ( + dates.format_date(date(2015, 12, 10), locale='lt_LT', format='long') == + u'2015 m. gruodžio 10 d.' + ) From 0ae0b2c03262ea2d45bc186a0f28282a14e4f678 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sat, 31 Oct 2015 23:32:01 +0100 Subject: [PATCH 141/489] numbers: Remove unneeded trailing spaces Done with coala. --- babel/numbers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/numbers.py b/babel/numbers.py index a15f399bb..ac03e7141 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -33,7 +33,7 @@ def get_currency_name(currency, count=None, locale=LC_NUMERIC): >>> get_currency_name('USD', locale='en_US') u'US Dollar' - + .. versionadded:: 0.9.4 :param currency: the currency code From 27ec384abf02721e003831b93e3bc50e0a831f12 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sat, 31 Oct 2015 23:33:33 +0100 Subject: [PATCH 142/489] Add coafile This allows using coala to check this code. --- .coafile | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .coafile diff --git a/.coafile b/.coafile new file mode 100644 index 000000000..cd1c7f0fc --- /dev/null +++ b/.coafile @@ -0,0 +1,5 @@ +[Default] +bears = SpaceConsistencyBear,LineLengthBear +use_spaces = true +max_line_length = 120 +files = babel/**/*.py From 1a53d5f5e0e6f71eb0b6805f7c06b595c1c1b0ab Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sun, 20 Dec 2015 17:50:52 +0100 Subject: [PATCH 143/489] util.sh: Move out relpath import This was detected by coala, rightfully, as an unused import. --- babel/messages/extract.py | 3 ++- babel/util.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index be2e6303c..8fe3f606c 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -18,10 +18,11 @@ """ import os +from os.path import relpath import sys from tokenize import generate_tokens, COMMENT, NAME, OP, STRING -from babel.util import parse_encoding, pathmatch, relpath +from babel.util import parse_encoding, pathmatch from babel._compat import PY2, text_type from textwrap import dedent diff --git a/babel/util.py b/babel/util.py index 0f3fb9947..1849e8a04 100644 --- a/babel/util.py +++ b/babel/util.py @@ -12,7 +12,6 @@ import codecs from datetime import timedelta, tzinfo import os -from os.path import relpath import re import textwrap from babel._compat import izip, imap From 6bb8a03ed03bab40ca25de9732e1084d889f7b29 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sun, 20 Dec 2015 17:40:41 +0100 Subject: [PATCH 144/489] codestyle: Check for unused python code --- .coafile | 2 +- babel/_compat.py | 3 ++- babel/dates.py | 1 - babel/localtime/__init__.py | 2 +- babel/numbers.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.coafile b/.coafile index cd1c7f0fc..666eefe95 100644 --- a/.coafile +++ b/.coafile @@ -1,5 +1,5 @@ [Default] -bears = SpaceConsistencyBear,LineLengthBear +bears = SpaceConsistencyBear,LineLengthBear,PyUnusedCodeBear use_spaces = true max_line_length = 120 files = babel/**/*.py diff --git a/babel/_compat.py b/babel/_compat.py index 78cd35a37..75abf9eb1 100644 --- a/babel/_compat.py +++ b/babel/_compat.py @@ -45,7 +45,8 @@ from StringIO import StringIO import cPickle as pickle - from itertools import izip, imap + from itertools import imap + from itertools import izip range_type = xrange cmp = cmp diff --git a/babel/dates.py b/babel/dates.py index 56677030f..0b7353880 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1011,7 +1011,6 @@ def format_week(self, char, num): if week == 0: date = self.value - timedelta(days=self.value.day) week = self.get_week_number(date.day, date.weekday()) - pass return '%d' % week def format_weekday(self, char, num): diff --git a/babel/localtime/__init__.py b/babel/localtime/__init__.py index cdb3e9b5d..4cab2364e 100644 --- a/babel/localtime/__init__.py +++ b/babel/localtime/__init__.py @@ -13,7 +13,7 @@ import sys import pytz import time -from datetime import timedelta, datetime +from datetime import timedelta from datetime import tzinfo from threading import RLock diff --git a/babel/numbers.py b/babel/numbers.py index ac03e7141..f0b0ca1bd 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -22,7 +22,7 @@ from datetime import date as date_, datetime as datetime_ from babel.core import default_locale, Locale, get_global -from babel._compat import range_type, Decimal, InvalidOperation, ROUND_HALF_EVEN +from babel._compat import Decimal, InvalidOperation, ROUND_HALF_EVEN LC_NUMERIC = default_locale('LC_NUMERIC') From 5b371279f1e7781d827097e851c509eb836f98d4 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Mon, 21 Dec 2015 10:50:45 +0100 Subject: [PATCH 145/489] catalog: Remove unneeded documentation Those documentation comments aren't adding any new information compared to the documentation comment of their respective functions and are thus redundant. Those comments where introduced first in 7ab115ce with no notice why they exist. --- babel/messages/catalog.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index e289bef76..21499ae6d 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -93,10 +93,10 @@ def __init__(self, id, string=u'', locations=(), flags=(), auto_comments=(), PO file, if any :param context: the message context """ - self.id = id #: The message ID + self.id = id if not string and self.pluralizable: string = (u'', u'') - self.string = string #: The message translation + self.string = string self.locations = list(distinct(locations)) self.flags = set(flags) if id and self.python_format: @@ -274,15 +274,15 @@ def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, :param charset: the encoding to use in the output (defaults to utf-8) :param fuzzy: the fuzzy bit on the catalog header """ - self.domain = domain #: The message domain + self.domain = domain if locale: locale = Locale.parse(locale) - self.locale = locale #: The locale or `None` + self.locale = locale 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.project = project or 'PROJECT' + self.version = version or 'VERSION' self.copyright_holder = copyright_holder or 'ORGANIZATION' self.msgid_bugs_address = msgid_bugs_address or 'EMAIL@ADDRESS' @@ -297,15 +297,15 @@ def __init__(self, locale=None, domain=None, header_comment=DEFAULT_HEADER, 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 + self.creation_date = creation_date if revision_date is None: revision_date = 'YEAR-MO-DA HO:MI+ZONE' 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 - self.fuzzy = fuzzy #: Catalog header fuzzy bit (`True` or `False`) + self.revision_date = revision_date + self.fuzzy = fuzzy - self.obsolete = odict() #: Dictionary of obsolete messages + self.obsolete = odict() # Dictionary of obsolete messages self._num_plurals = None self._plural_expr = None From 4c8515aae11b2faf4ab81c7412df3d89ee65c6ec Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 23:51:18 +0200 Subject: [PATCH 146/489] download_import_cldr: unzip into versioned dir --- scripts/download_import_cldr.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index f8ba1fa92..f9523aac5 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -71,8 +71,9 @@ def is_good_file(filename): def main(): scripts_path = os.path.dirname(os.path.abspath(__file__)) repo = os.path.dirname(scripts_path) - cldr_path = os.path.join(repo, 'cldr') - zip_path = os.path.join(cldr_path, FILENAME) + cldr_dl_path = os.path.join(repo, 'cldr') + cldr_path = os.path.join(repo, 'cldr', os.path.splitext(FILENAME)[0]) + zip_path = os.path.join(cldr_dl_path, FILENAME) changed = False while not is_good_file(zip_path): From 29b1724668a2804c48e8950577741c99fb9e2d57 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 21:53:06 +0200 Subject: [PATCH 147/489] import_cldr: support currency format lengths Non-default currency format lengths acquire colon-separated suffixes like units already do since 9327e0824a1bbed538e73d42b971988f8214b490 --- scripts/import_cldr.py | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index d179604c1..4f70ef9b4 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -600,21 +600,7 @@ def main(): scientific_formats[elem.attrib.get('type')] = \ numbers.parse_pattern(pattern) - currency_formats = data.setdefault('currency_formats', {}) - for elem in tree.findall('.//currencyFormats/currencyFormatLength/currencyFormat'): - if ('draft' in elem.attrib or 'alt' in elem.attrib) \ - and elem.attrib.get('type') in currency_formats: - continue - for child in elem.getiterator(): - if child.tag == 'alias': - currency_formats[elem.attrib.get('type')] = Alias( - _translate_alias(['currency_formats', elem.attrib['type']], - child.attrib['path']) - ) - elif child.tag == 'pattern': - pattern = text_type(child.text) - currency_formats[elem.attrib.get('type')] = \ - numbers.parse_pattern(pattern) + parse_currency_formats(data, tree) percent_formats = data.setdefault('percent_formats', {}) for elem in tree.findall('.//percentFormats/percentFormatLength'): @@ -674,5 +660,27 @@ def main(): outfile.close() +def parse_currency_formats(data, tree): + currency_formats = data.setdefault('currency_formats', {}) + for length_elem in tree.findall('.//currencyFormats/currencyFormatLength'): + curr_length_type = length_elem.attrib.get('type') + for elem in length_elem.findall('currencyFormat'): + type = elem.attrib.get('type') + if curr_length_type: + # Handle ``, etc. + type = '%s:%s' % (type, curr_length_type) + if ('draft' in elem.attrib or 'alt' in elem.attrib) and type in currency_formats: + continue + for child in elem.getiterator(): + if child.tag == 'alias': + currency_formats[type] = Alias( + _translate_alias(['currency_formats', elem.attrib['type']], + child.attrib['path']) + ) + elif child.tag == 'pattern': + pattern = text_type(child.text) + currency_formats[type] = numbers.parse_pattern(pattern) + + if __name__ == '__main__': main() From 6b6c5f1f8d95d24f028be1da3c79a93ff9f3f4ec Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 21:45:07 +0200 Subject: [PATCH 148/489] import_cldr: Add `--force` flag --- scripts/import_cldr.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 4f70ef9b4..b4ceffd4d 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -124,10 +124,14 @@ def _extract_plural_rules(file_path): def main(): parser = OptionParser(usage='%prog path/to/cldr') + parser.add_option( + '-f', '--force', dest='force', action='store_true', default=False, + help='force import even if destination file seems up to date' + ) options, args = parser.parse_args() if len(args) != 1: parser.error('incorrect number of arguments') - + force = bool(options.force) srcdir = args[0] destdir = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), '..', 'babel') @@ -145,7 +149,7 @@ def main(): # Import global data from the supplemental files global_path = os.path.join(destdir, 'global.dat') global_data = {} - if need_conversion(global_path, global_data, sup_filename): + if force or need_conversion(global_path, global_data, sup_filename): territory_zones = global_data.setdefault('territory_zones', {}) zone_aliases = global_data.setdefault('zone_aliases', {}) zone_territories = global_data.setdefault('zone_territories', {}) @@ -290,7 +294,7 @@ def main(): data_filename = os.path.join(destdir, 'locale-data', stem + '.dat') data = {} - if not need_conversion(data_filename, data, full_filename): + if not (force or need_conversion(data_filename, data, full_filename)): continue tree = parse(full_filename) From 23c4a550c6073a1f6948196539d0c8c51c950a3a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 22:15:12 +0200 Subject: [PATCH 149/489] import_cldr: Add `--dump-json` debug flag These JSON files are easier to inspect by eye to figure out what might be going wrong. They are never used by Babel itself. --- .gitignore | 1 + scripts/import_cldr.py | 33 +++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 7c27f0e60..67ba223aa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist test-env **/__pycache__ babel/global.dat +babel/global.dat.json tests/messages/data/project/i18n/long_messages.pot tests/messages/data/project/i18n/temp.pot tests/messages/data/project/i18n/en_US diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index b4ceffd4d..2925acc8a 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -122,16 +122,37 @@ def _extract_plural_rules(file_path): return rule_dict +def debug_repr(obj): + if isinstance(obj, PluralRule): + return obj.abstract + return repr(obj) + + +def write_datafile(path, data, dump_json=False): + with open(path, 'wb') as outfile: + pickle.dump(data, outfile, 2) + if dump_json: + import json + with open(path + '.json', 'w') as outfile: + json.dump(data, outfile, indent=4, default=debug_repr) + + def main(): parser = OptionParser(usage='%prog path/to/cldr') parser.add_option( '-f', '--force', dest='force', action='store_true', default=False, help='force import even if destination file seems up to date' ) + parser.add_option( + '-j', '--json', dest='dump_json', action='store_true', default=False, + help='also export debugging JSON dumps of locale data' + ) + options, args = parser.parse_args() if len(args) != 1: parser.error('incorrect number of arguments') force = bool(options.force) + dump_json = bool(options.dump_json) srcdir = args[0] destdir = os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), '..', 'babel') @@ -255,11 +276,7 @@ def main(): cur_crounding = int(fraction.attrib.get('cashRounding', cur_rounding)) currency_fractions[cur_code] = (cur_digits, cur_rounding, cur_cdigits, cur_crounding) - outfile = open(global_path, 'wb') - try: - pickle.dump(global_data, outfile, 2) - finally: - outfile.close() + write_datafile(global_path, global_data, dump_json=dump_json) # build a territory containment mapping for inheritance regions = {} @@ -657,11 +674,7 @@ def main(): date_fields[field_type].setdefault(rel_time_type, {})\ [pattern.attrib['count']] = text_type(pattern.text) - outfile = open(data_filename, 'wb') - try: - pickle.dump(data, outfile, 2) - finally: - outfile.close() + write_datafile(data_filename, data, dump_json=dump_json) def parse_currency_formats(data, tree): From 9f7f4d02998955719a8d18bfcbec8f700bec9923 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 21:29:21 +0200 Subject: [PATCH 150/489] Update to CLDR 28 (with test updates) * Aside from the usual data changes, the provisional aa locale is no longer, so we can't use it for tests. Instead, ii is used (chosen by virtue of ii.xml being fairly small, i.e. probably as incomplete as aa). * ms_Latn_SG is not in use anymore either; use sr_Latn_ME instead. Closes #226 Closes #290 --- babel/core.py | 8 ++++---- babel/dates.py | 4 ++-- babel/numbers.py | 2 +- scripts/download_import_cldr.py | 9 ++++----- tests/messages/test_plurals.py | 2 +- tests/test_dates.py | 6 +++--- tests/test_numbers.py | 2 +- tests/test_plural.py | 8 ++++---- 8 files changed, 20 insertions(+), 21 deletions(-) diff --git a/babel/core.py b/babel/core.py index 84ee18920..06070d515 100644 --- a/babel/core.py +++ b/babel/core.py @@ -113,10 +113,10 @@ class Locale(object): If a locale is requested for which no locale data is available, an `UnknownLocaleError` is raised: - >>> Locale.parse('en_DE') + >>> Locale.parse('en_XX') Traceback (most recent call last): ... - UnknownLocaleError: unknown locale 'en_DE' + UnknownLocaleError: unknown locale 'en_XX' For more information see :rfc:`3066`. """ @@ -432,8 +432,8 @@ def get_script_name(self, locale=None): 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' + >>> Locale('sr', 'ME', script='Latn').script_name + u'latinica' """) @property diff --git a/babel/dates.py b/babel/dates.py index 0b7353880..ab2d4fb29 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -582,7 +582,7 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, >>> format_datetime(dt, 'full', tzinfo=get_timezone('Europe/Paris'), ... locale='fr_FR') - u'dimanche 1 avril 2007 17:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale' + u'dimanche 1 avril 2007 \xe0 17:30:00 heure d\u2019\xe9t\xe9 d\u2019Europe centrale' >>> format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", ... tzinfo=get_timezone('US/Eastern'), locale='en') u'2007.04.01 AD at 11:30:00 EDT' @@ -742,7 +742,7 @@ def format_timedelta(delta, granularity='second', threshold=.85, The format parameter controls how compact or wide the presentation is: >>> format_timedelta(timedelta(hours=3), format='short', locale='en') - u'3 hrs' + u'3 hr' >>> format_timedelta(timedelta(hours=3), format='narrow', locale='en') u'3h' diff --git a/babel/numbers.py b/babel/numbers.py index f0b0ca1bd..e4b3e8ee4 100644 --- a/babel/numbers.py +++ b/babel/numbers.py @@ -265,7 +265,7 @@ def format_currency(number, currency, format=None, locale=LC_NUMERIC, >>> 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' + u'US$\\xa01.099,98' >>> format_currency(1099.98, 'EUR', locale='de_DE') u'1.099,98\\xa0\\u20ac' diff --git a/scripts/download_import_cldr.py b/scripts/download_import_cldr.py index f9523aac5..4da342349 100755 --- a/scripts/download_import_cldr.py +++ b/scripts/download_import_cldr.py @@ -5,7 +5,6 @@ import shutil import hashlib import zipfile -import urllib import subprocess try: from urllib.request import urlretrieve @@ -13,9 +12,9 @@ from urllib import urlretrieve -URL = 'http://unicode.org/Public/cldr/26/core.zip' -FILENAME = 'core-26.zip' -FILESUM = '46220170238b092685fd24221f895e3d' +URL = 'http://unicode.org/Public/cldr/28/core.zip' +FILENAME = 'core-28.zip' +FILESUM = 'bc545b4c831e1987ea931b04094d7b9fc59ec3d8' BLKSIZE = 131072 @@ -53,7 +52,7 @@ def is_good_file(filename): if not os.path.isfile(filename): log('Local copy \'%s\' not found', filename) return False - h = hashlib.md5() + h = hashlib.sha1() with open(filename, 'rb') as f: while 1: blk = f.read(BLKSIZE) diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py index 8c11745ff..d54857f7b 100644 --- a/tests/messages/test_plurals.py +++ b/tests/messages/test_plurals.py @@ -34,7 +34,7 @@ def test_get_plural_accpets_strings(): def test_get_plural_falls_back_to_default(): - assert plurals.get_plural('aa') == (2, '(n != 1)') + assert plurals.get_plural('ii') == (2, '(n != 1)') def test_plural_tuple_attributes(): diff --git a/tests/test_dates.py b/tests/test_dates.py index 0f099f31f..ea4805bb8 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -282,14 +282,14 @@ def test_zero_seconds(self): self.assertEqual('0 seconds', string) string = dates.format_timedelta(timedelta(seconds=0), locale='en', format='short') - self.assertEqual('0 secs', string) + self.assertEqual('0 sec', 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) + self.assertEqual('0 hr', string) def test_small_value_with_granularity(self): string = dates.format_timedelta(timedelta(seconds=42), @@ -465,7 +465,7 @@ def test_format_datetime(): full = dates.format_datetime(dt, 'full', tzinfo=timezone('Europe/Paris'), locale='fr_FR') - assert full == (u'dimanche 1 avril 2007 17:30:00 heure ' + assert full == (u'dimanche 1 avril 2007 à 17:30:00 heure ' u'd\u2019\xe9t\xe9 d\u2019Europe centrale') custom = dates.format_datetime(dt, "yyyy.MM.dd G 'at' HH:mm:ss zzz", tzinfo=timezone('US/Eastern'), locale='en') diff --git a/tests/test_numbers.py b/tests/test_numbers.py index f042833b3..4b8a48dce 100644 --- a/tests/test_numbers.py +++ b/tests/test_numbers.py @@ -230,7 +230,7 @@ def test_format_currency(): assert (numbers.format_currency(1099.98, 'USD', locale='en_US') == u'$1,099.98') assert (numbers.format_currency(1099.98, 'USD', locale='es_CO') - == u'US$1.099,98') + == u'US$\xa01.099,98') assert (numbers.format_currency(1099.98, 'EUR', locale='de_DE') == u'1.099,98\xa0\u20ac') assert (numbers.format_currency(1099.98, 'EUR', u'\xa4\xa4 #,##0.00', diff --git a/tests/test_plural.py b/tests/test_plural.py index b0cad8da2..fce1b8e0e 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -133,10 +133,10 @@ def test_plural_within_rules(): def test_locales_with_no_plural_rules_have_default(): from babel import Locale - aa_plural = Locale.parse('aa').plural_form - assert aa_plural(1) == 'other' - assert aa_plural(2) == 'other' - assert aa_plural(15) == 'other' + pf = Locale.parse('ii').plural_form + assert pf(1) == 'other' + assert pf(2) == 'other' + assert pf(15) == 'other' WELL_FORMED_TOKEN_TESTS = ( From e47064ed88c5dc5223f87ee8452d02964e9fe373 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Dec 2015 20:16:18 +0200 Subject: [PATCH 151/489] Mark Python 3.5 as supported Fixes #222 Refs #221 --- .ci/appveyor.yml | 8 ++++++++ .travis.yml | 9 +++++++++ setup.py | 2 ++ 3 files changed, 19 insertions(+) diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml index 5a9fd008b..8bec9251e 100644 --- a/.ci/appveyor.yml +++ b/.ci/appveyor.yml @@ -32,6 +32,14 @@ environment: PYTHON_VERSION: "3.4.x" PYTHON_ARCH: "64" + - PYTHON: "C:\\Python35" + PYTHON_VERSION: "3.5.x" + PYTHON_ARCH: "32" + + - PYTHON: "C:\\Python35-x64" + PYTHON_VERSION: "3.5.x" + PYTHON_ARCH: "64" + branches: # Only build official branches, PRs are built anyway. only: - master diff --git a/.travis.yml b/.travis.yml index 7bf5e9c4d..8d5433ebf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ cache: directories: - cldr - "$HOME/.cache/pip" + - "$HOME/.pyenv" matrix: include: @@ -28,6 +29,8 @@ matrix: python: 3.3 - os: linux python: 3.4 + - os: linux + python: 3.5 - os: osx language: generic env: @@ -72,6 +75,12 @@ matrix: - PYTHON_VERSION=3.4.3 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin + - os: osx + language: generic + env: + - PYTHON_VERSION=3.5.1 + - PYENV_ROOT=~/.pyenv + - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin install: - bash .ci/deps.${TRAVIS_OS_NAME}.sh diff --git a/setup.py b/setup.py index 83cd65170..7e70a0320 100755 --- a/setup.py +++ b/setup.py @@ -59,6 +59,8 @@ def run(self): 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', 'Topic :: Software Development :: Libraries :: Python Modules', ], packages=['babel', 'babel.messages', 'babel.localtime'], From 1bcd5a68655d268ee995a02442dae80261e587ac Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Mon, 28 Dec 2015 22:14:05 +0100 Subject: [PATCH 152/489] Install m3-cdecimal in Tox environments. Apparently, the cdecimal package has been renamed to m3-cdecimal. Being unable to install it breaks the test suite, so this should fix the problem. --- tox.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index a5b23e41d..6f552d8b0 100644 --- a/tox.ini +++ b/tox.ini @@ -2,10 +2,8 @@ envlist = py26, py27, pypy, py33, py34, py26-cdecimal, py27-cdecimal [testenv] -install_command = - pip install --allow-external cdecimal {opts} {packages} deps = pytest - cdecimal: cdecimal + cdecimal: m3-cdecimal whitelist_externals = make commands = make clean-cldr test From b5d400f3cfe53bd2683bb95711b7117bc3a5e9f0 Mon Sep 17 00:00:00 2001 From: Isaac Jurado Date: Tue, 29 Dec 2015 01:15:20 +0100 Subject: [PATCH 153/489] Install m3-cdecimal in Travis environments. Follow the cdecimal to m3-cdecimal package rename within TravisCI configuration. --- .travis.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8d5433ebf..29eedd43a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,13 +16,13 @@ matrix: - os: linux python: 2.6 env: - - CDECIMAL=cdecimal + - CDECIMAL=m3-cdecimal - os: linux python: 2.7 - os: linux python: 2.7 env: - - CDECIMAL=cdecimal + - CDECIMAL=m3-cdecimal - os: linux python: pypy - os: linux @@ -43,7 +43,7 @@ matrix: - PYTHON_VERSION=2.6.6 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin - - CDECIMAL=cdecimal + - CDECIMAL=m3-cdecimal - os: osx language: generic env: @@ -56,7 +56,7 @@ matrix: - PYTHON_VERSION=2.7.10 - PYENV_ROOT=~/.pyenv - PATH=$PYENV_ROOT/shims:$PATH:$PYENV_ROOT/bin - - CDECIMAL=cdecimal + - CDECIMAL=m3-cdecimal - os: osx language: generic env: @@ -85,7 +85,7 @@ matrix: install: - bash .ci/deps.${TRAVIS_OS_NAME}.sh - pip install --upgrade pip - - pip install --allow-external cdecimal --upgrade pytest==2.8.5 pytest-cov==2.2.0 $CDECIMAL + - pip install --upgrade pytest==2.8.5 pytest-cov==2.2.0 $CDECIMAL - pip install --editable . script: From ad11101f5e74abd0c6d9dc2d65ecd600b854da22 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Dec 2015 23:21:33 +0200 Subject: [PATCH 154/489] Flatten NullTranslations.files into a list `filter` is special in Python 3, and latter usages would error out in strange ways. Fixes #92 (https://github.com/python-babel/babel/pull/92) Fixes #162 (https://github.com/python-babel/babel/pull/162) --- babel/support.py | 2 +- tests/test_support.py | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/babel/support.py b/babel/support.py index 3b4869cdb..e803138e0 100644 --- a/babel/support.py +++ b/babel/support.py @@ -298,7 +298,7 @@ def __init__(self, fp=None): self._catalog = {} self.plural = lambda n: int(n != 1) super(NullTranslations, self).__init__(fp=fp) - self.files = filter(None, [getattr(fp, 'name', None)]) + self.files = list(filter(None, [getattr(fp, 'name', None)])) self.domain = self.DEFAULT_DOMAIN self._domains = {} diff --git a/tests/test_support.py b/tests/test_support.py index 4647f6b13..efdddeb71 100644 --- a/tests/test_support.py +++ b/tests/test_support.py @@ -22,7 +22,7 @@ from babel import support from babel.messages import Catalog from babel.messages.mofile import write_mo -from babel._compat import BytesIO +from babel._compat import BytesIO, PY2 @pytest.mark.usefixtures("os_environ") @@ -328,3 +328,28 @@ def greeting(name='world'): u"Hello, universe!", u"Hello, world!", ] + + +def test_catalog_merge_files(): + # Refs issues #92, #162 + t1 = support.Translations() + assert t1.files == [] + t1._catalog["foo"] = "bar" + if PY2: + # Explicitly use the pure-Python `StringIO` class, as we need to + # augment it with the `name` attribute, which we can't do for + # `babel._compat.BytesIO`, which is `cStringIO.StringIO` under + # `PY2`... + from StringIO import StringIO + fp = StringIO() + else: + fp = BytesIO() + write_mo(fp, Catalog()) + fp.seek(0) + fp.name = "pro.mo" + t2 = support.Translations(fp) + assert t2.files == ["pro.mo"] + t2._catalog["bar"] = "quux" + t1.merge(t2) + assert t1.files == ["pro.mo"] + assert set(t1._catalog.keys()) == set(('', 'foo', 'bar')) From 2aa80748f6123aa95f752353263c6ca2567b735e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roy=20Wellington=20=E2=85=A3?= Date: Tue, 31 Mar 2015 14:44:10 -0700 Subject: [PATCH 155/489] Add __hash__ to Locale. --- babel/core.py | 3 +++ tests/test_core.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/babel/core.py b/babel/core.py index 84ee18920..2df139c2c 100644 --- a/babel/core.py +++ b/babel/core.py @@ -326,6 +326,9 @@ def __eq__(self, other): def __ne__(self, other): return not self.__eq__(other) + def __hash__(self): + return hash((self.language, self.territory, self.script, self.variant)) + def __repr__(self): parameters = [''] for key in ('territory', 'script', 'variant'): diff --git a/tests/test_core.py b/tests/test_core.py index 4ce92ddbc..c887d5f53 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -57,6 +57,11 @@ def test_get_global(): class TestLocaleClass: + def test_hash(self): + locale_a = Locale('en', 'US') + locale_b = Locale('en', 'US') + assert hash(locale_a) == hash(locale_b) + def test_repr(self): assert repr(Locale('en', 'US')) == "Locale('en', territory='US')" From fd2447fc8b131ed5bb3a0ee9f23295a124ddd8ba Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Tue, 29 Dec 2015 17:55:15 +0200 Subject: [PATCH 156/489] Improve Locale equality and hashing tests --- tests/test_core.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index c887d5f53..48eb9fe7e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -24,18 +24,26 @@ def test_locale_provides_access_to_cldr_locale_data(): assert u'English (United States)' == locale.display_name assert u'.' == locale.number_symbols['decimal'] + def test_locale_repr(): + assert repr(Locale('en', 'US')) == "Locale('en', territory='US')" assert ("Locale('de', territory='DE')" == repr(Locale('de', 'DE'))) assert ("Locale('zh', territory='CN', script='Hans')" == repr(Locale('zh', 'CN', script='Hans'))) def test_locale_comparison(): en_US = Locale('en', 'US') + en_US_2 = Locale('en', 'US') + fi_FI = Locale('fi', 'FI') + bad_en_US = Locale('en_US') assert en_US == en_US + assert en_US == en_US_2 + assert en_US != fi_FI + assert not (en_US != en_US_2) assert None != en_US - - bad_en_US = Locale('en_US') assert en_US != bad_en_US + assert fi_FI != bad_en_US + def test_can_return_default_locale(os_environ): os_environ['LC_MESSAGES'] = 'fr_FR.UTF-8' @@ -55,16 +63,15 @@ def test_get_global(): assert core.get_global('zone_territories')['Europe/Berlin'] == 'DE' -class TestLocaleClass: - - def test_hash(self): - locale_a = Locale('en', 'US') - locale_b = Locale('en', 'US') - assert hash(locale_a) == hash(locale_b) +def test_hash(): + locale_a = Locale('en', 'US') + locale_b = Locale('en', 'US') + locale_c = Locale('fi', 'FI') + assert hash(locale_a) == hash(locale_b) + assert hash(locale_a) != hash(locale_c) - def test_repr(self): - assert repr(Locale('en', 'US')) == "Locale('en', territory='US')" +class TestLocaleClass: def test_attributes(self): locale = Locale('en', 'US') assert locale.language == 'en' @@ -262,6 +269,7 @@ def test_negotiate_locale(): 'ja_JP') assert core.negotiate_locale(['no', 'sv'], ['nb_NO', 'sv_SE']) == 'nb_NO' + def test_parse_locale(): assert core.parse_locale('zh_CN') == ('zh', 'CN', None, None) assert core.parse_locale('zh_Hans_CN') == ('zh', 'CN', 'Hans', None) From 5784501a7584438f83756f260d912b777585ab48 Mon Sep 17 00:00:00 2001 From: Lukas B Date: Tue, 4 Nov 2014 15:49:28 -0800 Subject: [PATCH 157/489] Fix typo and add semicolon in plural_forms Fix typo and add semicolon in plural_forms (missing l in 'plural' and semicolon at the end). It currently produces incorrect plural form string. --- babel/messages/plurals.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/babel/messages/plurals.py b/babel/messages/plurals.py index 05f91102b..cc7b79e3e 100644 --- a/babel/messages/plurals.py +++ b/babel/messages/plurals.py @@ -208,7 +208,7 @@ class _PluralTuple(tuple): The number of plurals used by the locale.""") plural_expr = property(itemgetter(1), doc=""" The plural expression used by the locale.""") - plural_forms = property(lambda x: 'npurals=%s; plural=%s' % x, doc=""" + plural_forms = property(lambda x: 'nplurals=%s; plural=%s;' % x, doc=""" The plural expression used by the catalog or locale.""") def __str__(self): @@ -233,13 +233,13 @@ def get_plural(locale=LC_CTYPE): >>> tup.plural_expr '0' >>> tup.plural_forms - 'npurals=1; plural=0' + 'nplurals=1; plural=0;' Converting the tuple into a string prints the plural forms for a gettext catalog: >>> str(tup) - 'npurals=1; plural=0' + 'nplurals=1; plural=0;' """ locale = Locale.parse(locale) try: From 52a835b0d1b664a514770925de96d586d0f429d5 Mon Sep 17 00:00:00 2001 From: Lukas B Date: Tue, 4 Nov 2014 15:51:39 -0800 Subject: [PATCH 158/489] Beef up test_plurals.py Update test coverage in test_plurals.py --- tests/messages/test_plurals.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/messages/test_plurals.py b/tests/messages/test_plurals.py index 8c11745ff..63ede13e2 100644 --- a/tests/messages/test_plurals.py +++ b/tests/messages/test_plurals.py @@ -37,9 +37,28 @@ def test_get_plural_falls_back_to_default(): assert plurals.get_plural('aa') == (2, '(n != 1)') -def test_plural_tuple_attributes(): - tup = plurals.get_plural("ja") - assert tup.num_plurals == 1 - assert tup.plural_expr == '0' - assert tup.plural_forms == 'npurals=1; plural=0' - assert str(tup) == 'npurals=1; plural=0' +def test_get_plural(): + # See http://localization-guide.readthedocs.org/en/latest/l10n/pluralforms.html for more details. + assert plurals.get_plural(locale='en') == (2, '(n != 1)') + assert plurals.get_plural(locale='ga') == (3, '(n==1 ? 0 : n==2 ? 1 : 2)') + + plural_ja = plurals.get_plural("ja") + assert str(plural_ja) == 'nplurals=1; plural=0;' + assert plural_ja.num_plurals == 1 + assert plural_ja.plural_expr == '0' + assert plural_ja.plural_forms == 'nplurals=1; plural=0;' + + plural_en_US = plurals.get_plural('en_US') + assert str(plural_en_US) == 'nplurals=2; plural=(n != 1);' + assert plural_en_US.num_plurals == 2 + assert plural_en_US.plural_expr == '(n != 1)' + + plural_fr_FR = plurals.get_plural('fr_FR') + assert str(plural_fr_FR) == 'nplurals=2; plural=(n > 1);' + assert plural_fr_FR.num_plurals == 2 + assert plural_fr_FR.plural_expr == '(n > 1)' + + plural_pl_PL = plurals.get_plural('pl_PL') + assert str(plural_pl_PL) == 'nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);' + assert plural_pl_PL.num_plurals == 3 + assert plural_pl_PL.plural_expr == '(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2)' From e0e7ef168856bb431076cfe45f6c456d12091233 Mon Sep 17 00:00:00 2001 From: David Stanek Date: Thu, 5 Sep 2013 13:15:57 -0400 Subject: [PATCH 159/489] Updates a catalog's header to from the template A language specific catalog's initial header is based on the template when it is created. Updates to the template's header were previously not getting into the catalog during the update_catalog process. --- babel/messages/catalog.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index 21499ae6d..f72a34fca 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -797,6 +797,11 @@ def _merge(message, oldkey, newkey): for msgid in remaining: if no_fuzzy_matching or msgid not in fuzzy_matches: self.obsolete[msgid] = remaining[msgid] + + # Allow the updated catalog's header to be rewritten based on the + # template's header + self.header_comment = template.header_comment + # Make updated catalog's POT-Creation-Date equal to the template # used to update the catalog self.creation_date = template.creation_date From 018aa8526c470086b2e10c3eb81ccfcc3525071d Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 30 Dec 2015 16:39:48 +0200 Subject: [PATCH 160/489] Add a test for header_comment updating. --- tests/messages/test_catalog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index 31bb1d140..7d18d80a9 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -428,7 +428,7 @@ def test_catalog_add(): def test_catalog_update(): - template = catalog.Catalog() + template = catalog.Catalog(header_comment="# A Custom Header") template.add('green', locations=[('main.py', 99)]) template.add('blue', locations=[('main.py', 100)]) template.add(('salad', 'salads'), locations=[('util.py', 42)]) @@ -440,6 +440,7 @@ def test_catalog_update(): cat.update(template) assert len(cat) == 3 + assert cat.header_comment == template.header_comment # Header comment also gets updated msg1 = cat['green'] msg1.string From bbacb6c28a07558c51286b491ca4c1c89f1f38a3 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 30 Dec 2015 17:00:25 +0200 Subject: [PATCH 161/489] Post coverage results to Codecov from Appveyor --- .ci/appveyor.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.ci/appveyor.yml b/.ci/appveyor.yml index 8bec9251e..e7b13f20b 100644 --- a/.ci/appveyor.yml +++ b/.ci/appveyor.yml @@ -51,11 +51,12 @@ install: - "python --version" - "python -c \"import struct; print(struct.calcsize('P') * 8)\"" # Build data files - - "pip install --upgrade pytest==2.8.5" + - "pip install --upgrade pytest==2.8.5 pytest-cov==2.2.0 codecov" - "pip install --editable ." - "python setup.py import_cldr" build: false # Not a C# project, build stuff at the test step instead. test_script: - - "%CMD_IN_ENV% python -m pytest" + - "%CMD_IN_ENV% python -m pytest --cov=babel" + - "codecov" From 327bcdf9e83d3e07619912a980e3b1e5eba38a89 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sat, 2 Jan 2016 14:54:14 +0100 Subject: [PATCH 162/489] Release 2.2 --- CHANGES | 44 ++++++++++++++++++++++++++++++++++++-------- babel/__init__.py | 2 +- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGES b/CHANGES index 77c426c2c..dc0e82179 100644 --- a/CHANGES +++ b/CHANGES @@ -1,17 +1,45 @@ Babel Changelog =============== -Version 2.2 +Version 2.3 ----------- -(Feature release, release date to be decided) +(Feature release, release data to be decided) + +Version 2.2 +----------- -- Upgraded data to CLDR 26 -- Add official support for Python 3.4 -- Use the CLDR recommended amount of decimal digits when formatting - currencies (https://github.com/python-babel/babel/issues/139) -- Properly load and use currency format types - (https://github.com/python-babel/babel/issues/201) +(Feature release, released on January 2nd 2016) + +### Bugfixes + +* General: Add __hash__ to Locale. (#303) (2aa8074) +* General: Allow files with BOM if they're UTF-8 (#189) (da87edd) +* General: localedata directory is now locale-data (#109) (2d1882e) +* General: odict: Fix pop method (0a9e97e) +* General: Removed uses of datetime.date class from *.dat files (#174) (94f6830) +* Messages: Fix plural selection for chinese (531f666) +* Messages: Fix typo and add semicolon in plural_forms (5784501) +* Messages: Flatten NullTranslations.files into a list (ad11101) +* Times: FixedOffsetTimezone: fix display of negative offsets (d816803) + +### Features + +* CLDR: Update to CLDR 28 (#292) (9f7f4d0) +* General: Add __copy__ and __deepcopy__ to LazyProxy. (a1cc3f1) +* General: Add official support for Python 3.4 and 3.5 +* General: Improve odict performance by making key search O(1) (6822b7f) +* Locale: Add an ordinal_form property to Locale (#270) (b3f3430) +* Locale: Add support for list formatting (37ce4fa, be6e23d) +* Locale: Check inheritance exceptions first (3ef0d6d) +* Messages: Allow file locations without line numbers (#279) (79bc781) +* Messages: Allow passing a callable to `extract()` (#289) (3f58516) +* Messages: Support 'Language' header field of PO files (#76) (3ce842b) +* Messages: Update catalog headers from templates (e0e7ef1) +* Numbers: Properly load and expose currency format types (#201) (df676ab) +* Numbers: Use cdecimal by default when available (b6169be) +* Numbers: Use the CLDR's suggested number of decimals for format_currency (#139) (201ed50) +* Times: Add format_timedelta(format='narrow') support (edc5eb5) Version 2.1 ----------- diff --git a/babel/__init__.py b/babel/__init__.py index d2e52599c..c58e972fb 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '2.2.0.dev0' +__version__ = '2.2.0' From 80380ffc70ceb2403ad9a3ff678a6d8b13b94301 Mon Sep 17 00:00:00 2001 From: Lasse Schuirmann Date: Sat, 2 Jan 2016 20:29:04 +0100 Subject: [PATCH 163/489] Set version to development --- babel/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/__init__.py b/babel/__init__.py index c58e972fb..d2e52599c 100644 --- a/babel/__init__.py +++ b/babel/__init__.py @@ -21,4 +21,4 @@ negotiate_locale, parse_locale, get_locale_identifier -__version__ = '2.2.0' +__version__ = '2.2.0.dev0' From f9b04a5fb2ee166ca75358ff574f00d1cd62a916 Mon Sep 17 00:00:00 2001 From: Roman Imankulov Date: Tue, 13 Oct 2015 11:18:48 +0000 Subject: [PATCH 164/489] Fix UnicodeEncodeError on file encoding detection If the first line of a python file is not a valid latin-1 string, parse_encoding dies with "UnicodeDecodeError". These strings nonetheless can be valid in some scenarios (for example, Mako extractor uses babel.messages.extract.extract_python), and it makes more sense to ignore this exception and return None. --- babel/util.py | 2 +- tests/test_util.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/babel/util.py b/babel/util.py index 1849e8a04..54f7d2db0 100644 --- a/babel/util.py +++ b/babel/util.py @@ -65,7 +65,7 @@ def parse_encoding(fp): try: import parser parser.suite(line1.decode('latin-1')) - except (ImportError, SyntaxError): + except (ImportError, SyntaxError, UnicodeEncodeError): # Either it's a real syntax error, in which case the source is # not valid python source, or line2 is a continuation of line1, # in which case we don't want to scan line2 for a magic diff --git a/tests/test_util.py b/tests/test_util.py index bb2bfdb4b..d4b4be523 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -14,6 +14,7 @@ import unittest from babel import util +from babel._compat import BytesIO def test_distinct(): @@ -52,3 +53,17 @@ def test_zone_zero_offset(self): def test_zone_positive_offset(self): self.assertEqual('Etc/GMT+330', util.FixedOffsetTimezone(330).zone) + +parse_encoding = lambda s: util.parse_encoding(BytesIO(s.encode('utf-8'))) + + +def test_parse_encoding_defined(): + assert parse_encoding(u'# coding: utf-8') == 'utf-8' + + +def test_parse_encoding_undefined(): + assert parse_encoding(u'') is None + + +def test_parse_encoding_non_ascii(): + assert parse_encoding(u'K\xf6ln') is None From 65e20c11bd8c8edececd54b20795e6a4f42d966e Mon Sep 17 00:00:00 2001 From: Michael Birtwell Date: Mon, 28 Sep 2015 17:44:30 +0100 Subject: [PATCH 165/489] Add support for date-time skeletons The skeletons for dates and times are described on http://cldr.unicode.org/translation/date-time-patterns under Additional Date-Time Formats. And are useful when you want to some more control over formatting dates and times but don't want to force all locales to use the same pattern. --- babel/core.py | 13 +++++++++++++ babel/dates.py | 28 ++++++++++++++++++++++++++++ scripts/import_cldr.py | 5 +++++ tests/test_core.py | 4 ++++ tests/test_dates.py | 9 +++++++++ 5 files changed, 59 insertions(+) diff --git a/babel/core.py b/babel/core.py index 813a61a96..c08cfa737 100644 --- a/babel/core.py +++ b/babel/core.py @@ -731,6 +731,19 @@ def datetime_formats(self): """ return self._data['datetime_formats'] + @property + def datetime_skeletons(self): + """Locale patterns for formatting parts of a datetime. + + >>> Locale('en').datetime_skeletons['MEd'] + + >>> Locale('fr').datetime_skeletons['MEd'] + + >>> Locale('fr').datetime_skeletons['H'] + + """ + return self._data['datetime_skeletons'] + @property def plural_form(self): """Plural rules for the locale. diff --git a/babel/dates.py b/babel/dates.py index ab2d4fb29..4f66adbe5 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -694,6 +694,34 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): return parse_pattern(format).apply(time, locale) +def format_skeleton(skeleton, datetime=None, tzinfo=None, locale=LC_TIME): + r"""Return a time and/or date formatted according to the given pattern. + + The skeletons are defined in the CLDR data and provide more flexibility + than the simple short/long/medium formats, but are a bit harder to use. + The are defined using the date/time symbols without order or punctuation + and map to a suitable format for the given locale. + + >>> t = datetime(2007, 4, 1, 15, 30) + >>> format_skeleton('MMMEd', t, locale='fr') + u'dim. 1 avr.' + >>> format_skeleton('MMMEd', t, locale='en') + u'Sun, Apr 1' + + After the skeleton is resolved to a pattern `format_datetime` is called so + all timezone processing etc is the same as for that. + + :param skeleton: A date time skeleton as defined in the cldr data. + :param datetime: the ``time`` or ``datetime`` object; if `None`, the current + time in UTC is used + :param tzinfo: the time-zone to apply to the time for display + :param locale: a `Locale` object or a locale identifier + """ + locale = Locale.parse(locale) + format = locale.datetime_skeletons[skeleton] + return format_datetime(datetime, format, tzinfo, locale) + + TIMEDELTA_UNITS = ( ('year', 3600 * 24 * 365), ('month', 3600 * 24 * 30), diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 2925acc8a..a5d0ad5dd 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -576,6 +576,7 @@ def main(): ) datetime_formats = data.setdefault('datetime_formats', {}) + datetime_skeletons = data.setdefault('datetime_skeletons', {}) for format in calendar.findall('dateTimeFormats'): for elem in format.getiterator(): if elem.tag == 'dateTimeFormatLength': @@ -591,6 +592,10 @@ def main(): datetime_formats = Alias(_translate_alias( ['datetime_formats'], elem.attrib['path']) ) + elif elem.tag == 'availableFormats': + for datetime_skeleton in elem.findall('dateFormatItem'): + datetime_skeletons[datetime_skeleton.attrib['id']] = \ + dates.parse_pattern(text_type(datetime_skeleton.text)) # diff --git a/tests/test_core.py b/tests/test_core.py index 48eb9fe7e..54cf37dde 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -236,6 +236,10 @@ def test_datetime_formats_property(self): assert Locale('en').datetime_formats['full'] == u"{1} 'at' {0}" assert Locale('th').datetime_formats['medium'] == u'{1} {0}' + def test_datetime_skeleton_property(self): + assert Locale('en').datetime_skeletons['Md'].pattern == u"M/d" + assert Locale('th').datetime_skeletons['Md'].pattern == u'd/M' + def test_plural_form_property(self): assert Locale('en').plural_form(1) == 'one' assert Locale('en').plural_form(0) == 'other' diff --git a/tests/test_dates.py b/tests/test_dates.py index ea4805bb8..30a0ea3d5 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -498,6 +498,15 @@ def test_format_time(): assert us_east == u'3:30:00 PM Eastern Standard Time' +def test_format_skeleton(): + dt = datetime(2007, 4, 1, 15, 30) + assert (dates.format_skeleton('yMEd', dt, locale='en_US') == u'Sun, 4/1/2007') + assert (dates.format_skeleton('yMEd', dt, locale='th') == u'อา. 1/4/2007') + + assert (dates.format_skeleton('EHm', dt, locale='en') == u'Sun 15:30') + assert (dates.format_skeleton('EHm', dt, tzinfo=timezone('Asia/Bangkok'), locale='th') == u'อา. 22:30 น.') + + def test_format_timedelta(): assert (dates.format_timedelta(timedelta(weeks=12), locale='en_US') == u'3 months') From 0ee2920521f683f8e3acd43d444c45b655751adf Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 30 Dec 2015 21:53:12 +0200 Subject: [PATCH 166/489] Frontend: Use Distutils commands also for the CLI --- babel/messages/frontend.py | 588 ++++++-------------------------- tests/messages/test_frontend.py | 3 - 2 files changed, 113 insertions(+), 478 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 56f1b7677..572d71113 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -14,8 +14,8 @@ except ImportError: from configparser import RawConfigParser from datetime import datetime -from distutils import log -from distutils.cmd import Command +from distutils import log as distutils_log +from distutils.cmd import Command as _Command from distutils.errors import DistutilsOptionError, DistutilsSetupError from locale import getpreferredencoding import logging @@ -35,7 +35,31 @@ from babel.messages.mofile import write_mo from babel.messages.pofile import read_po, write_po from babel.util import odict, LOCALTZ -from babel._compat import string_types, StringIO, PY2 +from babel._compat import string_types, StringIO + + +class Command(_Command): + # This class is a small shim between Distutils commands and + # optparse option parsing in the frontend command line. + + #: Option name to be input as `args` on the script command line. + as_args = None + + #: Options which allow multiple values. + multiple_value_options = () + + #: Log object. To allow replacement in the script command line runner. + log = distutils_log + + def __init__(self, dist=None): + # A less strict version of distutils' `__init__`. + self.distribution = dist + self.initialize_options() + self._dry_run = None + self.verbose = False + self.force = None + self.help = 0 + self.finalized = 0 class compile_catalog(Command): @@ -142,19 +166,19 @@ def run(self): percentage = 0 if len(catalog): percentage = translated * 100 // len(catalog) - log.info('%d of %d messages (%d%%) translated in %r', + self.log.info('%d of %d messages (%d%%) translated in %r', translated, len(catalog), percentage, po_file) if catalog.fuzzy and not self.use_fuzzy: - log.warn('catalog %r is marked as fuzzy, skipping', po_file) + self.log.info('catalog %r is marked as fuzzy, skipping', po_file) continue for message, errors in catalog.check(): for error in errors: - log.error('error: %s:%d: %s', po_file, message.lineno, + self.log.error('error: %s:%d: %s', po_file, message.lineno, error) - log.info('compiling catalog %r to %r', po_file, mo_file) + self.log.info('compiling catalog %r to %r', po_file, mo_file) outfile = open(mo_file, 'wb') try: @@ -181,7 +205,7 @@ class extract_messages(Command): description = 'extract localizable strings from the project code' user_options = [ ('charset=', None, - 'charset to use in the output file'), + 'charset to use in the output file (default "utf-8")'), ('keywords=', 'k', 'space-separated list of keywords to look for in addition to the ' 'defaults'), @@ -208,6 +232,10 @@ class extract_messages(Command): 'set report address for msgid'), ('copyright-holder=', None, 'set copyright holder in output'), + ('project=', None, + 'set project name in output'), + ('version=', None, + 'set project version in output'), ('add-comments=', 'c', 'place comment block with TAG (or those preceding keyword lines) in ' 'output file. Separate multiple TAGs with commas(,)'), @@ -221,6 +249,8 @@ class extract_messages(Command): 'no-default-keywords', 'no-location', 'omit-header', 'no-wrap', 'sort-output', 'sort-by-file', 'strip-comments' ] + as_args = 'input-dirs' + multiple_value_options = ('add-comments',) def initialize_options(self): self.charset = 'utf-8' @@ -238,8 +268,9 @@ def initialize_options(self): self.sort_by_file = False self.msgid_bugs_address = None self.copyright_holder = None + self.project = None + self.version = None self.add_comments = None - self._add_comments = [] self.strip_comments = False def finalize_options(self): @@ -266,21 +297,33 @@ def finalize_options(self): "are mutually exclusive") if self.input_dirs: - self.input_dirs = re.split(',\s*', self.input_dirs) + if isinstance(self.input_dirs, string_types): + self.input_dirs = re.split(',\s*', self.input_dirs) else: self.input_dirs = dict.fromkeys([k.split('.',1)[0] - for k in self.distribution.packages + for k in (self.distribution.packages or ()) ]).keys() + if not self.input_dirs: + raise DistutilsOptionError("no input directories specified") + if self.add_comments: - self._add_comments = self.add_comments.split(',') + if isinstance(self.add_comments, string_types): + self.add_comments = self.add_comments.split(',') + else: + self.add_comments = [] + + if self.distribution: + if not self.project: + self.project = self.distribution.get_name() + if not self.version: + self.version = self.distribution.get_version() def run(self): mappings = self._get_mappings() - outfile = open(self.output_file, 'wb') - try: - catalog = Catalog(project=self.distribution.get_name(), - version=self.distribution.get_version(), + with open(self.output_file, 'wb') as outfile: + catalog = Catalog(project=self.project, + version=self.version, msgid_bugs_address=self.msgid_bugs_address, copyright_holder=self.copyright_holder, charset=self.charset) @@ -294,11 +337,11 @@ def callback(filename, method, options): if options: optstr = ' (%s)' % ', '.join(['%s="%s"' % (k, v) for k, v in options.items()]) - log.info('extracting messages from %s%s', filepath, optstr) + self.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, + comment_tags=self.add_comments, callback=callback, strip_comment_tags= self.strip_comments) @@ -307,14 +350,12 @@ def callback(filename, method, options): catalog.add(message, None, [(filepath, lineno)], auto_comments=comments, context=context) - log.info('writing PO template file to %s' % self.output_file) + self.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 = {} @@ -436,7 +477,7 @@ def finalize_options(self): self.width = int(self.width) def run(self): - log.info('creating catalog %r based on %r', self.output_file, + self.log.info('creating catalog %r based on %r', self.output_file, self.input_file) infile = open(self.input_file, 'rb') @@ -564,8 +605,7 @@ def run(self): raise DistutilsOptionError('no message catalogs found') for locale, filename in po_files: - log.info('updating catalog %r based on %r', filename, - self.input_file) + self.log.info('updating catalog %r based on %r', filename, self.input_file) infile = open(filename, 'rb') try: catalog = read_po(infile, locale=locale, domain=domain) @@ -618,11 +658,22 @@ class CommandLineInterface(object): 'update': 'update existing message catalogs from a POT file' } - def run(self, argv=sys.argv): + command_classes = { + 'compile': compile_catalog, + 'extract': extract_messages, + 'init': init_catalog, + 'update': update_catalog, + } + + def run(self, argv=None): """Main entry point of the command-line interface. :param argv: list of arguments passed on the command-line """ + + if argv is None: + argv = sys.argv + self.parser = OptionParser(usage=self.usage % ('command', '[args]'), version=self.version) self.parser.disable_interspersed_args() @@ -662,7 +713,7 @@ def run(self, argv=sys.argv): if cmdname not in self.commands: self.parser.error('unknown command "%s"' % cmdname) - return getattr(self, cmdname)(args[1:]) + return self._dispatch(cmdname, args[1:]) def _configure_logging(self, loglevel): self.log = logging.getLogger('babel') @@ -688,463 +739,50 @@ def _help(self): for name, description in commands: print(format % (name, description)) - def compile(self, argv): - """Subcommand for compiling a message catalog to a MO file. - - :param argv: the command arguments - :since: version 0.9 - """ - parser = OptionParser(usage=self.usage % ('compile', ''), - description=self.commands['compile']) - parser.add_option('--domain', '-D', dest='domain', - help="domain of MO and PO files (default '%default')") - parser.add_option('--directory', '-d', dest='directory', - metavar='DIR', help='base directory of catalog files') - parser.add_option('--locale', '-l', dest='locale', metavar='LOCALE', - help='locale of the catalog') - parser.add_option('--input-file', '-i', dest='input_file', - metavar='FILE', help='name of the input file') - parser.add_option('--output-file', '-o', dest='output_file', - metavar='FILE', - help="name of the output file (default " - "'//LC_MESSAGES/" - ".mo')") - parser.add_option('--use-fuzzy', '-f', dest='use_fuzzy', - action='store_true', - help='also include fuzzy translations (default ' - '%default)') - parser.add_option('--statistics', dest='statistics', - action='store_true', - help='print statistics about translations') - - parser.set_defaults(domain='messages', use_fuzzy=False, - compile_all=False, statistics=False) - options, args = parser.parse_args(argv) - - po_files = [] - mo_files = [] - if not options.input_file: - if not options.directory: - parser.error('you must specify either the input file or the ' - 'base directory') - if options.locale: - po_files.append((options.locale, - os.path.join(options.directory, - options.locale, 'LC_MESSAGES', - options.domain + '.po'))) - mo_files.append(os.path.join(options.directory, options.locale, - 'LC_MESSAGES', - options.domain + '.mo')) - else: - for locale in os.listdir(options.directory): - po_file = os.path.join(options.directory, locale, - 'LC_MESSAGES', options.domain + '.po') - if os.path.exists(po_file): - po_files.append((locale, po_file)) - mo_files.append(os.path.join(options.directory, locale, - 'LC_MESSAGES', - options.domain + '.mo')) - else: - po_files.append((options.locale, options.input_file)) - if options.output_file: - mo_files.append(options.output_file) - else: - if not options.directory: - parser.error('you must specify either the output file or ' - 'the base directory') - mo_files.append(os.path.join(options.directory, options.locale, - 'LC_MESSAGES', - options.domain + '.mo')) - if not po_files: - parser.error('no message catalogs found') - - for idx, (locale, po_file) in enumerate(po_files): - mo_file = mo_files[idx] - infile = open(po_file, 'rb') - try: - catalog = read_po(infile, locale) - finally: - infile.close() - - if options.statistics: - translated = 0 - for message in list(catalog)[1:]: - if message.string: - translated +=1 - percentage = 0 - if len(catalog): - percentage = translated * 100 // len(catalog) - self.log.info("%d of %d messages (%d%%) translated in %r", - translated, len(catalog), percentage, po_file) - - if catalog.fuzzy and not options.use_fuzzy: - self.log.warning('catalog %r is marked as fuzzy, skipping', - po_file) - continue - - for message, errors in catalog.check(): - for error in errors: - self.log.error('error: %s:%d: %s', po_file, message.lineno, - error) - - self.log.info('compiling catalog %r to %r', po_file, mo_file) - - outfile = open(mo_file, 'wb') - try: - write_mo(outfile, catalog, use_fuzzy=options.use_fuzzy) - finally: - outfile.close() - - 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.commands['extract']) - parser.add_option('--charset', dest='charset', - help='charset to use in the output (default ' - '"%default")') - 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 76)") - 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('--project', dest='project', - help='set project name in output') - parser.add_option('--version', dest='version', - help='set project version 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.add_option('--strip-comment-tags', '-s', - dest='strip_comment_tags', action='store_true', - help='Strip the comment tags from the comments.') - - parser.set_defaults(charset='utf-8', keywords=[], - no_default_keywords=False, no_location=False, - omit_header = False, width=None, no_wrap=False, - sort_output=False, sort_by_file=False, - comment_tags=[], strip_comment_tags=False) - options, args = parser.parse_args(argv) - if not args: - parser.error('incorrect number of arguments') - - 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 - - if options.sort_output and options.sort_by_file: - parser.error("'--sort-output' and '--sort-by-file' are mutually " - "exclusive") - - 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) - - catalog_charset = catalog.charset - if options.output not in (None, '-'): - self.log.info('writing PO template file to %s' % options.output) - outfile = open(options.output, 'wb') - close_output = True - else: - outfile = sys.stdout - - # This is a bit of a hack on Python 3. stdout is a text stream so - # we need to find the underlying file when we write the PO. In - # later versions of Babel we want the write_po function to accept - # text or binary streams and automatically adjust the encoding. - if not PY2 and hasattr(outfile, 'buffer'): - catalog.charset = outfile.encoding - outfile = outfile.buffer.raw - - close_output = False - - 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 close_output: - outfile.close() - catalog.charset = catalog_charset - - def init(self, argv): - """Subcommand for creating new message catalogs from a template. - - :param argv: the command arguments + def _dispatch(self, cmdname, argv): """ - parser = OptionParser(usage=self.usage % ('init', ''), - description=self.commands['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.add_option('-w', '--width', dest='width', type='int', - help="set output line width (default 76)") - 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.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 as 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)) - 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 - - infile = open(options.input_file, 'r') - try: - # Although reading from the catalog template, read_po must be fed - # the locale in order to correctly calculate plurals - catalog = read_po(infile, locale=options.locale) - finally: - infile.close() - - catalog.locale = locale - catalog.revision_date = datetime.now(LOCALTZ) - - self.log.info('creating catalog %r based on %r', options.output_file, - options.input_file) - - outfile = open(options.output_file, 'wb') - try: - write_po(outfile, catalog, width=options.width) - finally: - outfile.close() - - def update(self, argv): - """Subcommand for updating existing message catalogs from a template. - - :param argv: the command arguments - :since: version 0.9 + :type cmdname: str + :type argv: list[str] """ - parser = OptionParser(usage=self.usage % ('update', ''), - description=self.commands['update']) - 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 of the translations catalog') - parser.add_option('-w', '--width', dest='width', type='int', - help="set output line width (default 76)") - 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('--ignore-obsolete', dest='ignore_obsolete', - action='store_true', - help='do not include obsolete messages in the output ' - '(default %default)') - parser.add_option('--no-fuzzy-matching', '-N', dest='no_fuzzy_matching', - action='store_true', - help='do not use fuzzy matching (default %default)') - parser.add_option('--previous', dest='previous', action='store_true', - help='keep previous msgids of translated messages ' - '(default %default)') - - parser.set_defaults(domain='messages', ignore_obsolete=False, - no_fuzzy_matching=False, previous=False) + cmdclass = self.command_classes[cmdname] + cmdinst = cmdclass() + cmdinst.log = self.log # Use our logger, not distutils'. + assert isinstance(cmdinst, Command) + cmdinst.initialize_options() + + parser = OptionParser( + usage=self.usage % (cmdname, ''), + description=self.commands[cmdname] + ) + as_args = getattr(cmdclass, "as_args", ()) + for long, short, help in cmdclass.user_options: + name = long.strip("=") + default = getattr(cmdinst, name.replace('-', '_')) + strs = ["--%s" % name] + if short: + strs.append("-%s" % short) + if name == as_args: + parser.usage += "<%s>" % name + elif name in cmdclass.boolean_options: + parser.add_option(*strs, action="store_true", help=help) + elif name in cmdclass.multiple_value_options: + parser.add_option(*strs, action="append", help=help) + else: + parser.add_option(*strs, help=help, default=default) options, args = parser.parse_args(argv) - 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 options.output_file and not options.locale: - parser.error('you must specify the locale') - if options.no_fuzzy_matching and options.previous: - options.previous = False + if as_args: + setattr(options, as_args.replace('-', '_'), args) - po_files = [] - if not options.output_file: - if options.locale: - po_files.append((options.locale, - os.path.join(options.output_dir, - options.locale, 'LC_MESSAGES', - options.domain + '.po'))) - else: - for locale in os.listdir(options.output_dir): - po_file = os.path.join(options.output_dir, locale, - 'LC_MESSAGES', - options.domain + '.po') - if os.path.exists(po_file): - po_files.append((locale, po_file)) - else: - po_files.append((options.locale, options.output_file)) - - domain = options.domain - if not domain: - domain = os.path.splitext(os.path.basename(options.input_file))[0] + for key, value in vars(options).items(): + setattr(cmdinst, key, value) - infile = open(options.input_file, 'U') try: - template = read_po(infile) - finally: - infile.close() - - if not po_files: - parser.error('no message catalogs found') - - 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 - for locale, filename in po_files: - self.log.info('updating catalog %r based on %r', filename, - options.input_file) - infile = open(filename, 'U') - try: - catalog = read_po(infile, locale=locale, domain=domain) - finally: - infile.close() - - catalog.update(template, options.no_fuzzy_matching) - - tmpname = os.path.join(os.path.dirname(filename), - tempfile.gettempprefix() + - os.path.basename(filename)) - tmpfile = open(tmpname, 'wb') - try: - try: - write_po(tmpfile, catalog, - ignore_obsolete=options.ignore_obsolete, - include_previous=options.previous, - width=options.width) - finally: - tmpfile.close() - except: - os.remove(tmpname) - raise + cmdinst.ensure_finalized() + except DistutilsOptionError as err: + parser.error(str(err)) - try: - os.rename(tmpname, filename) - except OSError: - # We're probably on Windows, which doesn't support atomic - # renames, at least not through Python - # If the error is in fact due to a permissions problem, that - # same error is going to be raised from one of the following - # operations - os.remove(filename) - shutil.copy(tmpname, filename) - os.remove(tmpname) + cmdinst.run() def main(): diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 4d26df50e..4676adc14 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -882,7 +882,6 @@ def test_init_with_output_dir(self): # project. # FIRST AUTHOR , 2007. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: TestProject 0.1\n" @@ -933,7 +932,6 @@ def test_init_singular_plural_forms(self): # project. # FIRST AUTHOR , 2007. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: TestProject 0.1\n" @@ -980,7 +978,6 @@ def test_init_more_than_2_plural_forms(self): # project. # FIRST AUTHOR , 2007. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: TestProject 0.1\n" From 9fb69847349a9a74e29dfd61fbc023e826d05060 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 30 Dec 2015 22:30:12 +0200 Subject: [PATCH 167/489] Slightly tidy up frontend.py --- babel/messages/frontend.py | 71 +++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 32 deletions(-) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 572d71113..5f6b14165 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -9,33 +9,34 @@ :license: BSD, see LICENSE for more details. """ from __future__ import print_function -try: - from ConfigParser import RawConfigParser -except ImportError: - from configparser import RawConfigParser -from datetime import datetime -from distutils import log as distutils_log -from distutils.cmd import Command as _Command -from distutils.errors import DistutilsOptionError, DistutilsSetupError -from locale import getpreferredencoding + import logging -from optparse import OptionParser +import optparse import os import re import shutil import sys import tempfile +from datetime import datetime +from locale import getpreferredencoding from babel import __version__ as VERSION from babel import Locale, localedata +from babel._compat import StringIO, string_types 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.extract import DEFAULT_KEYWORDS, DEFAULT_MAPPING, extract_from_dir from babel.messages.mofile import write_mo from babel.messages.pofile import read_po, write_po -from babel.util import odict, LOCALTZ -from babel._compat import string_types, StringIO +from babel.util import LOCALTZ, odict +from distutils import log as distutils_log +from distutils.cmd import Command as _Command +from distutils.errors import DistutilsOptionError, DistutilsSetupError + +try: + from ConfigParser import RawConfigParser +except ImportError: + from configparser import RawConfigParser class Command(_Command): @@ -162,12 +163,14 @@ def run(self): translated = 0 for message in list(catalog)[1:]: if message.string: - translated +=1 + translated += 1 percentage = 0 if len(catalog): percentage = translated * 100 // len(catalog) - self.log.info('%d of %d messages (%d%%) translated in %r', - translated, len(catalog), percentage, po_file) + self.log.info( + '%d of %d messages (%d%%) translated in %r', + translated, len(catalog), percentage, po_file + ) if catalog.fuzzy and not self.use_fuzzy: self.log.info('catalog %r is marked as fuzzy, skipping', po_file) @@ -175,8 +178,9 @@ def run(self): for message, errors in catalog.check(): for error in errors: - self.log.error('error: %s:%d: %s', po_file, message.lineno, - error) + self.log.error( + 'error: %s:%d: %s', po_file, message.lineno, error + ) self.log.info('compiling catalog %r to %r', po_file, mo_file) @@ -300,7 +304,8 @@ def finalize_options(self): if isinstance(self.input_dirs, string_types): self.input_dirs = re.split(',\s*', self.input_dirs) else: - self.input_dirs = dict.fromkeys([k.split('.',1)[0] + self.input_dirs = dict.fromkeys([ + k.split('.', 1)[0] for k in (self.distribution.packages or ()) ]).keys() @@ -339,12 +344,13 @@ def callback(filename, method, options): 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=self._keywords, - comment_tags=self.add_comments, - callback=callback, - strip_comment_tags= - self.strip_comments) + extracted = extract_from_dir( + dirname, method_map, options_map, + keywords=self._keywords, + comment_tags=self.add_comments, + callback=callback, + strip_comment_tags=self.strip_comments + ) for filename, lineno, message, comments, context in extracted: filepath = os.path.normpath(os.path.join(dirname, filename)) catalog.add(message, None, [(filepath, lineno)], @@ -477,8 +483,9 @@ def finalize_options(self): self.width = int(self.width) def run(self): - self.log.info('creating catalog %r based on %r', self.output_file, - self.input_file) + self.log.info( + 'creating catalog %r based on %r', self.output_file, self.input_file + ) infile = open(self.input_file, 'rb') try: @@ -674,8 +681,8 @@ def run(self, argv=None): if argv is None: argv = sys.argv - self.parser = OptionParser(usage=self.usage % ('command', '[args]'), - version=self.version) + self.parser = optparse.OptionParser(usage=self.usage % ('command', '[args]'), + version=self.version) self.parser.disable_interspersed_args() self.parser.print_help = self._help self.parser.add_option('--list-locales', dest='list_locales', @@ -750,7 +757,7 @@ def _dispatch(self, cmdname, argv): assert isinstance(cmdinst, Command) cmdinst.initialize_options() - parser = OptionParser( + parser = optparse.OptionParser( usage=self.usage % (cmdname, ''), description=self.commands[cmdname] ) @@ -843,7 +850,7 @@ def parse_mapping(fileobj, filename=None): options_map = {} parser = RawConfigParser() - parser._sections = odict(parser._sections) # We need ordered sections + parser._sections = odict(parser._sections) # We need ordered sections parser.readfp(fileobj, filename) for section in parser.sections(): if section == 'extractors': From fd22a8e363db44aff153686cd6cc0f64fff1fdf8 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 31 Dec 2015 02:46:08 +0200 Subject: [PATCH 168/489] Add test for frontend update_command --- .gitignore | 2 +- tests/messages/test_frontend.py | 37 +++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 67ba223aa..d8f8bc164 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,6 @@ test-env babel/global.dat babel/global.dat.json tests/messages/data/project/i18n/long_messages.pot -tests/messages/data/project/i18n/temp.pot +tests/messages/data/project/i18n/temp* tests/messages/data/project/i18n/en_US /venv* diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 4676adc14..f958b8050 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -24,9 +24,9 @@ from babel import __version__ as VERSION from babel.dates import format_datetime -from babel.messages import frontend +from babel.messages import frontend, Catalog from babel.util import LOCALTZ -from babel.messages.pofile import read_po +from babel.messages.pofile import read_po, write_po from babel._compat import StringIO @@ -1059,6 +1059,39 @@ def test_compile_catalog_with_more_than_2_plural_forms(self): if os.path.isfile(mo_file): os.unlink(mo_file) + def test_update(self): + template = Catalog() + template.add("1") + template.add("2") + template.add("3") + tmpl_file = os.path.join(self._i18n_dir(), 'temp-template.pot') + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + po_file = os.path.join(self._i18n_dir(), 'temp1.po') + self.cli.run(sys.argv + ['init', + '-l', 'fi', + '-o', po_file, + '-i', tmpl_file + ]) + with open(po_file, "r") as infp: + catalog = read_po(infp) + assert len(catalog) == 3 + + # Add another entry to the template + + template.add("4") + + with open(tmpl_file, "wb") as outfp: + write_po(outfp, template) + + self.cli.run(sys.argv + ['update', + '-l', 'fi_FI', + '-o', po_file, + '-i', tmpl_file]) + + with open(po_file, "r") as infp: + catalog = read_po(infp) + assert len(catalog) == 4 # Catalog was updated def test_parse_mapping(): buf = StringIO( From 4f60b3ebde4a462ab686129982dc13f3d840617a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Dec 2015 22:55:42 +0200 Subject: [PATCH 169/489] pofile: sort obsolete messages too --- babel/messages/pofile.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 226ac1ce9..e4c00afd9 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -474,7 +474,13 @@ def _write_message(message, prefix=''): _write('\n') if not ignore_obsolete: - for message in catalog.obsolete.values(): + obsolete = list(catalog.obsolete.values()) + if sort_output: + obsolete.sort() + elif sort_by_file: + obsolete.sort(key=lambda m: m.locations) + + for message in obsolete: for comment in message.user_comments: _write_comment(comment) _write_message(message, prefix='#~ ') From bc59c9c64ed285888a1bd929d55abbb63212e95b Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 23 Dec 2015 22:57:34 +0200 Subject: [PATCH 170/489] pofile: always sort message locations Fixes #77 --- babel/messages/pofile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index e4c00afd9..dfc78fecb 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -453,7 +453,7 @@ def _write_message(message, prefix=''): if not no_location: locs = [] - for filename, lineno in message.locations: + for filename, lineno in sorted(message.locations): if lineno: locs.append(u'%s:%d' % (filename.replace(os.sep, '/'), lineno)) else: From edce8eeaa594e277d3abb29fb419cf6e4fefb52a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Thu, 24 Dec 2015 20:06:28 +0200 Subject: [PATCH 171/489] pofile: Use `.sort(key=...)`, not `cmp` Fixes #79 (https://github.com/python-babel/babel/issues/79) --- babel/messages/pofile.py | 2 +- tests/messages/test_pofile.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index dfc78fecb..3c18226da 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -431,7 +431,7 @@ def _write_message(message, prefix=''): if sort_output: messages.sort() elif sort_by_file: - messages.sort(lambda x,y: cmp(x.locations, y.locations)) + messages.sort(key=lambda m: m.locations) for message in messages: if not message.id: # This is the header "message" diff --git a/tests/messages/test_pofile.py b/tests/messages/test_pofile.py index 4e991c63c..f375a1a42 100644 --- a/tests/messages/test_pofile.py +++ b/tests/messages/test_pofile.py @@ -513,6 +513,15 @@ def test_sorted_po(self): msgstr[1] "Voeh"''' in value assert value.find(b'msgid ""') < value.find(b'msgid "bar"') < value.find(b'msgid "foo"') + def test_file_sorted_po(self): + catalog = Catalog() + catalog.add(u'bar', locations=[('utils.py', 3)]) + catalog.add((u'foo', u'foos'), (u'Voh', u'Voeh'), locations=[('main.py', 1)]) + buf = BytesIO() + pofile.write_po(buf, catalog, sort_by_file=True) + value = buf.getvalue().strip() + assert value.find(b'main.py') < value.find(b'utils.py') + def test_file_with_no_lineno(self): catalog = Catalog() catalog.add(u'bar', locations=[('utils.py', None)], From b4b9ab603b4bcf12620b860dcd78dd3857adcd37 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:38:48 +0200 Subject: [PATCH 172/489] Fix CHANGES formatting to ReST --- CHANGES | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES b/CHANGES index dc0e82179..551b82a7a 100644 --- a/CHANGES +++ b/CHANGES @@ -11,7 +11,8 @@ Version 2.2 (Feature release, released on January 2nd 2016) -### Bugfixes +Bugfixes +~~~~~~~~ * General: Add __hash__ to Locale. (#303) (2aa8074) * General: Allow files with BOM if they're UTF-8 (#189) (da87edd) @@ -23,7 +24,8 @@ Version 2.2 * Messages: Flatten NullTranslations.files into a list (ad11101) * Times: FixedOffsetTimezone: fix display of negative offsets (d816803) -### Features +Features +~~~~~~~~ * CLDR: Update to CLDR 28 (#292) (9f7f4d0) * General: Add __copy__ and __deepcopy__ to LazyProxy. (a1cc3f1) From 1e1e9913d35d1bf0e10f9471479d7618d9ff4df4 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:39:08 +0200 Subject: [PATCH 173/489] Add docs for `babel.lists` --- babel/lists.py | 4 ++-- docs/api/index.rst | 1 + docs/api/lists.rst | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 docs/api/lists.rst diff --git a/babel/lists.py b/babel/lists.py index 6e6fbd192..82e5590c1 100644 --- a/babel/lists.py +++ b/babel/lists.py @@ -21,9 +21,9 @@ def format_list(lst, locale=DEFAULT_LOCALE): - """ Formats `lst` as a list + """ + Format the items in `lst` as a list. - e.g. >>> format_list(['apples', 'oranges', 'pears'], 'en') u'apples, oranges, and pears' >>> format_list(['apples', 'oranges', 'pears'], 'zh') diff --git a/docs/api/index.rst b/docs/api/index.rst index e21fa74ed..b882d300b 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -9,6 +9,7 @@ public API of Babel. core dates + lists messages/index numbers plural diff --git a/docs/api/lists.rst b/docs/api/lists.rst new file mode 100644 index 000000000..d526b6a73 --- /dev/null +++ b/docs/api/lists.rst @@ -0,0 +1,8 @@ +List Formatting +=============== + +.. module:: babel.lists + +This module lets you format lists of items in a locale-dependent manner. + +.. autofunction:: format_list From ef9046affe61016866ed49322e1fcaca75ee0382 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:39:35 +0200 Subject: [PATCH 174/489] Refer `format_skeleton` in docs --- docs/api/dates.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/api/dates.rst b/docs/api/dates.rst index 7186350f2..1b22cd74b 100644 --- a/docs/api/dates.rst +++ b/docs/api/dates.rst @@ -17,6 +17,8 @@ Date and Time Formatting .. autofunction:: format_timedelta +.. autofunction:: format_skeleton + Timezone Functionality ---------------------- From 896b803dd64b048ed310c75bef9ba2a9975a5320 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:39:43 +0200 Subject: [PATCH 175/489] Update docs metadata --- docs/conf.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index ed75a531f..3a51901a7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -44,16 +44,16 @@ # General information about the project. project = u'Babel' -copyright = u'2013, The Babel Team' +copyright = u'2016, The Babel Team' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = '1.0' +version = '2.2' # The full version, including alpha/beta/rc tags. -release = '1.0' +release = '2.2' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. From 56e36d545065602c7ea71dad5f581a2f0091d204 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:27:58 +0200 Subject: [PATCH 176/489] doc: Add CLDR data volatility notes Closes #317 --- babel/core.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/babel/core.py b/babel/core.py index c08cfa737..495015d67 100644 --- a/babel/core.py +++ b/babel/core.py @@ -529,6 +529,9 @@ def currency_symbols(self): def number_symbols(self): """Symbols used in number formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('fr', 'FR').number_symbols['decimal'] u',' """ @@ -538,6 +541,9 @@ def number_symbols(self): def decimal_formats(self): """Locale patterns for decimal number formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').decimal_formats[None] """ @@ -547,6 +553,9 @@ def decimal_formats(self): def currency_formats(self): """Locale patterns for currency number formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').currency_formats['standard'] >>> Locale('en', 'US').currency_formats['accounting'] @@ -558,6 +567,9 @@ def currency_formats(self): def percent_formats(self): """Locale patterns for percent number formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').percent_formats[None] """ @@ -567,6 +579,9 @@ def percent_formats(self): def scientific_formats(self): """Locale patterns for scientific number formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').scientific_formats[None] """ @@ -614,6 +629,9 @@ def quarters(self): def eras(self): """Locale display names for eras. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').eras['wide'][1] u'Anno Domini' >>> Locale('en', 'US').eras['abbreviated'][0] @@ -625,6 +643,9 @@ def eras(self): def time_zones(self): """Locale display names for time zones. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').time_zones['Europe/London']['long']['daylight'] u'British Summer Time' >>> Locale('en', 'US').time_zones['America/St_Johns']['city'] @@ -639,6 +660,9 @@ def meta_zones(self): Meta time zones are basically groups of different Olson time zones that have the same GMT offset and daylight savings time. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').meta_zones['Europe_Central']['long']['daylight'] u'Central European Summer Time' @@ -650,6 +674,9 @@ def meta_zones(self): def zone_formats(self): """Patterns related to the formatting of time zones. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').zone_formats['fallback'] u'%(1)s (%(0)s)' >>> Locale('pt', 'BR').zone_formats['region'] @@ -702,6 +729,9 @@ def min_week_days(self): def date_formats(self): """Locale patterns for date formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').date_formats['short'] >>> Locale('fr', 'FR').date_formats['long'] @@ -713,6 +743,9 @@ def date_formats(self): def time_formats(self): """Locale patterns for time formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en', 'US').time_formats['short'] >>> Locale('fr', 'FR').time_formats['long'] @@ -724,6 +757,9 @@ def time_formats(self): def datetime_formats(self): """Locale patterns for datetime formatting. + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en').datetime_formats['full'] u"{1} 'at' {0}" >>> Locale('th').datetime_formats['medium'] @@ -763,6 +799,9 @@ def plural_form(self): def list_patterns(self): """Patterns for generating lists + .. note:: The format of the value returned may change between + Babel versions. + >>> Locale('en').list_patterns['start'] u'{0}, {1}' >>> Locale('en').list_patterns['end'] From 28fe6775b93beaa7a16b49af95d1e895864fd7e9 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:17:05 +0200 Subject: [PATCH 177/489] Make catalog header updating an option The change in e0e7ef168856bb had an unexpected and likely undesired effect when updating catalogs with, e.g. translator names in the header comment. It's better to make the updating an option and revert back to the pre-2.2 behavior by default. Fixes https://github.com/python-babel/babel/issues/318 --- babel/messages/catalog.py | 9 +++++---- babel/messages/frontend.py | 10 ++++++++-- tests/messages/test_catalog.py | 4 +++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/babel/messages/catalog.py b/babel/messages/catalog.py index f72a34fca..ca4a5680c 100644 --- a/babel/messages/catalog.py +++ b/babel/messages/catalog.py @@ -673,7 +673,7 @@ def delete(self, id, context=None): if key in self._messages: del self._messages[key] - def update(self, template, no_fuzzy_matching=False): + def update(self, template, no_fuzzy_matching=False, update_header_comment=False): """Update the catalog based on the given template catalog. >>> from babel.messages import Catalog @@ -798,9 +798,10 @@ def _merge(message, oldkey, newkey): if no_fuzzy_matching or msgid not in fuzzy_matches: self.obsolete[msgid] = remaining[msgid] - # Allow the updated catalog's header to be rewritten based on the - # template's header - self.header_comment = template.header_comment + if update_header_comment: + # Allow the updated catalog's header to be rewritten based on the + # template's header + self.header_comment = template.header_comment # Make updated catalog's POT-Creation-Date equal to the template # used to update the catalog diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 5f6b14165..d9919f631 100755 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -545,10 +545,12 @@ class update_catalog(Command): 'whether to omit obsolete messages from the output'), ('no-fuzzy-matching', 'N', 'do not use fuzzy matching'), + ('update-header-comment', None, + 'update target header comment'), ('previous', None, 'keep previous msgids of translated messages') ] - boolean_options = ['ignore_obsolete', 'no_fuzzy_matching', 'previous'] + boolean_options = ['ignore_obsolete', 'no_fuzzy_matching', 'previous', 'update_header_comment'] def initialize_options(self): self.domain = 'messages' @@ -560,6 +562,7 @@ def initialize_options(self): self.no_wrap = False self.ignore_obsolete = False self.no_fuzzy_matching = False + self.update_header_comment = False self.previous = False def finalize_options(self): @@ -619,7 +622,10 @@ def run(self): finally: infile.close() - catalog.update(template, self.no_fuzzy_matching) + catalog.update( + template, self.no_fuzzy_matching, + update_header_comment=self.update_header_comment + ) tmpname = os.path.join(os.path.dirname(filename), tempfile.gettempprefix() + diff --git a/tests/messages/test_catalog.py b/tests/messages/test_catalog.py index 7d18d80a9..3eeaf64ec 100644 --- a/tests/messages/test_catalog.py +++ b/tests/messages/test_catalog.py @@ -440,7 +440,6 @@ def test_catalog_update(): cat.update(template) assert len(cat) == 3 - assert cat.header_comment == template.header_comment # Header comment also gets updated msg1 = cat['green'] msg1.string @@ -457,6 +456,9 @@ def test_catalog_update(): assert not 'head' in cat assert list(cat.obsolete.values())[0].id == 'head' + cat.update(template, update_header_comment=True) + assert cat.header_comment == template.header_comment # Header comment also gets updated + def test_datetime_parsing(): val1 = catalog._parse_datetime_header('2006-06-28 23:24+0200') From 7dcd86b841344fe0e33d8ebba575be6d341d1ab2 Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Sat, 22 Nov 2014 16:06:44 +0100 Subject: [PATCH 178/489] scripts: add territory-language import from CLDR Available in the global data onder the 'territory_language' key. --- scripts/import_cldr.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index a5d0ad5dd..8c0e7f7a5 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -183,6 +183,7 @@ def main(): territory_currencies = global_data.setdefault('territory_currencies', {}) parent_exceptions = global_data.setdefault('parent_exceptions', {}) currency_fractions = global_data.setdefault('currency_fractions', {}) + territory_languages = global_data.setdefault('territory_languages', {}) # create auxiliary zone->territory map from the windows zones (we don't set # the 'zones_territories' map directly here, because there are some zones @@ -276,6 +277,16 @@ def main(): cur_crounding = int(fraction.attrib.get('cashRounding', cur_rounding)) currency_fractions[cur_code] = (cur_digits, cur_rounding, cur_cdigits, cur_crounding) + # Languages in territories + for territory in sup.findall('.//territoryInfo/territory'): + languages = {} + for language in territory.findall('./languagePopulation'): + languages[language.attrib['type']] = { + 'population_percent': float(language.attrib['populationPercent']), + 'official_status': language.attrib.get('officialStatus'), + } + territory_languages[territory.attrib['type']] = languages + write_datafile(global_path, global_data, dump_json=dump_json) # build a territory containment mapping for inheritance From f68373c11ef6b355ee7656002eaf864600ebbc8d Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 3 Jan 2016 22:50:08 +0200 Subject: [PATCH 179/489] Add public API for territory language data --- babel/languages.py | 64 +++++++++++++++++++++++++++++++++++++++++ tests/test_languages.py | 14 +++++++++ 2 files changed, 78 insertions(+) create mode 100644 babel/languages.py create mode 100644 tests/test_languages.py diff --git a/babel/languages.py b/babel/languages.py new file mode 100644 index 000000000..28718f68d --- /dev/null +++ b/babel/languages.py @@ -0,0 +1,64 @@ +# -- encoding: UTF-8 -- +from babel.core import get_global + + +def get_official_languages(territory, regional=False, de_facto=False): + """ + Get the official language(s) for the given territory. + + The language codes, if any are known, are returned in order of descending popularity. + + If the `regional` flag is set, then languages which are regionally official are also returned. + + If the `de_facto` flag is set, then languages which are "de facto" official are also returned. + + :param territory: Territory code + :type territory: str + :param regional: Whether to return regionally official languages too + :type regional: bool + :param de_facto: Whether to return de-facto official languages too + :type de_facto: bool + :return: Tuple of language codes + :rtype: tuple[str] + """ + + territory = str(territory).upper() + allowed_stati = set(("official",)) + if regional: + allowed_stati.add("official_regional") + if de_facto: + allowed_stati.add("de_facto_official") + + languages = get_global("territory_languages").get(territory, {}) + pairs = [ + (info['population_percent'], language) + for language, info in languages.items() + if info.get('official_status') in allowed_stati + ] + pairs.sort(reverse=True) + return tuple(lang for _, lang in pairs) + + + +def get_territory_language_info(territory): + """ + Get a dictionary of language information for a territory. + + The dictionary is keyed by language code; the values are dicts with more information. + + The following keys are currently known for the values: + + * `population_percent`: The percentage of the territory's population speaking the + language. + * `official_status`: An optional string describing the officiality status of the language. + Known values are "official", "official_regional" and "de_facto_official". + + See http://www.unicode.org/cldr/charts/latest/supplemental/territory_language_information.html + + :param territory: Territory code + :type territory: str + :return: Language information dictionary + :rtype: dict[str, dict] + """ + territory = str(territory).upper() + return get_global("territory_languages").get(territory, {}).copy() diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 000000000..594149fa7 --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,14 @@ +# -- encoding: UTF-8 -- +from babel.languages import get_official_languages, get_territory_language_info + + +def test_official_languages(): + assert get_official_languages("FI") == ("fi", "sv") + assert get_official_languages("SE") == ("sv",) + assert get_official_languages("CH") == ("de", "fr", "it") + assert get_official_languages("CH", de_facto=True) == ("de", "gsw", "fr", "it") + assert get_official_languages("CH", regional=True) == ("de", "fr", "it", "rm") + + +def test_get_language_info(): + assert set(get_territory_language_info("HU").keys()) == set(("hu", "en", "de", "ro", "hr", "sk", "sl")) From 27ee02b5dab622b5a036d9687af220efc5842b6f Mon Sep 17 00:00:00 2001 From: Erik Romijn Date: Sun, 23 Nov 2014 17:24:59 +0100 Subject: [PATCH 180/489] core: documented all valid keys for get_global() --- babel/core.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/babel/core.py b/babel/core.py index 495015d67..4088a09f5 100644 --- a/babel/core.py +++ b/babel/core.py @@ -43,6 +43,11 @@ def get_global(key): >>> get_global('zone_territories')['Europe/Berlin'] u'DE' + The keys available are ``currency_fractions``, ``language_aliases``, ``likely_subtags``, + ``parent_exceptions``, ``script_aliases``, ``territory_aliases``, ``territory_currencies``, + ``territory_languages``, ``territory_zones``, ``variant_aliases``, ``win_mapping``, + ``zone_aliases`` and ``zone_territories``. + .. versionadded:: 0.9 :param key: the data key From 07dc2e4bbd57e76e419b1949820f5fbd03e2d2a8 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 3 Jan 2016 22:52:09 +0200 Subject: [PATCH 181/489] get_global: format key documentation, add warning --- babel/core.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/babel/core.py b/babel/core.py index 4088a09f5..4a284e893 100644 --- a/babel/core.py +++ b/babel/core.py @@ -43,10 +43,23 @@ def get_global(key): >>> get_global('zone_territories')['Europe/Berlin'] u'DE' - The keys available are ``currency_fractions``, ``language_aliases``, ``likely_subtags``, - ``parent_exceptions``, ``script_aliases``, ``territory_aliases``, ``territory_currencies``, - ``territory_languages``, ``territory_zones``, ``variant_aliases``, ``win_mapping``, - ``zone_aliases`` and ``zone_territories``. + The keys available are: + + - ``currency_fractions`` + - ``language_aliases`` + - ``likely_subtags`` + - ``parent_exceptions`` + - ``script_aliases`` + - ``territory_aliases`` + - ``territory_currencies`` + - ``territory_languages`` + - ``territory_zones`` + - ``variant_aliases`` + - ``win_mapping`` + - ``zone_aliases`` + - ``zone_territories`` + + .. note:: The internal structure of the data may change between versions. .. versionadded:: 0.9 From 935a0be2e48bfa5da03ab4a796fd0b8fc3995571 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 8 Jan 2016 10:46:04 +0200 Subject: [PATCH 182/489] Add documentation for `babel.languages` Fixes #319 --- babel/languages.py | 8 ++++++++ docs/api/index.rst | 1 + docs/api/languages.rst | 14 ++++++++++++++ 3 files changed, 23 insertions(+) create mode 100644 docs/api/languages.rst diff --git a/babel/languages.py b/babel/languages.py index 28718f68d..a7708712e 100644 --- a/babel/languages.py +++ b/babel/languages.py @@ -12,6 +12,9 @@ def get_official_languages(territory, regional=False, de_facto=False): If the `de_facto` flag is set, then languages which are "de facto" official are also returned. + .. warning:: Note that the data is as up to date as the current version of the CLDR used + by Babel. If you need scientifically accurate information, use another source! + :param territory: Territory code :type territory: str :param regional: Whether to return regionally official languages too @@ -53,6 +56,11 @@ def get_territory_language_info(territory): * `official_status`: An optional string describing the officiality status of the language. Known values are "official", "official_regional" and "de_facto_official". + .. warning:: Note that the data is as up to date as the current version of the CLDR used + by Babel. If you need scientifically accurate information, use another source! + + .. note:: Note that the format of the dict returned may change between Babel versions. + See http://www.unicode.org/cldr/charts/latest/supplemental/territory_language_information.html :param territory: Territory code diff --git a/docs/api/index.rst b/docs/api/index.rst index b882d300b..91f0967ca 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -10,6 +10,7 @@ public API of Babel. core dates lists + languages messages/index numbers plural diff --git a/docs/api/languages.rst b/docs/api/languages.rst new file mode 100644 index 000000000..287f1e07c --- /dev/null +++ b/docs/api/languages.rst @@ -0,0 +1,14 @@ +Languages +========= + +.. module:: babel.languages + +The languages module provides functionality to access data about +languages that is not bound to a given locale. + +Official Languages +------------------ + +.. autofunction:: get_official_languages + +.. autofunction:: get_territory_language_info From 19957e21470615d42fb7b6e2c1a580cd679d33c8 Mon Sep 17 00:00:00 2001 From: Eoin Nugent Date: Mon, 11 Jan 2016 14:43:58 -0800 Subject: [PATCH 183/489] extraction: Babel now supports extraction by filename as well as by dir One can now supply a filename or a directory to be extracted. For large codebases, this allows the consumer to optimize their string extraction process by, for instance, only supplying the files that have actually been changed on the given dev's branch compared to master. Relates to https://github.com/python-babel/babel/issues/253 . I don't want to say "fixes", but makes further optimization unnecessary for most use cases. --- babel/messages/extract.py | 88 ++++++++++++++++++++++++--------- babel/messages/frontend.py | 82 +++++++++++++++++++----------- tests/messages/test_frontend.py | 63 +++++++++++++++++++++-- 3 files changed, 178 insertions(+), 55 deletions(-) mode change 100755 => 100644 babel/messages/frontend.py diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 8fe3f606c..8183d527f 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -142,28 +142,72 @@ def extract_from_dir(dirname=None, method_map=DEFAULT_MAPPING, dirnames.sort() filenames.sort() 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, context in \ - extract_from_file(method, filepath, - keywords=keywords, - comment_tags=comment_tags, - options=options, - strip_comment_tags= - strip_comment_tags): - yield filename, lineno, message, comments, context - break + filepath = os.path.join(root, filename).replace(os.sep, '/') + + for message_tuple in check_and_call_extract_file( + filepath, + method_map, + options_map, + callback, + keywords, + comment_tags, + strip_comment_tags, + dirpath=absname, + ): + yield message_tuple + + +def check_and_call_extract_file(filepath, method_map, options_map, + callback, keywords, comment_tags, + strip_comment_tags, dirpath=None): + """Checks if the given file matches an extraction method mapping, and if so, calls extract_from_file. + + Note that the extraction method mappings are based relative to dirpath. + So, given an absolute path to a file `filepath`, we want to check using + just the relative path from `dirpath` to `filepath`. + + :param filepath: An absolute path to a file that exists. + :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 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 + :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 strip_comment_tags: a flag that if set to `True` causes all comment + tags to be removed from the collected comments. + :param dirpath: the path to the directory to extract messages from. + """ + # filename is the relative path from dirpath to the actual file + filename = relpath(filepath, dirpath) + + for pattern, method in method_map: + if not pathmatch(pattern, filename): + continue + + options = {} + for opattern, odict in options_map.items(): + if pathmatch(opattern, filename): + options = odict + if callback: + callback(filename, method, options) + for message_tuple in extract_from_file( + method, filepath, + keywords=keywords, + comment_tags=comment_tags, + options=options, + strip_comment_tags=strip_comment_tags + ): + yield (filename, ) + message_tuple + + break def extract_from_file(method, filename, keywords=DEFAULT_KEYWORDS, diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py old mode 100755 new mode 100644 index d9919f631..8c6fd8252 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -25,7 +25,7 @@ from babel._compat import StringIO, string_types from babel.core import UnknownLocaleError from babel.messages.catalog import Catalog -from babel.messages.extract import DEFAULT_KEYWORDS, DEFAULT_MAPPING, extract_from_dir +from babel.messages.extract import DEFAULT_KEYWORDS, DEFAULT_MAPPING, check_and_call_extract_file, extract_from_dir from babel.messages.mofile import write_mo from babel.messages.pofile import read_po, write_po from babel.util import LOCALTZ, odict @@ -245,15 +245,15 @@ class extract_messages(Command): 'output file. Separate multiple TAGs with commas(,)'), ('strip-comments', None, 'strip the comment TAGs from the comments.'), - ('input-dirs=', None, - 'directories that should be scanned for messages. Separate multiple ' - 'directories with commas(,)'), + ('input-paths=', None, + 'files or directories that should be scanned for messages. Separate multiple ' + 'files or directories with commas(,)'), ] boolean_options = [ 'no-default-keywords', 'no-location', 'omit-header', 'no-wrap', 'sort-output', 'sort-by-file', 'strip-comments' ] - as_args = 'input-dirs' + as_args = 'input-paths' multiple_value_options = ('add-comments',) def initialize_options(self): @@ -265,7 +265,7 @@ def initialize_options(self): self.no_location = False self.omit_header = False self.output_file = None - self.input_dirs = None + self.input_paths = None self.width = None self.no_wrap = False self.sort_output = False @@ -300,17 +300,21 @@ def finalize_options(self): raise DistutilsOptionError("'--sort-output' and '--sort-by-file' " "are mutually exclusive") - if self.input_dirs: - if isinstance(self.input_dirs, string_types): - self.input_dirs = re.split(',\s*', self.input_dirs) + if self.input_paths: + if isinstance(self.input_paths, string_types): + self.input_paths = re.split(',\s*', self.input_paths) else: - self.input_dirs = dict.fromkeys([ + self.input_paths = dict.fromkeys([ k.split('.', 1)[0] for k in (self.distribution.packages or ()) ]).keys() - if not self.input_dirs: - raise DistutilsOptionError("no input directories specified") + if not self.input_paths: + raise DistutilsOptionError("no input files or directories specified") + + for path in self.input_paths: + if not os.path.exists(path): + raise DistutilsOptionError("Input path: %s does not exist" % path) if self.add_comments: if isinstance(self.add_comments, string_types): @@ -333,29 +337,51 @@ def run(self): copyright_holder=self.copyright_holder, charset=self.charset) - for dirname, (method_map, options_map) in mappings.items(): + for path, (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)) + + # If we explicitly provide a full filepath, just use that. + # Otherwise, path will be the directory path and filename + # is the relative path from that dir to the file. + # So we can join those to get the full filepath. + if os.path.isfile(path): + filepath = path + else: + filepath = os.path.normpath(os.path.join(path, 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=self._keywords, - comment_tags=self.add_comments, - callback=callback, - strip_comment_tags=self.strip_comments - ) + if os.path.isfile(path): + current_dir = os.getcwd() + extracted = check_and_call_extract_file( + path, method_map, options_map, + callback, self._keywords, self.add_comments, + self.strip_comments, current_dir + ) + else: + extracted = extract_from_dir( + path, method_map, options_map, + keywords=self._keywords, + comment_tags=self.add_comments, + callback=callback, + strip_comment_tags=self.strip_comments + ) for filename, lineno, message, comments, context in extracted: - filepath = os.path.normpath(os.path.join(dirname, filename)) + if os.path.isfile(path): + filepath = filename # already normalized + else: + filepath = os.path.normpath(os.path.join(path, filename)) + catalog.add(message, None, [(filepath, lineno)], auto_comments=comments, context=context) + self.log.info('writing PO template file to %s' % self.output_file) write_po(outfile, catalog, width=self.width, no_location=self.no_location, @@ -370,14 +396,14 @@ def _get_mappings(self): 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 + for path in self.input_paths: + mappings[path] = 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(): + for path, mapping in message_extractors.items(): if isinstance(mapping, string_types): method_map, options_map = parse_mapping(StringIO(mapping)) else: @@ -385,11 +411,11 @@ def _get_mappings(self): for pattern, method, options in mapping: method_map.append((pattern, method)) options_map[pattern] = options or {} - mappings[dirname] = method_map, options_map + mappings[path] = method_map, options_map else: - for dirname in self.input_dirs: - mappings[dirname] = DEFAULT_MAPPING, {} + for path in self.input_paths: + mappings[path] = DEFAULT_MAPPING, {} return mappings diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index f958b8050..975876a5c 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -108,8 +108,13 @@ def test_both_sort_output_and_sort_by_file(self): self.cmd.sort_by_file = True self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) - def test_input_dirs_is_treated_as_list(self): - self.cmd.input_dirs = self.datadir + def test_invalid_file_or_dir_input_path(self): + self.cmd.input_paths = 'nonexistent_path' + self.cmd.output_file = 'dummy' + self.assertRaises(DistutilsOptionError, self.cmd.finalize_options) + + def test_input_paths_is_treated_as_list(self): + self.cmd.input_paths = self.datadir self.cmd.output_file = self._pot_file() self.cmd.finalize_options() self.cmd.run() @@ -120,12 +125,12 @@ def test_input_dirs_is_treated_as_list(self): self.assertEqual(1, len(msg.locations)) self.assertTrue('file1.py' in msg.locations[0][0]) - def test_input_dirs_handle_spaces_after_comma(self): - self.cmd.input_dirs = 'foo, bar' + def test_input_paths_handle_spaces_after_comma(self): + self.cmd.input_paths = '%s, %s' % (this_dir, self.datadir) self.cmd.output_file = self._pot_file() self.cmd.finalize_options() - self.assertEqual(['foo', 'bar'], self.cmd.input_dirs) + self.assertEqual([this_dir, self.datadir], self.cmd.input_paths) def test_extraction_with_default_mapping(self): self.cmd.copyright_holder = 'FooBar, Inc.' @@ -861,6 +866,54 @@ def test_extract_with_mapping_file(self): 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')} + with open(pot_file, 'U') as f: + actual_content = f.read() + self.assertEqual(expected_content, actual_content) + + def test_extract_with_exact_file(self): + """Tests that we can call extract with a particular file and only + strings from that file get extracted. (Note the absence of strings from file1.py) + """ + pot_file = self._pot_file() + file_to_extract = os.path.join(self.datadir, 'project', 'file2.py') + self.cli.run(sys.argv + ['extract', + '--copyright-holder', 'FooBar, Inc.', + '--project', 'TestProject', '--version', '0.1', + '--msgid-bugs-address', 'bugs.address@email.tld', + '--mapping', os.path.join(self.datadir, 'mapping.cfg'), + '-c', 'TRANSLATOR', '-c', 'TRANSLATORS:', + '-o', pot_file, file_to_extract]) + self.assert_pot_file_exists() + 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. +# 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" + +#: 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', From bc8f19ea1de43850c1824e405007d7be5159548e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 22 Jan 2016 20:42:03 +0200 Subject: [PATCH 184/489] plural: don't ignore possible rules in `other` category --- babel/plural.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index b5ce9ba65..63e395ba7 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -86,14 +86,14 @@ def __init__(self, rules): found = set() self.abstract = [] for key, expr in sorted(list(rules)): - if key == 'other': - continue if key not in _plural_tags: raise ValueError('unknown tag %r' % key) elif key in found: raise ValueError('tag %r defined twice' % key) found.add(key) - self.abstract.append((key, _Parser(expr).ast)) + ast = _Parser(expr).ast + if ast: + self.abstract.append((key, ast)) def __repr__(self): rules = self.rules @@ -388,6 +388,11 @@ class _Parser(object): def __init__(self, string): self.tokens = tokenize_rule(string) + if not self.tokens: + # If the pattern is only samples, it's entirely possible + # no stream of tokens whatsoever is generated. + self.ast = None + return self.ast = self.condition() if self.tokens: raise RuleError('Expected end of rule, got %r' % From 0e48c5e19757883adb981107c00e59d687d808a9 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 22 Jan 2016 20:42:24 +0200 Subject: [PATCH 185/489] plural: support Unicode ellipsis sign --- babel/plural.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/babel/plural.py b/babel/plural.py index 63e395ba7..5cc7289b5 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -303,7 +303,7 @@ class RuleError(Exception): .format(_VARS))), ('value', re.compile(r'\d+')), ('symbol', re.compile(r'%|,|!=|=')), - ('ellipsis', re.compile(r'\.\.')) + ('ellipsis', re.compile(r'\.{2,3}|\u2026', re.UNICODE)) # U+2026: ELLIPSIS ] From a4ed4e04d02bdeeec3d97118215876dcce52852a Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 22 Jan 2016 20:42:59 +0200 Subject: [PATCH 186/489] plural: DRY out `compile_zero` --- babel/plural.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/babel/plural.py b/babel/plural.py index 5cc7289b5..f2e9f4808 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -485,6 +485,9 @@ def _unary_compiler(tmpl): return lambda self, x: tmpl % self.compile(x) +compile_zero = lambda x: '0' + + class _Compiler(object): """The compilers are able to transform the expressions into multiple output formats. @@ -557,10 +560,10 @@ class _JavaScriptCompiler(_GettextCompiler): # XXX: presently javascript does not support any of the # fraction support and basically only deals with integers. compile_i = lambda x: 'parseInt(n, 10)' - compile_v = lambda x: '0' - compile_w = lambda x: '0' - compile_f = lambda x: '0' - compile_t = lambda x: '0' + compile_v = compile_zero + compile_w = compile_zero + compile_f = compile_zero + compile_t = compile_zero def compile_relation(self, method, expr, range_list): code = _GettextCompiler.compile_relation( From e31156fa2012b637923b2f047b8f24fd3c0e8423 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Sun, 20 Dec 2015 23:28:00 +0200 Subject: [PATCH 187/489] Compile noninteger TR35 operands to zeroes when emitting Gettext See http://www.unicode.org/reports/tr35/tr35-numbers.html#Operands See https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html Fixes #287 --- babel/plural.py | 6 ++++++ tests/test_plural.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/babel/plural.py b/babel/plural.py index f2e9f4808..9cb1d5cc6 100644 --- a/babel/plural.py +++ b/babel/plural.py @@ -534,6 +534,12 @@ def compile_relation(self, method, expr, range_list): class _GettextCompiler(_Compiler): """Compile into a gettext plural expression.""" + compile_i = _Compiler.compile_n + compile_v = compile_zero + compile_w = compile_zero + compile_f = compile_zero + compile_t = compile_zero + def compile_relation(self, method, expr, range_list): rv = [] expr = self.compile(expr) diff --git a/tests/test_plural.py b/tests/test_plural.py index fce1b8e0e..d51efef1e 100644 --- a/tests/test_plural.py +++ b/tests/test_plural.py @@ -13,7 +13,7 @@ import unittest import pytest -from babel import plural +from babel import plural, localedata from babel._compat import Decimal @@ -254,3 +254,17 @@ def test_extract_operands(source, n, i, v, w, f, t): source = Decimal(source) if isinstance(source, str) else source assert (plural.extract_operands(source) == Decimal(n), i, v, w, f, t) + + +@pytest.mark.parametrize('locale', ('ru', 'pl')) +def test_gettext_compilation(locale): + # Test that new plural form elements introduced in recent CLDR versions + # are compiled "down" to `n` when emitting Gettext rules. + ru_rules = localedata.load(locale)['plural_form'].rules + chars = 'ivwft' + # Test that these rules are valid for this test; i.e. that they contain at least one + # of the gettext-unsupported characters. + assert any((" " + ch + " ") in rule for ch in chars for rule in ru_rules.values()) + # Then test that the generated value indeed does not contain these. + ru_rules_gettext = plural.to_gettext(ru_rules) + assert not any(ch in ru_rules_gettext for ch in chars) From 2744fd7cc819db1e158510287971526cf843143b Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 00:25:48 +0200 Subject: [PATCH 188/489] CLDR: Import interval format data Refs #276 --- babel/core.py | 22 ++++++++++++++++++++++ scripts/import_cldr.py | 25 +++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/babel/core.py b/babel/core.py index 4a284e893..8764360ab 100644 --- a/babel/core.py +++ b/babel/core.py @@ -798,6 +798,28 @@ def datetime_skeletons(self): """ return self._data['datetime_skeletons'] + @property + def interval_formats(self): + """Locale patterns for interval formatting. + + .. note:: The format of the value returned may change between + Babel versions. + + How to format date intervals in Finnish when the day is the + smallest changing component: + + >>> Locale('fi_FI').interval_formats['MEd']['d'] + [u'E d. \u2013 ', u'E d.M.'] + + .. seealso:: + + The primary API to use this data is :py:func:`babel.dates.format_interval`. + + + :rtype: dict[str, dict[str, list[str]]] + """ + return self._data['interval_formats'] + @property def plural_form(self): """Plural rules for the locale. diff --git a/scripts/import_cldr.py b/scripts/import_cldr.py index 8c0e7f7a5..8e151f7d3 100755 --- a/scripts/import_cldr.py +++ b/scripts/import_cldr.py @@ -16,6 +16,7 @@ import os import re import sys + try: from xml.etree import cElementTree as ElementTree except ImportError: @@ -25,9 +26,10 @@ sys.path.insert(0, os.path.join(os.path.dirname(sys.argv[0]), '..')) from babel import dates, numbers -from babel.plural import PluralRule -from babel.localedata import Alias from babel._compat import pickle, text_type +from babel.dates import split_interval_pattern +from babel.localedata import Alias +from babel.plural import PluralRule parse = ElementTree.parse weekdays = {'mon': 0, 'tue': 1, 'wed': 2, 'thu': 3, 'fri': 4, 'sat': 5, @@ -608,6 +610,8 @@ def main(): datetime_skeletons[datetime_skeleton.attrib['id']] = \ dates.parse_pattern(text_type(datetime_skeleton.text)) + parse_interval_formats(data, calendar) + # number_symbols = data.setdefault('number_symbols', {}) @@ -693,6 +697,23 @@ def main(): write_datafile(data_filename, data, dump_json=dump_json) +def parse_interval_formats(data, tree): + # http://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats + interval_formats = data.setdefault("interval_formats", {}) + for elem in tree.findall("dateTimeFormats/intervalFormats/*"): + if 'draft' in elem.attrib: + continue + if elem.tag == "intervalFormatFallback": + interval_formats[None] = elem.text + elif elem.tag == "intervalFormatItem": + skel_data = interval_formats.setdefault(elem.attrib["id"], {}) + for item_sub in elem.getchildren(): + if item_sub.tag == "greatestDifference": + skel_data[item_sub.attrib["id"]] = split_interval_pattern(item_sub.text) + else: + raise NotImplementedError("Not implemented: %s(%r)" % (item_sub.tag, item_sub.attrib)) + + def parse_currency_formats(data, tree): currency_formats = data.setdefault('currency_formats', {}) for length_elem in tree.findall('.//currencyFormats/currencyFormatLength'): From 7800e547ed1710b24ee5679994cee6055c9c5698 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 20:36:39 +0200 Subject: [PATCH 189/489] dates: DRY out the internal datetime conversion code --- babel/dates.py | 163 ++++++++++++++++++++++++++++---------------- tests/test_dates.py | 26 ++++++- 2 files changed, 129 insertions(+), 60 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 4f66adbe5..b4de62712 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -38,6 +38,106 @@ time_ = time +def _get_dt_and_tzinfo(dt_or_tzinfo): + """ + Parse a `dt_or_tzinfo` value into a datetime and a tzinfo. + + See the docs for this function's callers for semantics. + + :rtype: tuple[datetime, tzinfo] + """ + if dt_or_tzinfo is None: + dt = datetime.now() + tzinfo = LOCALTZ + elif isinstance(dt_or_tzinfo, string_types): + dt = None + tzinfo = get_timezone(dt_or_tzinfo) + elif isinstance(dt_or_tzinfo, integer_types): + dt = None + tzinfo = UTC + elif isinstance(dt_or_tzinfo, (datetime, time)): + dt = _get_datetime(dt_or_tzinfo) + if dt.tzinfo is not None: + tzinfo = dt.tzinfo + else: + tzinfo = UTC + else: + dt = None + tzinfo = dt_or_tzinfo + return dt, tzinfo + + +def _get_datetime(instant): + """ + Get a datetime out of an "instant" (date, time, datetime, number). + + .. warning:: The return values of this function may depend on the system clock. + + If the instant is None, the current moment is used. + If the instant is a time, it's augmented with today's date. + + Dates are converted to naive datetimes with midnight as the time component. + + >>> _get_datetime(date(2015, 1, 1)) + datetime.datetime(2015, 1, 1, 0, 0) + + UNIX timestamps are converted to datetimes. + + >>> _get_datetime(1400000000) + datetime.datetime(2014, 5, 13, 16, 53, 20) + + Other values are passed through as-is. + + >>> x = datetime(2015, 1, 1) + >>> _get_datetime(x) is x + True + + :param instant: date, time, datetime, integer, float or None + :type instant: date|time|datetime|int|float|None + :return: a datetime + :rtype: datetime + """ + if instant is None: + return datetime_.utcnow() + elif isinstance(instant, integer_types) or isinstance(instant, float): + return datetime_.utcfromtimestamp(instant) + elif isinstance(instant, time): + return datetime_.combine(date.today(), instant) + elif isinstance(instant, date) and not isinstance(instant, datetime): + return datetime_.combine(instant, time()) + # TODO (3.x): Add an assertion/type check for this fallthrough branch: + return instant + + +def _ensure_datetime_tzinfo(datetime, tzinfo=None): + """ + Ensure the datetime passed has an attached tzinfo. + + If the datetime is tz-naive to begin with, UTC is attached. + + If a tzinfo is passed in, the datetime is normalized to that timezone. + + >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1)).tzinfo.zone + 'UTC' + + >>> tz = get_timezone("Europe/Stockholm") + >>> _ensure_datetime_tzinfo(datetime(2015, 1, 1, 13, 15, tzinfo=UTC), tzinfo=tz).hour + 14 + + :param datetime: Datetime to augment. + :param tzinfo: Optional tznfo. + :return: datetime with tzinfo + :rtype: datetime + """ + if datetime.tzinfo is None: + datetime = datetime.replace(tzinfo=UTC) + if tzinfo is not None: + datetime = datetime.astimezone(get_timezone(tzinfo)) + if hasattr(tzinfo, 'normalize'): # pytz + datetime = tzinfo.normalize(datetime) + return datetime + + def get_timezone(zone=None): """Looks up a timezone by name and returns it. The timezone object returned comes from ``pytz`` and corresponds to the `tzinfo` interface and @@ -78,10 +178,7 @@ def get_next_timezone_transition(zone=None, dt=None): If not given the current time is assumed. """ zone = get_timezone(zone) - if dt is None: - dt = datetime.utcnow() - else: - dt = dt.replace(tzinfo=None) + dt = _get_datetime(dt).replace(tzinfo=None) if not hasattr(zone, '_utc_transition_times'): raise TypeError('Given timezone does not have UTC transition ' @@ -301,12 +398,7 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): :param width: either "long" or "short" :param locale: the `Locale` object, or a locale string """ - if datetime is None: - datetime = datetime_.utcnow() - elif isinstance(datetime, integer_types): - datetime = datetime_.utcfromtimestamp(datetime).time() - if datetime.tzinfo is None: - datetime = datetime.replace(tzinfo=UTC) + datetime = _ensure_datetime_tzinfo(_get_datetime(datetime)) locale = Locale.parse(locale) offset = datetime.tzinfo.utcoffset(datetime) @@ -347,24 +439,7 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): :param locale: the `Locale` object, or a locale string :return: the localized timezone name using location format """ - if dt_or_tzinfo is None: - dt = datetime.now() - tzinfo = LOCALTZ - elif isinstance(dt_or_tzinfo, string_types): - dt = None - tzinfo = get_timezone(dt_or_tzinfo) - elif isinstance(dt_or_tzinfo, integer_types): - dt = None - tzinfo = UTC - elif isinstance(dt_or_tzinfo, (datetime, time)): - dt = dt_or_tzinfo - if dt.tzinfo is not None: - tzinfo = dt.tzinfo - else: - tzinfo = UTC - else: - dt = None - tzinfo = dt_or_tzinfo + dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) if hasattr(tzinfo, 'zone'): @@ -474,24 +549,7 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, ``'standard'``. :param locale: the `Locale` object, or a locale string """ - if dt_or_tzinfo is None: - dt = datetime.now() - tzinfo = LOCALTZ - elif isinstance(dt_or_tzinfo, string_types): - dt = None - tzinfo = get_timezone(dt_or_tzinfo) - elif isinstance(dt_or_tzinfo, integer_types): - dt = None - tzinfo = UTC - elif isinstance(dt_or_tzinfo, (datetime, time)): - dt = dt_or_tzinfo - if dt.tzinfo is not None: - tzinfo = dt.tzinfo - else: - tzinfo = UTC - else: - dt = None - tzinfo = dt_or_tzinfo + dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) if hasattr(tzinfo, 'zone'): @@ -594,18 +652,7 @@ def format_datetime(datetime=None, format='medium', tzinfo=None, :param tzinfo: the timezone to apply to the time for display :param locale: a `Locale` object or a locale identifier """ - if datetime is None: - datetime = datetime_.utcnow() - elif isinstance(datetime, number_types): - datetime = datetime_.utcfromtimestamp(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(get_timezone(tzinfo)) - if hasattr(tzinfo, 'normalize'): # pytz - datetime = tzinfo.normalize(datetime) + datetime = _ensure_datetime_tzinfo(_get_datetime(datetime), tzinfo) locale = Locale.parse(locale) if format in ('full', 'long', 'medium', 'short'): diff --git a/tests/test_dates.py b/tests/test_dates.py index 30a0ea3d5..e93fa401b 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -411,8 +411,8 @@ def test_get_timezone_location(): u'Mexiko (Mexiko-Stadt) Zeit') tz = timezone('Europe/Berlin') - assert (dates.get_timezone_name(tz, locale='de_DE') == - u'Mitteleurop\xe4ische Zeit') + assert (dates.get_timezone_location(tz, locale='de_DE') == + u'Deutschland (Berlin) Zeit') def test_get_timezone_name(): @@ -448,6 +448,14 @@ def test_get_timezone_name(): assert dates.get_timezone_name(tz, locale='en', width='long', zone_variant='daylight') == u'Pacific Daylight Time' + assert (dates.get_timezone_name(None, locale='en_US') == + dates.get_timezone_name(datetime.now().replace(tzinfo=dates.LOCALTZ), locale='en_US')) + + assert (dates.get_timezone_name('Europe/Berlin', locale='en_US') == "Central European Time") + + assert (dates.get_timezone_name(1400000000, locale='en_US', width='short') == "Unknown Region (GMT) Time") + assert (dates.get_timezone_name(time(16, 20), locale='en_US', width='short') == "+0000") + def test_format_date(): d = date(2007, 4, 1) @@ -556,3 +564,17 @@ def test_lithuanian_long_format(): dates.format_date(date(2015, 12, 10), locale='lt_LT', format='long') == u'2015 m. gruodžio 10 d.' ) + + +def test_format_current_moment(monkeypatch): + import datetime as datetime_module + frozen_instant = datetime.utcnow() + + class frozen_datetime(datetime): + @classmethod + def utcnow(cls): + return frozen_instant + + # Freeze time! Well, some of it anyway. + monkeypatch.setattr(datetime_module, "datetime", frozen_datetime) + assert dates.format_datetime(locale="en_US") == dates.format_datetime(frozen_instant, locale="en_US") From 9aafc7158e8aba99f9a08ae25f2dc6e8834e6fe8 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 18:47:36 +0200 Subject: [PATCH 190/489] dates: split tokenize_pattern out of parse_pattern --- babel/dates.py | 50 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index b4de62712..8f444f0e2 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1215,27 +1215,57 @@ def parse_pattern(pattern): return pattern result = [] + + for tok_type, tok_value in tokenize_pattern(pattern): + if tok_type == "chars": + result.append(tok_value.replace('%', '%%')) + elif tok_type == "field": + fieldchar, fieldnum = tok_value + limit = PATTERN_CHARS[fieldchar] + if limit and fieldnum not in limit: + raise ValueError('Invalid length for field: %r' + % (fieldchar * fieldnum)) + result.append('%%(%s)s' % (fieldchar * fieldnum)) + else: + raise NotImplementedError("Unknown token type: %s" % tok_type) + + return DateTimePattern(pattern, u''.join(result)) + + +def tokenize_pattern(pattern): + """ + Tokenize date format patterns. + + Returns a list of (token_type, token_value) tuples. + + ``token_type`` may be either "chars" or "field". + + For "chars" tokens, the value is the literal value. + + For "field" tokens, the value is a tuple of (field character, repetition count). + + :param pattern: Pattern string + :type pattern: str + :rtype: list[tuple] + """ + result = [] quotebuf = None charbuf = [] fieldchar = [''] fieldnum = [0] def append_chars(): - result.append(''.join(charbuf).replace('%', '%%')) + result.append(('chars', ''.join(charbuf).replace('\0', "'"))) 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])) + result.append(('field', (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 char == "'": # quote started if fieldchar[0]: append_field() elif charbuf: @@ -1257,10 +1287,10 @@ def append_field(): charbuf.append(char) elif quotebuf is not None: - if char == "'": # end of quote + if char == "'": # end of quote charbuf.extend(quotebuf) quotebuf = None - else: # inside quote + else: # inside quote quotebuf.append(char) if fieldchar[0]: @@ -1268,4 +1298,4 @@ def append_field(): elif charbuf: append_chars() - return DateTimePattern(pattern, u''.join(result).replace('\0', "'")) + return result From 516113b33e027a1a11b0740ddf26eccaedec90f6 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 19:01:52 +0200 Subject: [PATCH 191/489] dates: add split_interval_pattern and untokenize_pattern --- babel/dates.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/babel/dates.py b/babel/dates.py index 8f444f0e2..b5c670b41 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1299,3 +1299,62 @@ def append_field(): append_chars() return result + + +def untokenize_pattern(tokens): + """ + Turn a date format pattern token stream back into a string. + + This is the reverse operation of ``tokenize_pattern``. + + :type tokens: Iterable[tuple] + :rtype: str + """ + output = [] + for tok_type, tok_value in tokens: + if tok_type == "field": + output.append(tok_value[0] * tok_value[1]) + elif tok_type == "chars": + if not any(ch in PATTERN_CHARS for ch in tok_value): # No need to quote + output.append(tok_value) + else: + output.append("'%s'" % tok_value.replace("'", "''")) + return "".join(output) + + +def split_interval_pattern(pattern): + """ + Split an interval-describing datetime pattern into multiple pieces. + + > The pattern is then designed to be broken up into two pieces by determining the first repeating field. + - http://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats + + >>> split_interval_pattern(u'E d.M. \u2013 E d.M.') + [u'E d.M. \u2013 ', 'E d.M.'] + >>> split_interval_pattern("Y 'text' Y 'more text'") + ["Y 'text '", "Y 'more text'"] + >>> split_interval_pattern(u"E, MMM d \u2013 E") + [u'E, MMM d \u2013 ', u'E'] + >>> split_interval_pattern("MMM d") + ['MMM d'] + >>> split_interval_pattern("y G") + ['y G'] + >>> split_interval_pattern(u"MMM d \u2013 d") + [u'MMM d \u2013 ', u'd'] + + :param pattern: Interval pattern string + :return: list of "subpatterns" + """ + + seen_fields = set() + parts = [[]] + + for tok_type, tok_value in tokenize_pattern(pattern): + if tok_type == "field": + if tok_value[0] in seen_fields: # Repeated field + parts.append([]) + seen_fields.clear() + seen_fields.add(tok_value[0]) + parts[-1].append((tok_type, tok_value)) + + return [untokenize_pattern(tokens) for tokens in parts] From 9b96b1fbc49a7a0d47ec58c25cf0644282fe6201 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 20:52:36 +0200 Subject: [PATCH 192/489] dates: Memoize parsed DateTimePatterns --- babel/dates.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/babel/dates.py b/babel/dates.py index b5c670b41..20d1ab814 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1189,6 +1189,7 @@ def get_week_number(self, day_of_period, day_of_week=None): 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4], 'v': [1, 4], 'V': [1, 4] # zone } +_pattern_cache = {} def parse_pattern(pattern): """Parse date, time, and datetime format patterns. @@ -1214,6 +1215,9 @@ def parse_pattern(pattern): if type(pattern) is DateTimePattern: return pattern + if pattern in _pattern_cache: + return _pattern_cache[pattern] + result = [] for tok_type, tok_value in tokenize_pattern(pattern): @@ -1229,7 +1233,8 @@ def parse_pattern(pattern): else: raise NotImplementedError("Unknown token type: %s" % tok_type) - return DateTimePattern(pattern, u''.join(result)) + _pattern_cache[pattern] = pat = DateTimePattern(pattern, u''.join(result)) + return pat def tokenize_pattern(pattern): From b652f655deab93dddf35dd0fb4799f978778e275 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 19:34:24 +0200 Subject: [PATCH 193/489] dates: Add DateTimeFormat.extract() support function --- babel/dates.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/babel/dates.py b/babel/dates.py index 20d1ab814..b0ba9c082 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1041,6 +1041,25 @@ def __getitem__(self, name): else: raise KeyError('Unsupported date/time field %r' % char) + def extract(self, char): + char = str(char)[0] + if char == 'y': + return self.value.year + elif char == 'M': + return self.value.month + elif char == 'd': + return self.value.day + elif char == 'H': + return self.value.hour + elif char == 'h': + return (self.value.hour % 12 or 12) + elif char == 'm': + return self.value.minute + elif char == 'a': + return int(self.value.hour >= 12) # 0 for am, 1 for pm + else: + raise NotImplementedError("Not implemented: extracting %r from %r" % (char, self.value)) + def format_era(self, char, num): width = {3: 'abbreviated', 4: 'wide', 5: 'narrow'}[max(3, num)] era = int(self.value.year >= 0) From 796c3d20bfb430f9ae1cf845b2cde35ab0573c2b Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 19:35:56 +0200 Subject: [PATCH 194/489] dates: Add basic format_interval implementation Refs #276 --- babel/dates.py | 107 +++++++++++++++++++++++++++++++++++ docs/api/dates.rst | 2 + tests/test_date_intervals.py | 47 +++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 tests/test_date_intervals.py diff --git a/babel/dates.py b/babel/dates.py index b0ba9c082..3605a4519 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -882,6 +882,107 @@ def _iter_patterns(a_unit): return u'' +def _format_fallback_interval(start, end, skeleton, tzinfo, locale): + if skeleton in locale.datetime_skeletons: # Use the given skeleton + format = lambda dt: format_skeleton(skeleton, dt, tzinfo, locale=locale) + elif all((isinstance(d, date) and not isinstance(d, datetime)) for d in (start, end)): # Both are just dates + format = lambda dt: format_date(dt, locale=locale) + elif all((isinstance(d, time) and not isinstance(d, date)) for d in (start, end)): # Both are times + format = lambda dt: format_time(dt, tzinfo=tzinfo, locale=locale) + else: + format = lambda dt: format_datetime(dt, tzinfo=tzinfo, locale=locale) + + formatted_start = format(start) + formatted_end = format(end) + + if formatted_start == formatted_end: + return format(start) + + return ( + locale.interval_formats.get(None, "{0}-{1}"). + replace("{0}", formatted_start). + replace("{1}", formatted_end) + ) + + +def format_interval(start, end, skeleton, tzinfo=None, locale=LC_TIME): + """ + Format an interval between two instants according to the locale's rules. + + >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "yMd", locale="fi") + u'15.\u201317.1.2016' + + >>> format_interval(time(12, 12), time(16, 16), "Hm", locale="en_GB") + '12:12 \u2013 16:16' + + >>> format_interval(time(5, 12), time(16, 16), "hm", locale="en_US") + '5:12 AM \u2013 4:16 PM' + + >>> format_interval(time(16, 18), time(16, 24), "Hm", locale="it") + '16:18\u201316:24' + + If the start instant equals the end instant, the interval is formatted like the instant. + + >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it") + '16:18' + + :param start: First instant (datetime/date/time) + :param end: Second instant (datetime/date/time) + :param skeleton: The "skeleton format" to use for formatting. + :param tzinfo: tzinfo to use (if none is already attached) + :param locale: A locale object or identifier. + :return: Formatted interval + """ + locale = Locale.parse(locale) + + # NB: The quote comments below are from the algorithm description in + # http://www.unicode.org/reports/tr35/tr35-dates.html#intervalFormats + + # > Look for the intervalFormatItem element that matches the "skeleton", + # > starting in the current locale and then following the locale fallback + # > chain up to, but not including root. + + if skeleton not in locale.interval_formats: + # > If no match was found from the previous step, check what the closest + # > match is in the fallback locale chain, as in availableFormats. That + # > is, this allows for adjusting the string value field's width, + # > including adjusting between "MMM" and "MMMM", and using different + # > variants of the same field, such as 'v' and 'z'. + # TODO: Implement closest-match instead of immediately falling back + return _format_fallback_interval(start, end, skeleton, tzinfo, locale) + + skel_formats = locale.interval_formats[skeleton] + + if start == end: + return format_skeleton(skeleton, start, tzinfo, locale) + + start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo) + end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo) + + start_fmt = DateTimeFormat(start, locale=locale) + end_fmt = DateTimeFormat(end, locale=locale) + + # > If a match is found from previous steps, compute the calendar field + # > with the greatest difference between start and end datetime. If there + # > is no difference among any of the fields in the pattern, format as a + # > single date using availableFormats, and return. + + for field in PATTERN_CHAR_ORDER: # These are in largest-to-smallest order + if field in skel_formats: + if start_fmt.extract(field) != end_fmt.extract(field): + # > If there is a match, use the pieces of the corresponding pattern to + # > format the start and end datetime, as above. + return "".join( + parse_pattern(pattern).apply(instant, locale) + for pattern, instant + in zip(skel_formats[field], (start, end)) + ) + + # > Otherwise, format the start and end datetime using the fallback pattern. + + return _format_fallback_interval(start, end, skeleton, tzinfo, locale) + + def parse_date(string, locale=LC_TIME): """Parse a date from a string. @@ -1208,8 +1309,14 @@ def get_week_number(self, day_of_period, day_of_week=None): 'z': [1, 2, 3, 4], 'Z': [1, 2, 3, 4], 'v': [1, 4], 'V': [1, 4] # zone } +#: The pattern characters declared in the Date Field Symbol Table +#: (http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) +#: in order of decreasing magnitude. +PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZvV" + _pattern_cache = {} + def parse_pattern(pattern): """Parse date, time, and datetime format patterns. diff --git a/docs/api/dates.rst b/docs/api/dates.rst index 1b22cd74b..67ada4159 100644 --- a/docs/api/dates.rst +++ b/docs/api/dates.rst @@ -19,6 +19,8 @@ Date and Time Formatting .. autofunction:: format_skeleton +.. autofunction:: format_interval + Timezone Functionality ---------------------- diff --git a/tests/test_date_intervals.py b/tests/test_date_intervals.py new file mode 100644 index 000000000..73fae462c --- /dev/null +++ b/tests/test_date_intervals.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +import datetime + +from babel import dates +from babel.dates import get_timezone +from babel.util import UTC + +TEST_DT = datetime.datetime(2016, 1, 8, 11, 46, 15) +TEST_TIME = TEST_DT.time() +TEST_DATE = TEST_DT.date() + + +def test_format_interval_same_instant_1(): + assert dates.format_interval(TEST_DT, TEST_DT, "yMMMd", locale="fi") == "8. tammikuuta 2016" + + +def test_format_interval_same_instant_2(): + assert dates.format_interval(TEST_DT, TEST_DT, "xxx", locale="fi") == "8.1.2016 klo 11.46.15" + + +def test_format_interval_same_instant_3(): + assert dates.format_interval(TEST_TIME, TEST_TIME, "xxx", locale="fi") == "11.46.15" + + +def test_format_interval_same_instant_4(): + assert dates.format_interval(TEST_DATE, TEST_DATE, "xxx", locale="fi") == "8.1.2016" + + +def test_format_interval_no_difference(): + t1 = TEST_DT + t2 = t1 + datetime.timedelta(minutes=8) + assert dates.format_interval(t1, t2, "yMd", locale="fi") == "8.1.2016" + + +def test_format_interval_in_tz(): + t1 = TEST_DT.replace(tzinfo=UTC) + t2 = t1 + datetime.timedelta(minutes=18) + hki_tz = get_timezone("Europe/Helsinki") + assert dates.format_interval(t1, t2, "Hmv", tzinfo=hki_tz, locale="fi") == "13.46\u201314.04 aikavyöhyke: Suomi" + + +def test_format_interval_12_hour(): + t2 = TEST_DT + t1 = t2 - datetime.timedelta(hours=1) + assert dates.format_interval(t1, t2, "hm", locale="en") == "10:46 \u2013 11:46 AM" From cd70395b0f138b7f60b0866f5b2b164f1a025786 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 4 Jan 2016 21:51:36 +0200 Subject: [PATCH 195/489] dates: Add and use fuzzy skeleton matching Based on: * http://www.unicode.org/reports/tr35/tr35-dates.html#availableFormats_appendItems * http://source.icu-project.org/repos/icu/icu4j/trunk/main/classes/core/src/com/ibm/icu/text/DateIntervalInfo.java (method `getBestSkeleton`) --- babel/dates.py | 114 ++++++++++++++++++++++++++++++++--- tests/test_date_intervals.py | 17 ++++-- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 3605a4519..b41945bc7 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -741,7 +741,7 @@ def format_time(time=None, format='medium', tzinfo=None, locale=LC_TIME): return parse_pattern(format).apply(time, locale) -def format_skeleton(skeleton, datetime=None, tzinfo=None, locale=LC_TIME): +def format_skeleton(skeleton, datetime=None, tzinfo=None, fuzzy=True, locale=LC_TIME): r"""Return a time and/or date formatted according to the given pattern. The skeletons are defined in the CLDR data and provide more flexibility @@ -754,6 +754,12 @@ def format_skeleton(skeleton, datetime=None, tzinfo=None, locale=LC_TIME): u'dim. 1 avr.' >>> format_skeleton('MMMEd', t, locale='en') u'Sun, Apr 1' + >>> format_skeleton('yMMd', t, locale='fi') # yMMd is not in the Finnish locale; yMd gets used + u'1.4.2007' + >>> format_skeleton('yMMd', t, fuzzy=False, locale='fi') # yMMd is not in the Finnish locale, an error is thrown + Traceback (most recent call last): + ... + KeyError: yMMd After the skeleton is resolved to a pattern `format_datetime` is called so all timezone processing etc is the same as for that. @@ -762,9 +768,13 @@ def format_skeleton(skeleton, datetime=None, tzinfo=None, locale=LC_TIME): :param datetime: the ``time`` or ``datetime`` object; if `None`, the current time in UTC is used :param tzinfo: the time-zone to apply to the time for display + :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's + close enough to it. :param locale: a `Locale` object or a locale identifier """ locale = Locale.parse(locale) + if fuzzy and skeleton not in locale.datetime_skeletons: + skeleton = match_skeleton(skeleton, locale.datetime_skeletons) format = locale.datetime_skeletons[skeleton] return format_datetime(datetime, format, tzinfo, locale) @@ -905,7 +915,7 @@ def _format_fallback_interval(start, end, skeleton, tzinfo, locale): ) -def format_interval(start, end, skeleton, tzinfo=None, locale=LC_TIME): +def format_interval(start, end, skeleton=None, tzinfo=None, fuzzy=True, locale=LC_TIME): """ Format an interval between two instants according to the locale's rules. @@ -926,10 +936,23 @@ def format_interval(start, end, skeleton, tzinfo=None, locale=LC_TIME): >>> format_interval(time(16, 18), time(16, 18), "Hm", locale="it") '16:18' + Unknown skeletons fall back to "default" formatting. + + >>> format_interval(date(2015, 1, 1), date(2017, 1, 1), "wzq", locale="ja") + '2015/01/01\uff5e2017/01/01' + + >>> format_interval(time(16, 18), time(16, 24), "xxx", locale="ja") + '16:18:00\uff5e16:24:00' + + >>> format_interval(date(2016, 1, 15), date(2016, 1, 17), "xxx", locale="de") + '15.01.2016 \u2013 17.01.2016' + :param start: First instant (datetime/date/time) :param end: Second instant (datetime/date/time) :param skeleton: The "skeleton format" to use for formatting. :param tzinfo: tzinfo to use (if none is already attached) + :param fuzzy: If the skeleton is not found, allow choosing a skeleton that's + close enough to it. :param locale: A locale object or identifier. :return: Formatted interval """ @@ -942,19 +965,26 @@ def format_interval(start, end, skeleton, tzinfo=None, locale=LC_TIME): # > starting in the current locale and then following the locale fallback # > chain up to, but not including root. - if skeleton not in locale.interval_formats: + interval_formats = locale.interval_formats + + if skeleton not in interval_formats or not skeleton: # > If no match was found from the previous step, check what the closest # > match is in the fallback locale chain, as in availableFormats. That # > is, this allows for adjusting the string value field's width, # > including adjusting between "MMM" and "MMMM", and using different # > variants of the same field, such as 'v' and 'z'. - # TODO: Implement closest-match instead of immediately falling back - return _format_fallback_interval(start, end, skeleton, tzinfo, locale) + if skeleton and fuzzy: + skeleton = match_skeleton(skeleton, interval_formats) + else: + skeleton = None + if not skeleton: # Still no match whatsoever? + # > Otherwise, format the start and end datetime using the fallback pattern. + return _format_fallback_interval(start, end, skeleton, tzinfo, locale) - skel_formats = locale.interval_formats[skeleton] + skel_formats = interval_formats[skeleton] if start == end: - return format_skeleton(skeleton, start, tzinfo, locale) + return format_skeleton(skeleton, start, tzinfo, fuzzy=fuzzy, locale=locale) start = _ensure_datetime_tzinfo(_get_datetime(start), tzinfo=tzinfo) end = _ensure_datetime_tzinfo(_get_datetime(end), tzinfo=tzinfo) @@ -1489,3 +1519,73 @@ def split_interval_pattern(pattern): parts[-1].append((tok_type, tok_value)) return [untokenize_pattern(tokens) for tokens in parts] + + +def match_skeleton(skeleton, options, allow_different_fields=False): + """ + Find the closest match for the given datetime skeleton among the options given. + + This uses the rules outlined in the TR35 document. + + >>> match_skeleton('yMMd', ('yMd', 'yMMMd')) + 'yMd' + + >>> match_skeleton('yMMd', ('jyMMd',), allow_different_fields=True) + 'jyMMd' + + >>> match_skeleton('yMMd', ('qyMMd',), allow_different_fields=False) + + >>> match_skeleton('hmz', ('hmv',)) + 'hmv' + + :param skeleton: The skeleton to match + :type skeleton: str + :param options: An iterable of other skeletons to match against + :type options: Iterable[str] + :return: The closest skeleton match, or if no match was found, None. + :rtype: str|None + """ + + # TODO: maybe implement pattern expansion? + + # Based on the implementation in + # http://source.icu-project.org/repos/icu/icu4j/trunk/main/classes/core/src/com/ibm/icu/text/DateIntervalInfo.java + + # Filter out falsy values and sort for stability; when `interval_formats` is passed in, there may be a None key. + options = sorted(option for option in options if option) + + if 'z' in skeleton and not any('z' in option for option in options): + skeleton = skeleton.replace('z', 'v') + + get_input_field_width = dict(t[1] for t in tokenize_pattern(skeleton) if t[0] == "field").get + best_skeleton = None + best_distance = None + for option in options: + get_opt_field_width = dict(t[1] for t in tokenize_pattern(option) if t[0] == "field").get + distance = 0 + for field in PATTERN_CHARS: + input_width = get_input_field_width(field, 0) + opt_width = get_opt_field_width(field, 0) + if input_width == opt_width: + continue + if opt_width == 0 or input_width == 0: + if not allow_different_fields: # This one is not okay + option = None + break + distance += 0x1000 # Magic weight constant for "entirely different fields" + elif field == 'M' and ((input_width > 2 and opt_width <= 2) or (input_width <= 2 and opt_width > 2)): + distance += 0x100 # Magic weight for "text turns into a number" + else: + distance += abs(input_width - opt_width) + + if not option: # We lost the option along the way (probably due to "allow_different_fields") + continue + + if not best_skeleton or distance < best_distance: + best_skeleton = option + best_distance = distance + + if distance == 0: # Found a perfect match! + break + + return best_skeleton diff --git a/tests/test_date_intervals.py b/tests/test_date_intervals.py index 73fae462c..e5a797a94 100644 --- a/tests/test_date_intervals.py +++ b/tests/test_date_intervals.py @@ -13,25 +13,25 @@ def test_format_interval_same_instant_1(): - assert dates.format_interval(TEST_DT, TEST_DT, "yMMMd", locale="fi") == "8. tammikuuta 2016" + assert dates.format_interval(TEST_DT, TEST_DT, "yMMMd", fuzzy=False, locale="fi") == "8. tammikuuta 2016" def test_format_interval_same_instant_2(): - assert dates.format_interval(TEST_DT, TEST_DT, "xxx", locale="fi") == "8.1.2016 klo 11.46.15" + assert dates.format_interval(TEST_DT, TEST_DT, "xxx", fuzzy=False, locale="fi") == "8.1.2016 klo 11.46.15" def test_format_interval_same_instant_3(): - assert dates.format_interval(TEST_TIME, TEST_TIME, "xxx", locale="fi") == "11.46.15" + assert dates.format_interval(TEST_TIME, TEST_TIME, "xxx", fuzzy=False, locale="fi") == "11.46.15" def test_format_interval_same_instant_4(): - assert dates.format_interval(TEST_DATE, TEST_DATE, "xxx", locale="fi") == "8.1.2016" + assert dates.format_interval(TEST_DATE, TEST_DATE, "xxx", fuzzy=False, locale="fi") == "8.1.2016" def test_format_interval_no_difference(): t1 = TEST_DT t2 = t1 + datetime.timedelta(minutes=8) - assert dates.format_interval(t1, t2, "yMd", locale="fi") == "8.1.2016" + assert dates.format_interval(t1, t2, "yMd", fuzzy=False, locale="fi") == "8.1.2016" def test_format_interval_in_tz(): @@ -45,3 +45,10 @@ def test_format_interval_12_hour(): t2 = TEST_DT t1 = t2 - datetime.timedelta(hours=1) assert dates.format_interval(t1, t2, "hm", locale="en") == "10:46 \u2013 11:46 AM" + + +def test_format_interval_invalid_skeleton(): + t1 = TEST_DATE + t2 = TEST_DATE + datetime.timedelta(days=1) + assert dates.format_interval(t1, t2, "mumumu", fuzzy=False, locale="fi") == u"8.1.2016\u20139.1.2016" + assert dates.format_interval(t1, t2, fuzzy=False, locale="fi") == u"8.1.2016\u20139.1.2016" From 3e8d7cff5b145b2ed4a8fc0ca51a342b9fbca20b Mon Sep 17 00:00:00 2001 From: sudheesh001 Date: Thu, 28 Jan 2016 09:01:23 +0530 Subject: [PATCH 196/489] Fix #266 Points newcomers to easy bugs in the documentation --- CONTRIBUTING.md | 3 +++ README.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2fa0a6584..dfad49f60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,9 @@ For a PR to be merged, the following statements must hold true: not the author of the PR. Commits shall comply to the "Good Commits" standards outlined below. +To begin contributing have a look at the open [easy issues](https://github.com/python-babel/babel/issues?q=is%3Aopen+is%3Aissue+label%3Adifficulty%2Flow) +which could be fixed. + ## Correcting PRs Rebasing PRs is preferred over merging master into the source branches again diff --git a/README.md b/README.md index 4fa81e609..98ea01467 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Contributing to Babel ===================== If you want to contribute code to Babel, please take a look at our -CONTRIBUTING.md. +[CONTRIBUTING.md](https://github.com/python-babel/babel/blob/master/CONTRIBUTING.md). If you know your way around Babels codebase a bit and like to help further, we would appreciate any help in reviewing pull requests. Please contact us at From 5a2674f2740ffbc13825f1ddb6da7ab51c92c3bc Mon Sep 17 00:00:00 2001 From: Sven Anderson Date: Wed, 27 Jan 2016 15:35:20 +0100 Subject: [PATCH 197/489] Frontend: Add multi-domain support to compile_catalog command Some projects have their translations split up into several text domains within one package. This change adds the possibility to specify a space seperated list of domains in the configuration, like 'setup.py compile_catalog --domain="foo bar"', for instance. --- babel/messages/frontend.py | 18 +++++++---- .../project/i18n/de_DE/LC_MESSAGES/bar.po | 32 +++++++++++++++++++ .../project/i18n/de_DE/LC_MESSAGES/foo.po | 32 +++++++++++++++++++ tests/messages/test_frontend.py | 23 +++++++++++++ 4 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 tests/messages/data/project/i18n/de_DE/LC_MESSAGES/bar.po create mode 100644 tests/messages/data/project/i18n/de_DE/LC_MESSAGES/foo.po diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 8c6fd8252..73abf4f19 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -83,7 +83,7 @@ class compile_catalog(Command): description = 'compile message catalogs to binary MO files' user_options = [ ('domain=', 'D', - "domain of PO file (default 'messages')"), + "domains of PO files (space separated list, default 'messages')"), ('directory=', 'd', 'path to base directory containing the catalogs'), ('input-file=', 'i', @@ -118,6 +118,12 @@ def finalize_options(self): 'or the base directory') def run(self): + domains = self.domain.split() + + for domain in domains: + self._run_domain(domain) + + def _run_domain(self, domain): po_files = [] mo_files = [] @@ -126,19 +132,19 @@ def run(self): po_files.append((self.locale, os.path.join(self.directory, self.locale, 'LC_MESSAGES', - self.domain + '.po'))) + domain + '.po'))) mo_files.append(os.path.join(self.directory, self.locale, 'LC_MESSAGES', - self.domain + '.mo')) + domain + '.mo')) else: for locale in os.listdir(self.directory): po_file = os.path.join(self.directory, locale, - 'LC_MESSAGES', self.domain + '.po') + 'LC_MESSAGES', domain + '.po') if os.path.exists(po_file): po_files.append((locale, po_file)) mo_files.append(os.path.join(self.directory, locale, 'LC_MESSAGES', - self.domain + '.mo')) + domain + '.mo')) else: po_files.append((self.locale, self.input_file)) if self.output_file: @@ -146,7 +152,7 @@ def run(self): else: mo_files.append(os.path.join(self.directory, self.locale, 'LC_MESSAGES', - self.domain + '.mo')) + domain + '.mo')) if not po_files: raise DistutilsOptionError('no message catalogs found') diff --git a/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/bar.po b/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/bar.po new file mode 100644 index 000000000..c5c974892 --- /dev/null +++ b/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/bar.po @@ -0,0 +1,32 @@ +# German (Germany) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +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: 2007-07-30 22:18+0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: de_DE \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 0.9dev-r245\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "Stange" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "Fuhstange" +msgstr[1] "Fuhstangen" + diff --git a/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/foo.po b/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/foo.po new file mode 100644 index 000000000..c5c974892 --- /dev/null +++ b/tests/messages/data/project/i18n/de_DE/LC_MESSAGES/foo.po @@ -0,0 +1,32 @@ +# German (Germany) translations for TestProject. +# Copyright (C) 2007 FooBar, Inc. +# This file is distributed under the same license as the TestProject +# project. +# FIRST AUTHOR , 2007. +# +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: 2007-07-30 22:18+0200\n" +"Last-Translator: FULL NAME \n" +"Language-Team: de_DE \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 0.9dev-r245\n" + +#. This will be a translator coment, +#. that will include several lines +#: project/file1.py:8 +msgid "bar" +msgstr "Stange" + +#: project/file2.py:9 +msgid "foobar" +msgid_plural "foobars" +msgstr[0] "Fuhstange" +msgstr[1] "Fuhstangen" + diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index 975876a5c..fc1efad31 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -1112,6 +1112,29 @@ def test_compile_catalog_with_more_than_2_plural_forms(self): if os.path.isfile(mo_file): os.unlink(mo_file) + def test_compile_catalog_multidomain(self): + po_foo = os.path.join(self._i18n_dir(), 'de_DE', 'LC_MESSAGES', + 'foo.po') + po_bar = os.path.join(self._i18n_dir(), 'de_DE', 'LC_MESSAGES', + 'bar.po') + mo_foo = po_foo.replace('.po', '.mo') + mo_bar = po_bar.replace('.po', '.mo') + try: + self.cli.run(sys.argv + ['compile', + '--locale', 'de_DE', '--domain', 'foo bar', '--use-fuzzy', + '-d', self._i18n_dir()]) + for mo_file in [mo_foo, mo_bar]: + assert os.path.isfile(mo_file) + self.assertEqual("""\ +compiling catalog %r to %r +compiling catalog %r to %r +""" % (po_foo, mo_foo, po_bar, mo_bar), sys.stderr.getvalue()) + + finally: + for mo_file in [mo_foo, mo_bar]: + if os.path.isfile(mo_file): + os.unlink(mo_file) + def test_update(self): template = Catalog() template.add("1") From 65ce160b6915011e8c99e6175607e5fb329ce7fb Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Fri, 29 Jan 2016 14:47:30 +0200 Subject: [PATCH 198/489] pofile: refactor message sorting into helper fn --- babel/messages/pofile.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/babel/messages/pofile.py b/babel/messages/pofile.py index 3c18226da..1fb2c1c3d 100644 --- a/babel/messages/pofile.py +++ b/babel/messages/pofile.py @@ -427,13 +427,13 @@ def _write_message(message, prefix=''): prefix, _normalize(message.string or '', prefix) )) - messages = list(catalog) + sort_by = None if sort_output: - messages.sort() + sort_by = "message" elif sort_by_file: - messages.sort(key=lambda m: m.locations) + sort_by = "location" - for message in messages: + for message in _sort_messages(catalog, sort_by=sort_by): if not message.id: # This is the header "message" if omit_header: continue @@ -474,14 +474,29 @@ def _write_message(message, prefix=''): _write('\n') if not ignore_obsolete: - obsolete = list(catalog.obsolete.values()) - if sort_output: - obsolete.sort() - elif sort_by_file: - obsolete.sort(key=lambda m: m.locations) - - for message in obsolete: + for message in _sort_messages( + catalog.obsolete.values(), + sort_by=sort_by + ): for comment in message.user_comments: _write_comment(comment) _write_message(message, prefix='#~ ') _write('\n') + + +def _sort_messages(messages, sort_by): + """ + Sort the given message iterable by the given criteria. + + Always returns a list. + + :param messages: An iterable of Messages. + :param sort_by: Sort by which criteria? Options are `message` and `location`. + :return: list[Message] + """ + messages = list(messages) + if sort_by == "message": + messages.sort() + elif sort_by == "location": + messages.sort(key=lambda m: m.locations) + return messages From b8d7d48b0e7eb02fdc7086c2ed5b43cf2b8755bd Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 3 Feb 2016 00:54:42 +0200 Subject: [PATCH 199/489] Extract CLI: Add `input-dirs` alias for `input-paths`. Just in case some script expects to be able to pass `--input-dirs`. Fixes #330 Augments 19957e21470615d42fb7b6e2c1a580cd679d33c8 --- babel/messages/frontend.py | 11 +++++++++++ tests/messages/test_frontend.py | 13 +++++++++++++ 2 files changed, 24 insertions(+) diff --git a/babel/messages/frontend.py b/babel/messages/frontend.py index 73abf4f19..9bb46bbce 100644 --- a/babel/messages/frontend.py +++ b/babel/messages/frontend.py @@ -254,6 +254,8 @@ class extract_messages(Command): ('input-paths=', None, 'files or directories that should be scanned for messages. Separate multiple ' 'files or directories with commas(,)'), + ('input-dirs=', None, # TODO (3.x): Remove me. + 'alias for input-paths (does allow files as well as directories).'), ] boolean_options = [ 'no-default-keywords', 'no-location', 'omit-header', 'no-wrap', @@ -271,6 +273,7 @@ def initialize_options(self): self.no_location = False self.omit_header = False self.output_file = None + self.input_dirs = None self.input_paths = None self.width = None self.no_wrap = False @@ -284,6 +287,14 @@ def initialize_options(self): self.strip_comments = False def finalize_options(self): + if self.input_dirs: + if not self.input_paths: + self.input_paths = self.input_dirs + else: + raise DistutilsOptionError( + 'input-dirs and input-paths are mutually exclusive' + ) + if self.no_default_keywords and not self.keywords: raise DistutilsOptionError('you must specify new keywords if you ' 'disable the default ones') diff --git a/tests/messages/test_frontend.py b/tests/messages/test_frontend.py index fc1efad31..42e3e3836 100644 --- a/tests/messages/test_frontend.py +++ b/tests/messages/test_frontend.py @@ -132,6 +132,19 @@ def test_input_paths_handle_spaces_after_comma(self): self.assertEqual([this_dir, self.datadir], self.cmd.input_paths) + def test_input_dirs_is_alias_for_input_paths(self): + self.cmd.input_dirs = this_dir + self.cmd.output_file = self._pot_file() + self.cmd.finalize_options() + # Gets listified in `finalize_options`: + assert self.cmd.input_paths == [self.cmd.input_dirs] + + def test_input_dirs_is_mutually_exclusive_with_input_paths(self): + self.cmd.input_dirs = this_dir + self.cmd.input_paths = this_dir + self.cmd.output_file = self._pot_file() + 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' From 400d5ad343ccf6c179ca1b5abce2b11037e544a7 Mon Sep 17 00:00:00 2001 From: sudheesh001 Date: Thu, 28 Jan 2016 09:26:33 +0530 Subject: [PATCH 200/489] Added npgettext to default keywords in extraction --- babel/messages/extract.py | 3 ++- tests/messages/test_extract.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index 8183d527f..cadcb629b 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -38,7 +38,8 @@ 'dgettext': (2,), 'dngettext': (2, 3), 'N_': None, - 'pgettext': ((1, 'c'), 2) + 'pgettext': ((1, 'c'), 2), + 'npgettext': ((1, 'c', 2, 3)) } DEFAULT_MAPPING = [('**.py', 'python')] diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index fa03207c4..a9408f6af 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -60,6 +60,15 @@ def test_nested_comments(self): ['TRANSLATORS:'], {})) self.assertEqual([(1, 'ngettext', (u'pylon', u'pylons', None), [])], messages) + buf = BytesIO(b"""\ +msg = npgettext('Strings', 'pylon', # TRANSLATORS: shouldn't be + 'pylons', # TRANSLATORS: seeing this + count) +""") + messages = list(extract.extract_python(buf, ('npgettext',), + ['TRANSLATORS:'], {})) + self.assertEqual([(1, 'npgettext', (u'Strings', u'pylon', u'pylons', None), [])], + messages) def test_comments_with_calls_that_spawn_multiple_lines(self): buf = BytesIO(b"""\ @@ -131,6 +140,17 @@ def test_multiline(self): self.assertEqual([(1, 'ngettext', (u'pylon', u'pylons', None), []), (3, 'ngettext', (u'elvis', u'elvises', None), [])], messages) + buf = BytesIO(b"""\ +msg1 = npgettext('Strings','pylon', + 'pylons', count) +msg2 = npgettext('Strings','elvis', + 'elvises', + count) +""") + messages = list(extract.extract_python(buf, ('npgettext',), [], {})) + self.assertEqual([(1, 'npgettext', (u'Strings', u'pylon', u'pylons', None), []), + (3, 'npgettext', (u'Strings', u'elvis', u'elvises', None), [])], + messages) def test_triple_quoted_strings(self): buf = BytesIO(b"""\ From 7a672aeb8bdea2c2c775df37b82397850a42641a Mon Sep 17 00:00:00 2001 From: sudheesh001 Date: Thu, 28 Jan 2016 09:26:33 +0530 Subject: [PATCH 201/489] Extract: Add npgettext to default keywords map Add the npgettext as a default keyword in the keywords map. Create test_npgettext in test_extract showing the passing tests. Fixes https://github.com/python-babel/babel/issues/281 Fixes https://github.com/python-babel/babel/issues/328 --- babel/messages/extract.py | 2 +- tests/messages/test_extract.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/babel/messages/extract.py b/babel/messages/extract.py index cadcb629b..c2dcd5b99 100644 --- a/babel/messages/extract.py +++ b/babel/messages/extract.py @@ -39,7 +39,7 @@ 'dngettext': (2, 3), 'N_': None, 'pgettext': ((1, 'c'), 2), - 'npgettext': ((1, 'c', 2, 3)) + 'npgettext': ((1, 'c'), 2, 3) } DEFAULT_MAPPING = [('**.py', 'python')] diff --git a/tests/messages/test_extract.py b/tests/messages/test_extract.py index a9408f6af..66c8cd908 100644 --- a/tests/messages/test_extract.py +++ b/tests/messages/test_extract.py @@ -60,15 +60,6 @@ def test_nested_comments(self): ['TRANSLATORS:'], {})) self.assertEqual([(1, 'ngettext', (u'pylon', u'pylons', None), [])], messages) - buf = BytesIO(b"""\ -msg = npgettext('Strings', 'pylon', # TRANSLATORS: shouldn't be - 'pylons', # TRANSLATORS: seeing this - count) -""") - messages = list(extract.extract_python(buf, ('npgettext',), - ['TRANSLATORS:'], {})) - self.assertEqual([(1, 'npgettext', (u'Strings', u'pylon', u'pylons', None), [])], - messages) def test_comments_with_calls_that_spawn_multiple_lines(self): buf = BytesIO(b"""\ @@ -140,6 +131,8 @@ def test_multiline(self): self.assertEqual([(1, 'ngettext', (u'pylon', u'pylons', None), []), (3, 'ngettext', (u'elvis', u'elvises', None), [])], messages) + + def test_npgettext(self): buf = BytesIO(b"""\ msg1 = npgettext('Strings','pylon', 'pylons', count) @@ -151,6 +144,15 @@ def test_multiline(self): self.assertEqual([(1, 'npgettext', (u'Strings', u'pylon', u'pylons', None), []), (3, 'npgettext', (u'Strings', u'elvis', u'elvises', None), [])], messages) + buf = BytesIO(b"""\ +msg = npgettext('Strings', 'pylon', # TRANSLATORS: shouldn't be + 'pylons', # TRANSLATORS: seeing this + count) +""") + messages = list(extract.extract_python(buf, ('npgettext',), + ['TRANSLATORS:'], {})) + self.assertEqual([(1, 'npgettext', (u'Strings', u'pylon', u'pylons', None), [])], + messages) def test_triple_quoted_strings(self): buf = BytesIO(b"""\ From 949e4cb402b6290245b88051c00577b96ae4787f Mon Sep 17 00:00:00 2001 From: Sachin Paliwal Date: Fri, 5 Feb 2016 21:09:34 +0530 Subject: [PATCH 202/489] dates: Add Features in get timezone functions Added argument 'return_z' and two values 'iso8601' and 'iso8601_short' in argument width in get_timezone_gmt(), 'return_city' argument is add in get_timezone_location() and 'return_zone' in get_timezone_name() so we can implement iso8601 timezone patterns. --- babel/dates.py | 43 +++++++++++++++++++++++++++++++++++-------- tests/test_dates.py | 12 ++++++++++-- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index b41945bc7..6d6c36aa8 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -370,20 +370,25 @@ def get_time_format(format='medium', locale=LC_TIME): return Locale.parse(locale).time_formats[format] -def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): +def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME, return_z=False): """Return the timezone associated with the given `datetime` object formatted as string indicating the offset from GMT. >>> dt = datetime(2007, 4, 1, 15, 30) >>> get_timezone_gmt(dt, locale='en') u'GMT+00:00' - + >>> get_timezone_gmt(dt, locale='en', return_z=True) + 'Z' + >>> get_timezone_gmt(dt, locale='en', width='iso8601_short') + u'+00' >>> tz = get_timezone('America/Los_Angeles') >>> dt = tz.localize(datetime(2007, 4, 1, 15, 30)) >>> get_timezone_gmt(dt, locale='en') u'GMT-07:00' >>> get_timezone_gmt(dt, 'short', locale='en') u'-0700' + >>> get_timezone_gmt(dt, locale='en', width='iso8601_short') + u'-07' The long format depends on the locale, for example in France the acronym UTC string is used instead of GMT: @@ -395,8 +400,10 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): :param datetime: the ``datetime`` object; if `None`, the current date and time in UTC is used - :param width: either "long" or "short" + :param width: either "long" or "short" or "iso8601" or "iso8601_short" :param locale: the `Locale` object, or a locale string + :param return_z: True or False; Function returns indicator "Z" + when local time offset is 0 """ datetime = _ensure_datetime_tzinfo(_get_datetime(datetime)) locale = Locale.parse(locale) @@ -404,14 +411,20 @@ def get_timezone_gmt(datetime=None, width='long', locale=LC_TIME): offset = datetime.tzinfo.utcoffset(datetime) seconds = offset.days * 24 * 60 * 60 + offset.seconds hours, seconds = divmod(seconds, 3600) - if width == 'short': + if return_z and hours == 0 and seconds == 0: + return 'Z' + elif seconds == 0 and width == 'iso8601_short': + return u'%+03d' % hours + elif width == 'short' or width == 'iso8601_short': pattern = u'%+03d%02d' + elif width == 'iso8601': + pattern = u'%+03d:%02d' else: pattern = locale.zone_formats['gmt'] % '%+03d:%02d' return pattern % (hours, seconds // 60) -def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): +def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME, return_city=False): u"""Return a representation of the given timezone using "location format". The result depends on both the local display name of the country and the @@ -420,6 +433,10 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): >>> tz = get_timezone('America/St_Johns') >>> print(get_timezone_location(tz, locale='de_DE')) Kanada (St. John’s) Zeit + >>> print(get_timezone_location(tz, locale='en')) + Canada (St. John’s) Time + >>> print(get_timezone_location(tz, locale='en', return_city=True)) + St. John’s >>> tz = get_timezone('America/Mexico_City') >>> get_timezone_location(tz, locale='de_DE') u'Mexiko (Mexiko-Stadt) Zeit' @@ -437,7 +454,10 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): the timezone; if `None`, the current date and time in UTC is assumed :param locale: the `Locale` object, or a locale string + :param return_city: True or False, if True then return exemplar city (location) + for the time zone :return: the localized timezone name using location format + """ dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) @@ -459,7 +479,7 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): if territory not in locale.territories: territory = 'ZZ' # invalid/unknown territory_name = locale.territories[territory] - if territory and len(get_global('territory_zones').get(territory, [])) == 1: + if not return_city and territory and len(get_global('territory_zones').get(territory, [])) == 1: return region_format % (territory_name) # Otherwise, include the city in the output @@ -476,6 +496,8 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): else: city_name = zone.replace('_', ' ') + if return_city: + return city_name return region_format % (fallback_format % { '0': city_name, '1': territory_name @@ -483,13 +505,15 @@ def get_timezone_location(dt_or_tzinfo=None, locale=LC_TIME): def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, - locale=LC_TIME, zone_variant=None): + locale=LC_TIME, zone_variant=None, return_zone=False): r"""Return the localized display name for the given timezone. The timezone may be specified using a ``datetime`` or `tzinfo` object. >>> 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, locale='en_US', return_zone=True) + 'America/Los_Angeles' >>> get_timezone_name(dt, width='short', locale='en_US') u'PST' @@ -548,6 +572,8 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, values are valid: ``'generic'``, ``'daylight'`` and ``'standard'``. :param locale: the `Locale` object, or a locale string + :param return_zone: True or False. If true then function + returns long time zone ID """ dt, tzinfo = _get_dt_and_tzinfo(dt_or_tzinfo) locale = Locale.parse(locale) @@ -572,7 +598,8 @@ def get_timezone_name(dt_or_tzinfo=None, width='long', uncommon=False, # Get the canonical time-zone code zone = get_global('zone_aliases').get(zone, zone) - + if return_zone: + return zone info = locale.time_zones.get(zone, {}) # Try explicitly translated zone names first if width in info: diff --git a/tests/test_dates.py b/tests/test_dates.py index e93fa401b..0d3fe546e 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -393,12 +393,13 @@ def test_get_time_format(): def test_get_timezone_gmt(): dt = datetime(2007, 4, 1, 15, 30) assert dates.get_timezone_gmt(dt, locale='en') == u'GMT+00:00' - + assert dates.get_timezone_gmt(dt, locale='en', return_z=True) == 'Z' + assert dates.get_timezone_gmt(dt, locale='en', width='iso8601_short') == u'+00' tz = timezone('America/Los_Angeles') dt = tz.localize(datetime(2007, 4, 1, 15, 30)) assert dates.get_timezone_gmt(dt, locale='en') == u'GMT-07:00' assert dates.get_timezone_gmt(dt, 'short', locale='en') == u'-0700' - + assert dates.get_timezone_gmt(dt, locale='en', width='iso8601_short') == u'-07' assert dates.get_timezone_gmt(dt, 'long', locale='fr_FR') == u'UTC-07:00' @@ -406,6 +407,11 @@ def test_get_timezone_location(): tz = timezone('America/St_Johns') assert (dates.get_timezone_location(tz, locale='de_DE') == u"Kanada (St. John\u2019s) Zeit") + assert (dates.get_timezone_location(tz, locale='en') == + u'Canada (St. John’s) Time') + assert (dates.get_timezone_location(tz, locale='en', return_city=True) == + u'St. John’s') + tz = timezone('America/Mexico_City') assert (dates.get_timezone_location(tz, locale='de_DE') == u'Mexiko (Mexiko-Stadt) Zeit') @@ -419,6 +425,8 @@ def test_get_timezone_name(): dt = time(15, 30, tzinfo=timezone('America/Los_Angeles')) assert (dates.get_timezone_name(dt, locale='en_US') == u'Pacific Standard Time') + assert (dates.get_timezone_name(dt, locale='en_US', return_zone=True) == + u'America/Los_Angeles') assert dates.get_timezone_name(dt, width='short', locale='en_US') == u'PST' tz = timezone('America/Los_Angeles') From b7593085f38ccee7211972931c9e53d6eee1ceb3 Mon Sep 17 00:00:00 2001 From: Sachin Paliwal Date: Fri, 5 Feb 2016 21:17:55 +0530 Subject: [PATCH 203/489] dates: Add iso8601 pattern timezone Format https://github.com/python-babel/babel/issues/325 --- babel/dates.py | 59 +++++++++++++++------ tests/test_dates.py | 123 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 15 deletions(-) diff --git a/babel/dates.py b/babel/dates.py index 6d6c36aa8..1af9955a5 100644 --- a/babel/dates.py +++ b/babel/dates.py @@ -1194,7 +1194,7 @@ def __getitem__(self, name): return self.format_frac_seconds(num) elif char == 'A': return self.format_milliseconds_in_day(num) - elif char in ('z', 'Z', 'v', 'V'): + elif char in ('z', 'Z', 'v', 'V', 'x', 'X', 'O'): return self.format_timezone(char, num) else: raise KeyError('Unsupported date/time field %r' % char) @@ -1296,11 +1296,17 @@ def format_milliseconds_in_day(self, num): return self.format(msecs, num) def format_timezone(self, char, num): - width = {3: 'short', 4: 'long'}[max(3, num)] + width = {3: 'short', 4: 'long', 5: 'iso8601'}[max(3, num)] if char == 'z': return get_timezone_name(self.value, width, locale=self.locale) elif char == 'Z': + if num == 5: + return get_timezone_gmt(self.value, width, locale=self.locale, return_z=True) return get_timezone_gmt(self.value, width, locale=self.locale) + elif char == 'O': + if num == 4: + return get_timezone_gmt(self.value, width, locale=self.locale) + # TODO: To add support for O:1 elif char == 'v': return get_timezone_name(self.value.tzinfo, width, locale=self.locale) @@ -1308,7 +1314,29 @@ def format_timezone(self, char, num): if num == 1: return get_timezone_name(self.value.tzinfo, width, uncommon=True, locale=self.locale) + elif num == 2: + return get_timezone_name(self.value.tzinfo, locale=self.locale, return_zone=True) + elif num == 3: + return get_timezone_location(self.value.tzinfo, locale=self.locale, return_city=True) return get_timezone_location(self.value.tzinfo, locale=self.locale) + # Included additional elif condition to add support for 'Xx' in timezone format + elif char == 'X': + if num == 1: + return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale, + return_z=True) + elif num in (2, 4): + return get_timezone_gmt(self.value, width='short', locale=self.locale, + return_z=True) + elif num in (3, 5): + return get_timezone_gmt(self.value, width='iso8601', locale=self.locale, + return_z=True) + elif char == 'x': + if num == 1: + return get_timezone_gmt(self.value, width='iso8601_short', locale=self.locale) + elif num in (2, 4): + return get_timezone_gmt(self.value, width='short', locale=self.locale) + elif num in (3, 5): + return get_timezone_gmt(self.value, width='iso8601', locale=self.locale) def format(self, value, length): return ('%%0%dd' % length) % value @@ -1352,24 +1380,25 @@ def get_week_number(self, day_of_period, day_of_week=None): 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], 'V': [1, 4] # zone + '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, 5], 'O': [1, 4], 'v': [1, 4], # zone + 'V': [1, 2, 3, 4], 'x': [1, 2, 3, 4, 5], 'X': [1, 2, 3, 4, 5] # zone } #: The pattern characters declared in the Date Field Symbol Table #: (http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) #: in order of decreasing magnitude. -PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZvV" +PATTERN_CHAR_ORDER = "GyYuUQqMLlwWdDFgEecabBChHKkjJmsSAzZOvVXx" _pattern_cache = {} diff --git a/tests/test_dates.py b/tests/test_dates.py index 0d3fe546e..5155c0cca 100644 --- a/tests/test_dates.py +++ b/tests/test_dates.py @@ -250,6 +250,129 @@ def test_with_float(self): formatted_string = dates.format_datetime(epoch, format='long', locale='en_US') self.assertEqual(u'April 1, 2012 at 3:30:29 PM +0000', formatted_string) + def test_timezone_formats(self): + dt = datetime(2016, 1, 13, 7, 8, 35) + tz = dates.get_timezone('America/Los_Angeles') + dt = tz.localize(dt) + formatted_string = dates.format_datetime(dt, 'z', locale='en') + self.assertEqual(u'PST', formatted_string) + formatted_string = dates.format_datetime(dt, 'zz', locale='en') + self.assertEqual(u'PST', formatted_string) + formatted_string = dates.format_datetime(dt, 'zzz', locale='en') + self.assertEqual(u'PST', formatted_string) + formatted_string = dates.format_datetime(dt, 'zzzz', locale='en') + self.assertEqual(u'Pacific Standard Time', formatted_string) + formatted_string = dates.format_datetime(dt, 'Z', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZ', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZ', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZ', locale='en') + self.assertEqual(u'GMT-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZZ', locale='en') + self.assertEqual(u'-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'OOOO', locale='en') + self.assertEqual(u'GMT-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'VV', locale='en') + self.assertEqual(u'America/Los_Angeles', formatted_string) + formatted_string = dates.format_datetime(dt, 'VVV', locale='en') + self.assertEqual(u'Los Angeles', formatted_string) + formatted_string = dates.format_datetime(dt, 'X', locale='en') + self.assertEqual(u'-08', formatted_string) + formatted_string = dates.format_datetime(dt, 'XX', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXX', locale='en') + self.assertEqual(u'-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXX', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXXX', locale='en') + self.assertEqual(u'-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'x', locale='en') + self.assertEqual(u'-08', formatted_string) + formatted_string = dates.format_datetime(dt, 'xx', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxx', locale='en') + self.assertEqual(u'-08:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxx', locale='en') + self.assertEqual(u'-0800', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxxx', locale='en') + self.assertEqual(u'-08:00', formatted_string) + dt = datetime(2016, 1, 13, 7, 8, 35) + tz = dates.get_timezone('UTC') + dt = tz.localize(dt) + formatted_string = dates.format_datetime(dt, 'Z', locale='en') + self.assertEqual(u'+0000', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZ', locale='en') + self.assertEqual(u'+0000', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZ', locale='en') + self.assertEqual(u'+0000', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZ', locale='en') + self.assertEqual(u'GMT+00:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZZ', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'OOOO', locale='en') + self.assertEqual(u'GMT+00:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'VV', locale='en') + self.assertEqual(u'Etc/GMT', formatted_string) + formatted_string = dates.format_datetime(dt, 'VVV', locale='en') + self.assertEqual(u'GMT', formatted_string) + formatted_string = dates.format_datetime(dt, 'X', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'XX', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXX', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXX', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXXX', locale='en') + self.assertEqual(u'Z', formatted_string) + formatted_string = dates.format_datetime(dt, 'x', locale='en') + self.assertEqual(u'+00', formatted_string) + formatted_string = dates.format_datetime(dt, 'xx', locale='en') + self.assertEqual(u'+0000', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxx', locale='en') + self.assertEqual(u'+00:00', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxx', locale='en') + self.assertEqual(u'+0000', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxxx', locale='en') + self.assertEqual(u'+00:00', formatted_string) + dt = datetime(2016, 1, 13, 7, 8, 35) + tz = dates.get_timezone('Asia/Kolkata') + dt = tz.localize(dt) + formatted_string = dates.format_datetime(dt, 'zzzz', locale='en') + self.assertEqual(u'India Standard Time', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZ', locale='en') + self.assertEqual(u'GMT+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'ZZZZZ', locale='en') + self.assertEqual(u'+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'OOOO', locale='en') + self.assertEqual(u'GMT+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'VV', locale='en') + self.assertEqual(u'Asia/Calcutta', formatted_string) + formatted_string = dates.format_datetime(dt, 'VVV', locale='en') + self.assertEqual(u'Kolkata', formatted_string) + formatted_string = dates.format_datetime(dt, 'X', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'XX', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXX', locale='en') + self.assertEqual(u'+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXX', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'XXXXX', locale='en') + self.assertEqual(u'+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'x', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'xx', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxx', locale='en') + self.assertEqual(u'+05:30', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxx', locale='en') + self.assertEqual(u'+0530', formatted_string) + formatted_string = dates.format_datetime(dt, 'xxxxx', locale='en') + self.assertEqual(u'+05:30', formatted_string) + class FormatTimeTestCase(unittest.TestCase): From bda29a7089c3a21e9e49887f54cd73e38f6445d5 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 18 Jan 2016 16:54:23 +0200 Subject: [PATCH 204/489] JavaScript: modernize lexer slightly --- babel/messages/jslexer.py | 37 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/babel/messages/jslexer.py b/babel/messages/jslexer.py index 22c6e1f9c..282d294f3 100644 --- a/babel/messages/jslexer.py +++ b/babel/messages/jslexer.py @@ -9,27 +9,34 @@ :copyright: (c) 2013 by the Babel Team. :license: BSD, see LICENSE for more details. """ - -from operator import itemgetter +from collections import namedtuple import re from babel._compat import unichr -operators = [ +operators = sorted([ '+', '-', '*', '%', '!=', '==', '<', '>', '<=', '>=', '=', '+=', '-=', '*=', '%=', '<<', '>>', '>>>', '<<=', '>>=', '>>>=', '&', '&=', '|', '|=', '&&', '||', '^', '^=', '(', ')', '[', ']', '{', '}', '!', '--', '++', '~', ',', ';', '.', ':' -] -operators.sort(key=lambda a: -len(a)) +], key=len, reverse=True) escapes = {'b': '\b', 'f': '\f', 'n': '\n', 'r': '\r', 't': '\t'} +division_re = re.compile(r'/=?') +regex_re = re.compile(r'/(?:[^/\\]*(?:\\.[^/\\]*)*)/[a-zA-Z]*(?s)') +line_re = re.compile(r'(\r\n|\n|\r)') +line_join_re = re.compile(r'\\' + line_re.pattern) +uni_escape_re = re.compile(r'[a-fA-F0-9]{1,4}') +name_re = re.compile(r'(\$+\w*|[^\W\d]\w*)(?u)') + +Token = namedtuple('Token', 'type value lineno') + rules = [ (None, re.compile(r'\s+(?u)')), (None, re.compile(r'