changeset 181:8a762ce37bf7 trunk

The frontends now provide ways to update existing translations catalogs from a template. Closes #22.
author cmlenz
date Thu, 28 Jun 2007 10:28:25 +0000
parents 31beb381d62f
children 9da358020629
files ChangeLog babel/messages/catalog.py babel/messages/frontend.py babel/messages/pofile.py babel/messages/tests/catalog.py babel/messages/tests/data/project/i18n/messages_non_fuzzy.pot babel/messages/tests/frontend.py babel/messages/tests/pofile.py doc/cmdline.txt doc/setup.txt setup.py
diffstat 11 files changed, 352 insertions(+), 62 deletions(-) [+]
line wrap: on
line diff
--- a/ChangeLog
+++ b/ChangeLog
@@ -3,6 +3,7 @@
 (?, from branches/stable/0.9.x)
 
  * Added compilation of message catalogs to MO files.
+ * Added updating of message catalogs from POT files.
 
 
 Version 0.8.1
--- a/babel/messages/catalog.py
+++ b/babel/messages/catalog.py
@@ -176,7 +176,9 @@
         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.fuzzy = fuzzy #: Catalog header fuzzy bit (`True` or `False`)
+
+        self.obsolete = odict() #: Dictionary of obsolete messages
 
     def _get_header_comment(self):
         comment = self._header_comment
@@ -496,7 +498,7 @@
         >>> catalog.add(('salad', 'salads'), (u'Salat', u'Salate'),
         ...             locations=[('util.py', 38)])
         
-        >>> rest = catalog.update(template)
+        >>> catalog.update(template)
         >>> len(catalog)
         2
         
@@ -512,15 +514,17 @@
         >>> msg2.locations
         [('util.py', 42)]
         
+        Messages that are in the catalog but not in the template are removed
+        from the main collection, but can still be accessed via the `obsolete`
+        member:
+        
         >>> 'head' in catalog
         False
-        >>> rest
+        >>> catalog.obsolete.values()
         [<Message 'head' (Flags: '')>]
         
         :param template: the reference catalog, usually read from a POT file
         :param fuzzy_matching: whether to use fuzzy matching of message IDs
-        :return: a list of `Message` objects that the catalog contained before
-                 the updated, but couldn't be found in the template
         """
         messages = self._messages
         self._messages = odict()
@@ -548,7 +552,7 @@
 
                     self[message.id] = message
 
-        return messages.values()
+        self.obsolete = messages
 
     def _key_for(self, id):
         """The key for a message is just the singular ID even for pluralizable
--- a/babel/messages/frontend.py
+++ b/babel/messages/frontend.py
@@ -37,7 +37,7 @@
 from babel.util import odict, LOCALTZ
 
 __all__ = ['CommandLineInterface', 'compile_catalog', 'extract_messages',
-           'new_catalog', 'check_message_extractors']
+           'init_catalog', 'check_message_extractors']
 __docformat__ = 'restructuredtext en'
 
 
@@ -59,7 +59,7 @@
     :see: `setuptools <http://peak.telecommunity.com/DevCenter/setuptools>`_
     """
 
-    description = 'compile a catalog to a binary MO file'
+    description = 'compile message catalogs to binary MO files'
     user_options = [
         ('domain=', 'D',
          "domain of PO file (default 'messages')"),
@@ -75,7 +75,7 @@
         ('use-fuzzy', 'f',
          'also include fuzzy translations'),
     ]
-    boolean_options = ['use-fuzzy', 'compile-all']
+    boolean_options = ['use-fuzzy']
 
     def initialize_options(self):
         self.domain = 'messages'
@@ -341,25 +341,25 @@
                                   'parameter must be a dictionary')
 
 
-class new_catalog(Command):
-    """New catalog command for use in ``setup.py`` scripts.
+class init_catalog(Command):
+    """New catalog initialization command for use in ``setup.py`` scripts.
 
     If correctly installed, this command is available to Setuptools-using
     setup scripts automatically. For projects using plain old ``distutils``,
     the command needs to be registered explicitly in ``setup.py``::
 
-        from babel.messages.frontend import new_catalog
+        from babel.messages.frontend import init_catalog
 
         setup(
             ...
-            cmdclass = {'new_catalog': new_catalog}
+            cmdclass = {'init_catalog': init_catalog}
         )
 
     :see: `Integrating new distutils commands <http://docs.python.org/dist/node32.html>`_
     :see: `setuptools <http://peak.telecommunity.com/DevCenter/setuptools>`_
     """
 
-    description = 'create new catalogs based on a catalog template'
+    description = 'create a new catalog based on a POT file'
     user_options = [
         ('domain=', 'D',
          "domain of PO file (default 'messages')"),
@@ -421,6 +421,94 @@
             outfile.close()
 
 
+class update_catalog(Command):
+    """Catalog merging command for use in ``setup.py`` scripts.
+
+    If correctly installed, this command is available to Setuptools-using
+    setup scripts automatically. For projects using plain old ``distutils``,
+    the command needs to be registered explicitly in ``setup.py``::
+
+        from babel.messages.frontend import update_catalog
+
+        setup(
+            ...
+            cmdclass = {'update_catalog': update_catalog}
+        )
+
+    :see: `Integrating new distutils commands <http://docs.python.org/dist/node32.html>`_
+    :see: `setuptools <http://peak.telecommunity.com/DevCenter/setuptools>`_
+    """
+
+    description = 'update message catalogs from a POT file'
+    user_options = [
+        ('domain=', 'D',
+         "domain of PO file (default 'messages')"),
+        ('input-file=', 'i',
+         'name of the input file'),
+        ('output-dir=', 'd',
+         'path to base directory containing the catalogs'),
+        ('output-file=', 'o',
+         "name of the output file (default "
+         "'<output_dir>/<locale>/LC_MESSAGES/<domain>.po')"),
+        ('locale=', 'l',
+         'locale of the catalog to compile'),
+    ]
+
+    def initialize_options(self):
+        self.domain = 'messages'
+        self.input_file = None
+        self.output_dir = None
+        self.output_file = None
+        self.locale = None
+
+    def finalize_options(self):
+        if not self.input_file:
+            raise DistutilsOptionError('you must specify the input file')
+        if not self.output_file and not self.output_dir:
+            raise DistutilsOptionError('you must specify the output file or '
+                                       'directory')
+
+    def run(self):
+        po_files = []
+        if not self.output_file:
+            if self.locale:
+                po_files.append(os.path.join(self.output_dir, self.locale,
+                                             'LC_MESSAGES',
+                                             self.domain + '.po'))
+            else:
+                for locale in os.listdir(self.output_dir):
+                    po_file = os.path.join(self.output_dir, locale,
+                                           'LC_MESSAGES',
+                                           self.domain + '.po')
+                    if os.path.exists(po_file):
+                        po_files.append(po_file)
+        else:
+            po_files.append(self.output_file)
+
+        infile = open(self.input_file, 'U')
+        try:
+            template = read_po(infile)
+        finally:
+            infile.close()
+
+        for po_file in po_files:
+            log.info('updating catalog %r based on %r', po_file,
+                     self.input_file)
+            infile = open(po_file, 'U')
+            try:
+                catalog = read_po(infile)
+            finally:
+                infile.close()
+
+            rest = catalog.update(template)
+
+            outfile = open(po_file, 'w')
+            try:
+                write_po(outfile, catalog)
+            finally:
+                outfile.close()
+
+
 class CommandLineInterface(object):
     """Command-line interface.
 
@@ -433,7 +521,8 @@
     commands = {
         'compile': 'compile message catalogs to MO files',
         'extract': 'extract messages from source files and generate a POT file',
-        'init': 'create new message catalogs from a template',
+        'init':    'create new message catalogs from a POT file',
+        'update':  'update existing message catalogs from a POT file'
     }
 
     def run(self, argv=sys.argv):
@@ -729,6 +818,75 @@
         finally:
             outfile.close()
 
+    def update(self, argv):
+        """Subcommand for updating existing message catalogs from a template.
+
+        :param argv: the command arguments
+        """
+        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 "
+                               "'<output_dir>/<locale>/LC_MESSAGES/"
+                               "<domain>.po')")
+        parser.add_option('--locale', '-l', dest='locale', metavar='LOCALE',
+                          help='locale of the translations catalog')
+
+        parser.set_defaults(domain='messages')
+        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')
+
+        po_files = []
+        if not options.output_file:
+            if options.locale:
+                po_files.append(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(po_file)
+        else:
+            po_files.append(options.output_file)
+
+        infile = open(options.input_file, 'U')
+        try:
+            template = read_po(infile)
+        finally:
+            infile.close()
+
+        for po_file in po_files:
+            print 'updating catalog %r based on %r' % (po_file,
+                                                       options.input_file)
+            infile = open(po_file, 'U')
+            try:
+                catalog = read_po(infile)
+            finally:
+                infile.close()
+
+            rest = catalog.update(template)
+
+            outfile = open(po_file, 'w')
+            try:
+                write_po(outfile, catalog)
+            finally:
+                outfile.close()
+
 
 def main():
     CommandLineInterface().run(sys.argv)
--- a/babel/messages/pofile.py
+++ b/babel/messages/pofile.py
@@ -338,6 +338,24 @@
             text = text.encode(catalog.charset)
         fileobj.write(text)
 
+    def _write_comment(comment, prefix=''):
+        lines = comment
+        if width and width > 0:
+            lines = wrap(comment, width, break_long_words=False)
+        for line in lines:
+            _write('#%s %s\n' % (prefix, line.strip()))
+
+    def _write_message(message, prefix=''):
+        if isinstance(message.id, (list, tuple)):
+            _write('%smsgid %s\n' % (prefix, _normalize(message.id[0])))
+            _write('%smsgid_plural %s\n' % (prefix, _normalize(message.id[1])))
+            for i, string in enumerate(message.string):
+                _write('%smsgstr[%d] %s\n' % (prefix, i,
+                                              _normalize(message.string[i])))
+        else:
+            _write('%smsgid %s\n' % (prefix, _normalize(message.id)))
+            _write('%smsgstr %s\n' % (prefix, _normalize(message.string or '')))
+
     messages = list(catalog)
     if sort_output:
         messages.sort(lambda x,y: cmp(x.id, y.id))
@@ -357,32 +375,23 @@
                 comment_header = u'\n'.join(lines) + u'\n'
             _write(comment_header)
 
-        if message.user_comments:
-            for comment in message.user_comments:
-                for line in wrap(comment, width, break_long_words=False):
-                    _write('# %s\n' % line.strip())
-
-        if message.auto_comments:
-            for comment in message.auto_comments:
-                for line in wrap(comment, width, break_long_words=False):
-                    _write('#. %s\n' % line.strip())
+        for comment in message.user_comments:
+            _write_comment(comment)
+        for comment in message.auto_comments:
+            _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])
-            if width and width > 0:
-                locs = wrap(locs, width, break_long_words=False)
-            for line in locs:
-                _write('#: %s\n' % line.strip())
+            _write_comment(locs, prefix=':')
         if message.flags:
             _write('#%s\n' % ', '.join([''] + list(message.flags)))
 
-        if isinstance(message.id, (list, tuple)):
-            _write('msgid %s\n' % _normalize(message.id[0]))
-            _write('msgid_plural %s\n' % _normalize(message.id[1]))
-            for i, string in enumerate(message.string):
-                _write('msgstr[%d] %s\n' % (i, _normalize(message.string[i])))
-        else:
-            _write('msgid %s\n' % _normalize(message.id))
-            _write('msgstr %s\n' % _normalize(message.string or ''))
+        _write_message(message)
         _write('\n')
+
+    for message in catalog.obsolete.values():
+        for comment in message.user_comments:
+            _write_comment(comment)
+        _write_message(message, prefix='#~ ')
+        _write('\n')
--- a/babel/messages/tests/catalog.py
+++ b/babel/messages/tests/catalog.py
@@ -62,8 +62,8 @@
         cat.add('bar', 'Bahr')
         tmpl = catalog.Catalog()
         tmpl.add('Foo')
-        rest = cat.update(tmpl)
-        self.assertEqual(1, len(rest))
+        cat.update(tmpl)
+        self.assertEqual(1, len(cat.obsolete))
         assert 'foo' not in cat
 
         self.assertEqual('Voh', cat['Foo'].string)
@@ -75,8 +75,8 @@
         cat.add('bar', 'Bahr')
         tmpl = catalog.Catalog()
         tmpl.add('foo')
-        rest = cat.update(tmpl)
-        self.assertEqual(1, len(rest))
+        cat.update(tmpl)
+        self.assertEqual(1, len(cat.obsolete))
         assert 'fo' not in cat
 
         self.assertEqual('Voh', cat['foo'].string)
@@ -88,8 +88,8 @@
         cat.add('bar', 'Bahr')
         tmpl = catalog.Catalog()
         tmpl.add('foo')
-        rest = cat.update(tmpl, fuzzy_matching=False)
-        self.assertEqual(2, len(rest))
+        cat.update(tmpl, fuzzy_matching=False)
+        self.assertEqual(2, len(cat.obsolete))
 
 
 def suite():
--- a/babel/messages/tests/data/project/i18n/messages_non_fuzzy.pot
+++ b/babel/messages/tests/data/project/i18n/messages_non_fuzzy.pot
@@ -4,7 +4,6 @@
 # project.
 # FIRST AUTHOR <EMAIL@ADDRESS>, 2007.
 #
-#, fuzzy
 msgid ""
 msgstr ""
 "Project-Id-Version: TestProject 0.1\n"
--- a/babel/messages/tests/frontend.py
+++ b/babel/messages/tests/frontend.py
@@ -263,7 +263,7 @@
         open(pot_file, 'U').read())
 
 
-class NewCatalogTestCase(unittest.TestCase):
+class InitCatalogTestCase(unittest.TestCase):
 
     def setUp(self):
         self.olddir = os.getcwd()
@@ -276,7 +276,7 @@
             version='0.1',
             packages=['project']
         ))
-        self.cmd = frontend.new_catalog(self.dist)
+        self.cmd = frontend.init_catalog(self.dist)
         self.cmd.initialize_options()
 
     def tearDown(self):
@@ -347,7 +347,9 @@
                                tzinfo=LOCALTZ, locale='en')},
        open(po_file, 'U').read())
 
-class NewNonFuzzyCatalogTestCase(unittest.TestCase):
+
+class InitCatalogNonFuzzyTestCase(unittest.TestCase):
+    # FIXME: what is this test case about?
 
     def setUp(self):
         self.olddir = os.getcwd()
@@ -360,7 +362,7 @@
             version='0.1',
             packages=['project']
         ))
-        self.cmd = frontend.new_catalog(self.dist)
+        self.cmd = frontend.init_catalog(self.dist)
         self.cmd.initialize_options()
 
     def tearDown(self):
@@ -420,6 +422,7 @@
                                tzinfo=LOCALTZ, locale='en')},
        open(po_file, 'U').read())
 
+
 class CommandLineInterfaceTestCase(unittest.TestCase):
 
     def setUp(self):
@@ -465,7 +468,8 @@
 commands:
   compile     compile message catalogs to mo files
   extract     extract messages from source files and generate a pot file
-  init        create new message catalogs from a template
+  init        create new message catalogs from a pot file
+  update      update existing message catalogs from a pot file
 """, sys.stdout.getvalue().lower())
 
     def test_extract_with_default_mapping(self):
@@ -683,7 +687,8 @@
     suite.addTest(doctest.DocTestSuite(frontend))
     suite.addTest(unittest.makeSuite(CompileCatalogTestCase))
     suite.addTest(unittest.makeSuite(ExtractMessagesTestCase))
-    suite.addTest(unittest.makeSuite(NewCatalogTestCase))
+    suite.addTest(unittest.makeSuite(InitCatalogTestCase))
+    suite.addTest(unittest.makeSuite(InitCatalogNonFuzzyTestCase))
     suite.addTest(unittest.makeSuite(CommandLineInterfaceTestCase))
     return suite
 
--- a/babel/messages/tests/pofile.py
+++ b/babel/messages/tests/pofile.py
@@ -16,7 +16,7 @@
 from StringIO import StringIO
 import unittest
 
-from babel.messages.catalog import Catalog
+from babel.messages.catalog import Catalog, Message
 from babel.messages import pofile
 
 
@@ -150,6 +150,22 @@
 msgid "bar"
 msgstr ""''', buf.getvalue().strip())
 
+    def test_po_with_obsolete_messages(self):
+        catalog = Catalog()
+        catalog.add(u'foo', u'Voh', locations=[('main.py', 1)])
+        catalog.obsolete['bar'] = Message(u'bar', u'Bahr',
+                                          locations=[('utils.py', 3)],
+                                          user_comments=['User comment'])
+        buf = StringIO()
+        pofile.write_po(buf, catalog, omit_header=True)
+        self.assertEqual('''#: main.py:1
+msgid "foo"
+msgstr "Voh"
+
+# User comment
+#~ msgid "bar"
+#~ msgstr "Bahr"''', buf.getvalue().strip())
+
 
 def suite():
     suite = unittest.TestSuite()
--- a/doc/cmdline.txt
+++ b/doc/cmdline.txt
@@ -26,7 +26,8 @@
     subcommands:
       compile     compile message catalogs to MO files
       extract     extract messages from source files and generate a POT file
-      init        create new message catalogs from a template
+      init        create new message catalogs from a POT file
+      update      update existing message catalogs from a POT file
 
 The ``babel`` script provides a number of sub-commands that do the actual work.
 Those sub-commands are described below.
@@ -120,7 +121,7 @@
     $ babel init --help
     usage: babel init [options] 
     
-    create new message catalogs from a template
+    create new message catalogs from a POT file
     
     options:
     -h, --help      show this help message and exit
@@ -143,3 +144,38 @@
     --project-name=NAME   the project name
     --project-version=VERSION
                     the project version
+
+
+update
+======
+
+The `update` sub-command updates an existing new translations catalog based on
+a PO template file::
+
+    $ babel update --help
+    usage: babel update [options] 
+    
+    update existing message catalogs from a POT file
+    
+    options:
+      -h, --help            show this help message and exit
+      -D DOMAIN, --domain=DOMAIN
+                            domain of PO file (default 'messages')
+      -i FILE, --input-file=FILE
+                            name of the input file
+      -d DIR, --output-dir=DIR
+                            path to output directory
+      -o FILE, --output-file=FILE
+                            name of the output file (default
+                            '<output_dir>/<locale>/LC_MESSAGES/<domain>.po')
+      -l LOCALE, --locale=LOCALE
+                            locale of the translations catalog
+
+If ``output_dir`` is specified, but ``output-file`` is not, the default
+filename of the output file will be::
+
+    <directory>/<locale>/LC_MESSAGES/<domain>.mo
+
+If neither the ``output_file`` nor the ``locale`` option is set, this command
+looks for all catalog files in the base directory that match the given domain,
+and updates each of them.
--- a/doc/setup.txt
+++ b/doc/setup.txt
@@ -21,7 +21,7 @@
         ...
         cmd_class = {'compile_catalog': babel.compile_catalog,
                      'extract_messages': babel.extract_messages,
-                     'new_catalog': babel.new_catalog}
+                     'init_catalog': babel.init_catalog}
     )
 
 
@@ -214,37 +214,37 @@
 file. For boolean options, use "true" or "false" values.
 
 
-new_catalog
-===========
+init_catalog
+============
 
-The ``new_catalog`` command is basically equivalent to the GNU ``msginit``
+The ``init_catalog`` command is basically equivalent to the GNU ``msginit``
 program: it creates a new translation catalog based on a PO template file (POT).
 
 If the command has been correctly installed or registered, a project's
 ``setup.py`` script should allow you to use the command::
 
-    $ ./setup.py new_catalog --help
+    $ ./setup.py init_catalog --help
     Global options:
       --verbose (-v)  run verbosely (default)
       --quiet (-q)    run quietly (turns verbosity off)
       --dry-run (-n)  don't actually do anything
       --help (-h)     show detailed help message
 
-    Options for 'new_catalog' command:
+    Options for 'init_catalog' command:
       ...
 
 Running the command will produce a PO file::
 
-    $ ./setup.py new_catalog -l fr -i foobar/locales/messages.pot \
+    $ ./setup.py init_catalog -l fr -i foobar/locales/messages.pot \
                              -o foobar/locales/fr/messages.po
-    running new_catalog
+    running init_catalog
     creating catalog 'foobar/locales/fr/messages.po' based on 'foobar/locales/messages.pot'
 
 
 Options
 -------
 
-The ``new_catalog`` command accepts the following options:
+The ``init_catalog`` command accepts the following options:
 
   +-----------------------------+----------------------------------------------+
   | Option                      | Description                                  |
@@ -274,3 +274,64 @@
 
 These options can either be specified on the command-line, or in the
 ``setup.cfg`` file.
+
+
+update_catalog
+==============
+
+The ``update_catalog`` command is basically equivalent to the GNU ``msgmerge``
+program: it updates an existing translations catalog based on a PO template
+file (POT).
+
+If the command has been correctly installed or registered, a project's
+``setup.py`` script should allow you to use the command::
+
+    $ ./setup.py update_catalog --help
+    Global options:
+      --verbose (-v)  run verbosely (default)
+      --quiet (-q)    run quietly (turns verbosity off)
+      --dry-run (-n)  don't actually do anything
+      --help (-h)     show detailed help message
+
+    Options for 'update_catalog' command:
+      ...
+
+Running the command will update a PO file::
+
+    $ ./setup.py update_catalog -l fr -i foobar/locales/messages.pot \
+                                -o foobar/locales/fr/messages.po
+    running update_catalog
+    updating catalog 'foobar/locales/fr/messages.po' based on 'foobar/locales/messages.pot'
+
+
+Options
+-------
+
+The ``update_catalog`` command accepts the following options:
+
+  +-----------------------------+----------------------------------------------+
+  | Option                      | Description                                  |
+  +=============================+==============================================+
+  | ``--domain``                | domain of the PO file (defaults to           |
+  |                             | lower-cased project name)                    |
+  +-----------------------------+----------------------------------------------+
+  | ``--input-file`` (``-i``)   | name of the input file                       |
+  +-----------------------------+----------------------------------------------+
+  | ``--output-dir`` (``-d``)   | name of the output directory                 |
+  +-----------------------------+----------------------------------------------+
+  | ``--output-file`` (``-o``)  | name of the output file                      |
+  +-----------------------------+----------------------------------------------+
+  | ``--locale``                | locale for the new localized string          |
+  +-----------------------------+----------------------------------------------+
+
+If ``output-dir`` is specified, but ``output-file`` is not, the default filename
+of the output file will be::
+
+    <output_dir>/<locale>/LC_MESSAGES/<domain>.po
+
+If neither the ``input_file`` nor the ``locale`` option is set, this command
+looks for all catalog files in the base directory that match the given domain,
+and updates each of them.
+
+These options can either be specified on the command-line, or in the
+``setup.cfg`` file.
--- a/setup.py
+++ b/setup.py
@@ -140,7 +140,8 @@
     [distutils.commands]
     compile_catalog = babel.messages.frontend:compile_catalog
     extract_messages = babel.messages.frontend:extract_messages
-    new_catalog = babel.messages.frontend:new_catalog
+    init_catalog = babel.messages.frontend:init_catalog
+    update_catalog = babel.messages.frontend:update_catalog
     
     [distutils.setup_keywords]
     message_extractors = babel.messages.frontend:check_message_extractors
Copyright (C) 2012-2017 Edgewall Software