Import iceweasel_44.0.2.orig-compare-locales.tar.bz2
authorMike Hommey <glandium@debian.org>
Sun, 14 Feb 2016 09:25:51 +0000 (09:25 +0000)
committerMike Hommey <glandium@debian.org>
Sun, 14 Feb 2016 09:25:51 +0000 (09:25 +0000)
[dgit import orig iceweasel_44.0.2.orig-compare-locales.tar.bz2]

19 files changed:
.gitignore [new file with mode: 0644]
ez_setup.py [new file with mode: 0644]
lib/Mozilla/Checks.py [new file with mode: 0644]
lib/Mozilla/CompareLocales.py [new file with mode: 0644]
lib/Mozilla/Jars.py [new file with mode: 0644]
lib/Mozilla/Parser.py [new file with mode: 0644]
lib/Mozilla/Paths.py [new file with mode: 0644]
lib/Mozilla/__init__.py [new file with mode: 0644]
lib/Mozilla/tests/unitChecks.py [new file with mode: 0644]
lib/Mozilla/tests/unitDTD.py [new file with mode: 0644]
lib/Mozilla/tests/unitMerge.py [new file with mode: 0644]
lib/Mozilla/tests/unitProperties.py [new file with mode: 0644]
scripts/compare-dirs [new file with mode: 0755]
scripts/compare-locales [new file with mode: 0755]
scripts/compare-packs [new file with mode: 0755]
scripts/enum-checks [new file with mode: 0755]
scripts/verify-rss-redirects [new file with mode: 0755]
setup.py [new file with mode: 0755]
util/dump_property.js [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..2070d3f
--- /dev/null
@@ -0,0 +1,5 @@
+*.orig
+*.pyc
+build/
+dist/
+lib/compare_locales.egg-info/
diff --git a/ez_setup.py b/ez_setup.py
new file mode 100644 (file)
index 0000000..9e9b281
--- /dev/null
@@ -0,0 +1,234 @@
+#!python
+"""Bootstrap setuptools installation
+
+If you want to use setuptools in your package's setup.py, just include this
+file in the same directory with it, and add this to the top of your setup.py::
+
+    from ez_setup import use_setuptools
+    use_setuptools()
+
+If you want to require a specific version of setuptools, set a download
+mirror, or use an alternate download directory, you can do so by supplying
+the appropriate options to ``use_setuptools()``.
+
+This file can also be run as a script to install or upgrade setuptools.
+"""
+import sys
+DEFAULT_VERSION = "0.6c7"
+DEFAULT_URL     = "http://pypi.python.org/packages/%s/s/setuptools/" % sys.version[:3]
+
+md5_data = {
+    'setuptools-0.6b1-py2.3.egg': '8822caf901250d848b996b7f25c6e6ca',
+    'setuptools-0.6b1-py2.4.egg': 'b79a8a403e4502fbb85ee3f1941735cb',
+    'setuptools-0.6b2-py2.3.egg': '5657759d8a6d8fc44070a9d07272d99b',
+    'setuptools-0.6b2-py2.4.egg': '4996a8d169d2be661fa32a6e52e4f82a',
+    'setuptools-0.6b3-py2.3.egg': 'bb31c0fc7399a63579975cad9f5a0618',
+    'setuptools-0.6b3-py2.4.egg': '38a8c6b3d6ecd22247f179f7da669fac',
+    'setuptools-0.6b4-py2.3.egg': '62045a24ed4e1ebc77fe039aa4e6f7e5',
+    'setuptools-0.6b4-py2.4.egg': '4cb2a185d228dacffb2d17f103b3b1c4',
+    'setuptools-0.6c1-py2.3.egg': 'b3f2b5539d65cb7f74ad79127f1a908c',
+    'setuptools-0.6c1-py2.4.egg': 'b45adeda0667d2d2ffe14009364f2a4b',
+    'setuptools-0.6c2-py2.3.egg': 'f0064bf6aa2b7d0f3ba0b43f20817c27',
+    'setuptools-0.6c2-py2.4.egg': '616192eec35f47e8ea16cd6a122b7277',
+    'setuptools-0.6c3-py2.3.egg': 'f181fa125dfe85a259c9cd6f1d7b78fa',
+    'setuptools-0.6c3-py2.4.egg': 'e0ed74682c998bfb73bf803a50e7b71e',
+    'setuptools-0.6c3-py2.5.egg': 'abef16fdd61955514841c7c6bd98965e',
+    'setuptools-0.6c4-py2.3.egg': 'b0b9131acab32022bfac7f44c5d7971f',
+    'setuptools-0.6c4-py2.4.egg': '2a1f9656d4fbf3c97bf946c0a124e6e2',
+    'setuptools-0.6c4-py2.5.egg': '8f5a052e32cdb9c72bcf4b5526f28afc',
+    'setuptools-0.6c5-py2.3.egg': 'ee9fd80965da04f2f3e6b3576e9d8167',
+    'setuptools-0.6c5-py2.4.egg': 'afe2adf1c01701ee841761f5bcd8aa64',
+    'setuptools-0.6c5-py2.5.egg': 'a8d3f61494ccaa8714dfed37bccd3d5d',
+    'setuptools-0.6c6-py2.3.egg': '35686b78116a668847237b69d549ec20',
+    'setuptools-0.6c6-py2.4.egg': '3c56af57be3225019260a644430065ab',
+    'setuptools-0.6c6-py2.5.egg': 'b2f8a7520709a5b34f80946de5f02f53',
+    'setuptools-0.6c7-py2.3.egg': '209fdf9adc3a615e5115b725658e13e2',
+    'setuptools-0.6c7-py2.4.egg': '5a8f954807d46a0fb67cf1f26c55a82e',
+    'setuptools-0.6c7-py2.5.egg': '45d2ad28f9750e7434111fde831e8372',
+}
+
+import sys, os
+
+def _validate_md5(egg_name, data):
+    if egg_name in md5_data:
+        from md5 import md5
+        digest = md5(data).hexdigest()
+        if digest != md5_data[egg_name]:
+            print >>sys.stderr, (
+                "md5 validation of %s failed!  (Possible download problem?)"
+                % egg_name
+            )
+            sys.exit(2)
+    return data
+
+
+def use_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+    download_delay=15
+):
+    """Automatically find/download setuptools and make it available on sys.path
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end with
+    a '/').  `to_dir` is the directory where setuptools will be downloaded, if
+    it is not already available.  If `download_delay` is specified, it should
+    be the number of seconds that will be paused before initiating a download,
+    should one be required.  If an older version of setuptools is installed,
+    this routine will print a message to ``sys.stderr`` and raise SystemExit in
+    an attempt to abort the calling script.
+    """
+    try:
+        import setuptools
+        if setuptools.__version__ == '0.0.1':
+            print >>sys.stderr, (
+            "You have an obsolete version of setuptools installed.  Please\n"
+            "remove it from your system entirely before rerunning this script."
+            )
+            sys.exit(2)
+    except ImportError:
+        egg = download_setuptools(version, download_base, to_dir, download_delay)
+        sys.path.insert(0, egg)
+        import setuptools; setuptools.bootstrap_install_from = egg
+
+    import pkg_resources
+    try:
+        pkg_resources.require("setuptools>="+version)
+
+    except pkg_resources.VersionConflict, e:
+        # XXX could we install in a subprocess here?
+        print >>sys.stderr, (
+            "The required version of setuptools (>=%s) is not available, and\n"
+            "can't be installed while this script is running. Please install\n"
+            " a more recent version first.\n\n(Currently using %r)"
+        ) % (version, e.args[0])
+        sys.exit(2)
+
+def download_setuptools(
+    version=DEFAULT_VERSION, download_base=DEFAULT_URL, to_dir=os.curdir,
+    delay = 15
+):
+    """Download setuptools from a specified location and return its filename
+
+    `version` should be a valid setuptools version number that is available
+    as an egg for download under the `download_base` URL (which should end
+    with a '/'). `to_dir` is the directory where the egg will be downloaded.
+    `delay` is the number of seconds to pause before an actual download attempt.
+    """
+    import urllib2, shutil
+    egg_name = "setuptools-%s-py%s.egg" % (version,sys.version[:3])
+    url = download_base + egg_name
+    saveto = os.path.join(to_dir, egg_name)
+    src = dst = None
+    if not os.path.exists(saveto):  # Avoid repeated downloads
+        try:
+            from distutils import log
+            if delay:
+                log.warn("""
+---------------------------------------------------------------------------
+This script requires setuptools version %s to run (even to display
+help).  I will attempt to download it for you (from
+%s), but
+you may need to enable firewall access for this script first.
+I will start the download in %d seconds.
+
+(Note: if this machine does not have network access, please obtain the file
+
+   %s
+
+and place it in this directory before rerunning this script.)
+---------------------------------------------------------------------------""",
+                    version, download_base, delay, url
+                ); from time import sleep; sleep(delay)
+            log.warn("Downloading %s", url)
+            src = urllib2.urlopen(url)
+            # Read/write all in one block, so we don't create a corrupt file
+            # if the download is interrupted.
+            data = _validate_md5(egg_name, src.read())
+            dst = open(saveto,"wb"); dst.write(data)
+        finally:
+            if src: src.close()
+            if dst: dst.close()
+    return os.path.realpath(saveto)
+
+def main(argv, version=DEFAULT_VERSION):
+    """Install or upgrade setuptools and EasyInstall"""
+
+    try:
+        import setuptools
+    except ImportError:
+        egg = None
+        try:
+            egg = download_setuptools(version, delay=0)
+            sys.path.insert(0,egg)
+            from setuptools.command.easy_install import main
+            return main(list(argv)+[egg])   # we're done here
+        finally:
+            if egg and os.path.exists(egg):
+                os.unlink(egg)
+    else:
+        if setuptools.__version__ == '0.0.1':
+            # tell the user to uninstall obsolete version
+            use_setuptools(version)
+
+    req = "setuptools>="+version
+    import pkg_resources
+    try:
+        pkg_resources.require(req)
+    except pkg_resources.VersionConflict:
+        try:
+            from setuptools.command.easy_install import main
+        except ImportError:
+            from easy_install import main
+        main(list(argv)+[download_setuptools(delay=0)])
+        sys.exit(0) # try to force an exit
+    else:
+        if argv:
+            from setuptools.command.easy_install import main
+            main(argv)
+        else:
+            print "Setuptools version",version,"or greater has been installed."
+            print '(Run "ez_setup.py -U setuptools" to reinstall or upgrade.)'
+
+
+
+def update_md5(filenames):
+    """Update our built-in md5 registry"""
+
+    import re
+    from md5 import md5
+
+    for name in filenames:
+        base = os.path.basename(name)
+        f = open(name,'rb')
+        md5_data[base] = md5(f.read()).hexdigest()
+        f.close()
+
+    data = ["    %r: %r,\n" % it for it in md5_data.items()]
+    data.sort()
+    repl = "".join(data)
+
+    import inspect
+    srcfile = inspect.getsourcefile(sys.modules[__name__])
+    f = open(srcfile, 'rb'); src = f.read(); f.close()
+
+    match = re.search("\nmd5_data = {\n([^}]+)}", src)
+    if not match:
+        print >>sys.stderr, "Internal error!"
+        sys.exit(2)
+
+    src = src[:match.start(1)] + repl + src[match.end(1):]
+    f = open(srcfile,'w')
+    f.write(src)
+    f.close()
+
+
+if __name__=='__main__':
+    if len(sys.argv)>2 and sys.argv[1]=='--md5update':
+        update_md5(sys.argv[2:])
+    else:
+        main(sys.argv[1:])
+
+
+
+
+
diff --git a/lib/Mozilla/Checks.py b/lib/Mozilla/Checks.py
new file mode 100644 (file)
index 0000000..a7e19a3
--- /dev/null
@@ -0,0 +1,408 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2010
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import re
+import itertools
+import codecs
+from difflib import SequenceMatcher
+from xml import sax
+try:
+    from cStringIO import StringIO
+except ImportError:
+    from StringIO import StringIO
+
+from Parser import DTDParser
+
+class Checker(object):
+    '''Abstract class to implement checks per file type.
+    '''
+    pattern = None
+
+    def use(self, file):
+        return self.pattern.match(file.file)
+
+    def check(self, refEnt, l10nEnt):
+        '''Given the reference and localized Entities, performs checks.
+
+        This is a generator yielding tuples of
+        - "warning" or "error", depending on what should be reported,
+        - tuple of line, column info for the error within the string
+        - description string to be shown in the report
+        '''
+        if True:
+            raise NotImplementedError, "Need to subclass"
+        yield ("error", (0,0), "This is an example error", "example")
+
+class PrintfException(Exception):
+    def __init__(self, msg, pos):
+        self.pos = pos
+        self.msg = msg
+
+class PropertiesChecker(Checker):
+    '''Tests to run on .properties files.
+    '''
+    pattern = re.compile('.*\.properties$')
+    printf = re.compile(r'%(?P<good>%|(?:(?P<number>[1-9][0-9]*)\$)?(?P<width>\*|[0-9]+)?(?P<prec>\.(?:\*|[0-9]+)?)?(?P<spec>[duxXosScpfg]))?')
+
+    def check(self, refEnt, l10nEnt):
+        '''Test for the different variable formats.
+        '''
+        refValue, l10nValue = refEnt.val, l10nEnt.val
+        refSpecs = None
+        # check for PluralForm.jsm stuff, should have the docs in the
+        # comment
+        if 'Localization_and_Plurals' in refEnt.pre_comment:
+            # For plurals, common variable pattern is #1. Try that.
+            pats = set(int(m.group(1)) for m in re.finditer('#([0-9]+)',
+                                                            refValue))
+            if len(pats)==0:
+                return
+            lpats = set(int(m.group(1)) for m in re.finditer('#([0-9]+)',
+                                                             l10nValue))
+            if pats - lpats:
+                yield ('warning', 0, 'not all variables used in l10n',
+                       'plural')
+                return
+            if lpats - pats:
+                yield ('error', 0, 'unreplaced variables in l10n',
+                       'plural')
+                return
+            return
+        try:
+            refSpecs = self.getPrintfSpecs(refValue)
+        except PrintfException, e:
+            refSpecs = []
+        if refSpecs:
+            for t in self.checkPrintf(refSpecs, l10nValue):
+                yield t
+            return
+
+    def checkPrintf(self, refSpecs, l10nValue):
+        try:
+            l10nSpecs = self.getPrintfSpecs(l10nValue)
+        except PrintfException, e:
+            yield ('error', e.pos, e.msg, 'printf')
+            return
+        if refSpecs != l10nSpecs:
+            sm = SequenceMatcher()
+            sm.set_seqs(refSpecs, l10nSpecs)
+            msgs = []
+            warn = None
+            for action, ls, le, rs, re in sm.get_opcodes():
+                if action == 'equal':
+                    continue
+                if action == 'delete':
+                    # missing argument in l10n
+                    if le == len(refSpecs):
+                        # trailing specs missing, that's just a warning
+                        warn = ', '.join('trailing argument %d `%s` missing' %
+                                         (i+1, refSpecs[i]) 
+                                         for i in xrange(ls, le))
+                    else:
+                        for i in xrange(ls, le):
+                            msgs.append('argument %d `%s` missing' %
+                                        (i+1, refSpecs[i]))
+                    continue
+                if action == 'insert':
+                    # obsolete argument in l10n
+                    for i in xrange(rs, re):
+                        msgs.append('argument %d `%s` obsolete' %
+                                    (i+1, l10nSpecs[i]))
+                    continue
+                if action == 'replace':
+                    for i, j in zip(xrange(ls, le), xrange(rs, re)):
+                        msgs.append('argument %d `%s` should be `%s`' %
+                                    (j+1, l10nSpecs[j], refSpecs[i]))
+            if msgs:
+                yield ('error', 0, ', '.join(msgs), 'printf')
+            if warn is not None:
+                yield ('warning', 0, warn, 'printf')
+
+    def getPrintfSpecs(self, val):
+        hasNumber = False
+        specs = []
+        for m in self.printf.finditer(val):
+            if m.group("good") is None:
+                # found just a '%', signal an error
+                raise PrintfException('Found single %', m.start())
+            if m.group("good") == '%':
+                # escaped %
+                continue
+            if ((hasNumber and m.group('number') is None) or
+                (not hasNumber and specs and m.group('number') is not None)):
+                # mixed style, numbered and not
+                raise PrintfException('Mixed ordered and non-ordered args',
+                                      m.start())
+            hasNumber = m.group('number') is not None
+            if hasNumber:
+                pos = int(m.group('number')) - 1
+                ls = len(specs)
+                if pos >= ls:
+                    # pad specs
+                    nones = pos - ls
+                    specs[ls:pos] = nones*[None]
+                    specs.append(m.group('spec'))
+                else:
+                    if specs[pos] is not None:
+                        raise PrintfException('Double ordered argument %d' % (pos+1),
+                                              m.start())
+                    specs[pos] = m.group('spec')
+            else:
+                specs.append(m.group('spec'))
+        # check for missing args
+        if hasNumber and not all(specs):
+            raise PrintfException('Ordered argument missing', 0)
+        return specs
+
+
+class DTDChecker(Checker):
+    """Tests to run on DTD files.
+
+    Uses xml.sax for the heavy lifting of xml parsing.
+
+    The code tries to parse until it doesn't find any unresolved entities
+    anymore. If it finds one, it tries to grab the key, and adds an empty
+    <!ENTITY key ""> definition to the header.
+
+    Also checks for some CSS and number heuristics in the values.
+    """
+    pattern = re.compile('.*\.dtd$')
+
+    eref = re.compile('&(%s);' % DTDParser.Name)
+    tmpl = '''<!DOCTYPE elem [%s]>
+<elem>%s</elem>
+'''
+    xmllist = set(('amp', 'lt', 'gt', 'apos', 'quot'))
+
+    # Setup for XML parser, with default and text-only content handler
+    class TextContent(sax.handler.ContentHandler):
+        textcontent = ''
+        def characters(self, content):
+            self.textcontent += content
+
+    defaulthandler = sax.handler.ContentHandler()
+    texthandler = TextContent()
+
+    numPattern = r'([0-9]+|[0-9]*\.[0-9]+)'
+    num = re.compile('^%s$' % numPattern)
+    lengthPattern = '%s(em|px|ch|cm|in)' % numPattern
+    length = re.compile('^%s$' % lengthPattern)
+    spec = re.compile(r'((?:min\-)?(?:width|height))\s*:\s*%s' % lengthPattern)
+    style = re.compile(r'^%(spec)s\s*(;\s*%(spec)s\s*)*;?$' % {'spec': spec.pattern})
+
+    processContent = None
+
+    def __init__(self):
+        self.parser = sax.make_parser()
+        self.parser.setFeature(sax.handler.feature_external_ges, False)
+
+    def check(self, refEnt, l10nEnt):
+        """Try to parse the refvalue inside a dummy element, and keep
+        track of entities that we need to define to make that work.
+
+        Return a checker that offers just those entities.
+        """
+        refValue, l10nValue = refEnt.val, l10nEnt.val
+        # find entities the refValue references,
+        # reusing markup from DTDParser.
+        reflist = set(m.group(1).encode('utf-8') 
+                      for m in self.eref.finditer(refValue)) \
+                      - self.xmllist
+        entities = ''.join('<!ENTITY %s "">' % s for s in sorted(reflist))
+        self.parser.setContentHandler(self.defaulthandler)
+        try:
+            self.parser.parse(StringIO(self.tmpl % (entities, refValue.encode('utf-8'))))
+            # also catch stray %
+            self.parser.parse(StringIO(self.tmpl % (refEnt.all.encode('utf-8') + entities, '&%s;' % refEnt.key.encode('utf-8'))))
+        except sax.SAXParseException, e:
+            yield ('warning',
+                   (0,0),
+                   "can't parse en-US value", 'xmlparse')
+
+        # find entities the l10nValue references,
+        # reusing markup from DTDParser.
+        l10nlist = set(m.group(1).encode('utf-8') 
+                       for m in self.eref.finditer(l10nValue)) \
+                       - self.xmllist
+        missing = sorted(l10nlist - reflist)
+        _entities = entities + ''.join('<!ENTITY %s "">' % s for s in missing)
+        warntmpl = u'Referencing unknown entity `%s`'
+        if reflist:
+            warntmpl += ' (%s known)' % ', '.join(sorted(reflist))
+        if self.processContent is not None:
+            self.texthandler.textcontent = ''
+            self.parser.setContentHandler(self.texthandler)
+        try:
+            self.parser.parse(StringIO(self.tmpl % (_entities, l10nValue.encode('utf-8'))))
+            # also catch stray %
+            # if this fails, we need to substract the entity definition
+            self.parser.setContentHandler(self.defaulthandler)
+            self.parser.parse(StringIO(self.tmpl % (l10nEnt.all.encode('utf-8') + _entities, '&%s;' % l10nEnt.key.encode('utf-8'))))
+        except sax.SAXParseException, e:
+            # xml parse error, yield error
+            # sometimes, the error is reported on our fake closing
+            # element, make that the end of the last line
+            lnr = e.getLineNumber() - 1
+            lines = l10nValue.splitlines()
+            if lnr > len(lines):
+                lnr = len(lines)
+                col = len(lines[lnr-1])
+            else:
+                col = e.getColumnNumber()
+                if lnr == 1:
+                    col -= len("<elem>") # first line starts with <elem>, substract
+                elif lnr == 0:
+                    col -= len("<!DOCTYPE elem [") # first line is DOCTYPE
+            yield ('error', (lnr, col), ' '.join(e.args), 'xmlparse')
+
+        for key in missing:
+            yield ('warning', (0,0), warntmpl % key.decode('utf-8'), 'xmlparse')
+
+        # Number check
+        if self.num.match(refValue) and not self.num.match(l10nValue):
+            yield ('warning', 0, 'reference is a number', 'number')
+        # CSS checks
+        # just a length, width="100em"
+        if self.length.match(refValue) and not self.length.match(l10nValue):
+            yield ('error', 0, 'reference is a CSS length', 'css')
+        # real CSS spec, style="width:100px;"
+        if self.style.match(refValue):
+            if not self.style.match(l10nValue):
+                yield ('error', 0, 'reference is a CSS spec', 'css')
+            else:
+                # warn if different properties or units
+                refMap = dict((s, u) for s, _, u in
+                              self.spec.findall(refValue))
+                msgs = []
+                for s, _, u in self.spec.findall(l10nValue):
+                    if s not in refMap:
+                        msgs.insert(0, '%s only in l10n' % s)
+                        continue
+                    else:
+                        ru = refMap.pop(s)
+                        if u != ru:
+                            msgs.append("units for %s don't match "
+                                        "(%s != %s)" % (s, u, ru))
+                for s in refMap.iterkeys():
+                    msgs.insert(0, '%s only in reference' % s)
+                if msgs:
+                    yield ('warning', 0, ', '.join(msgs), 'css')
+
+        if self.processContent is not None:
+            for t in self.processContent(self.texthandler.textcontent):
+                yield t
+
+
+class PrincessAndroid(DTDChecker):
+    """Checker for the string values that Android puts into an XML container.
+
+    http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling
+    has more info. Check for unescaped apostrophes and bad unicode escapes.
+    """
+    quoted = re.compile("(?P<q>[\"']).*(?P=q)$")
+    def unicode_escape(self, str):
+        """Helper method to try to decode all unicode escapes in a string.
+
+        This code uses the standard python decode for unicode-escape, but that's
+        somewhat tricky, as its input needs to be ascii. To get to ascii, the
+        unicode string gets converted to ascii with backslashreplace, i.e.,
+        all non-ascii unicode chars get unicode escaped. And then we try to roll
+        all of that back.
+        Now, when that hits an error, that's from the original string, and we need
+        to search for the actual error position in the original string, as the
+        backslashreplace code changes string positions quite badly. See also the
+        last check in TestAndroid.test_android_dtd, with a lengthy chinese string.
+        """
+        val = str.encode('ascii', 'backslashreplace')
+        try:
+            val.decode('unicode-escape')
+        except UnicodeDecodeError, e:
+            args = list(e.args)
+            badstring = args[1][args[2]:args[3]]
+            i = str.rindex(badstring, 0, args[3])
+            args[2] = i
+            args[3] = i + len(badstring)
+            raise UnicodeDecodeError(*args)
+    def use(self, file):
+        """Use this Checker only for DTD files in embedding/android."""
+        return (file.module in ("embedding/android",
+                                "mobile/android/base")
+            and DTDChecker.pattern.match(file.file))
+    def processContent(self, val):
+        """Actual check code.
+        Check for unicode escapes and unescaped quotes and apostrophes, if string's not quoted.
+        """
+        # first, try to decode unicode escapes
+        try:
+            self.unicode_escape(val)
+        except UnicodeDecodeError, e:
+            yield ('error', e.args[2], e.args[4], 'android')
+        # check for unescaped single or double quotes.
+        # first, see if the complete string is single or double quoted, that changes the rules
+        m = self.quoted.match(val)
+        if m:
+            q = m.group('q')
+            offset = 0
+            val = val[1:-1] # strip quotes
+        else:
+            q = "[\"']"
+            offset = -1
+        stray_quot = re.compile(r"[\\\\]*(%s)" % q)
+            
+        for m in stray_quot.finditer(val):
+            if len(m.group(0)) % 2:
+                # found an unescaped single or double quote, which message?
+                msg = m.group(1) == '"' and u"Quotes in Android DTDs need escaping with \\\" or \\u0022, or put string in apostrophes." \
+                      or u"Apostrophes in Android DTDs need escaping with \\' or \\u0027, or use \u2019, or put string in quotes."
+                yield ('error', m.end(0)+offset, msg, 'android')
+
+
+class __checks:
+    props = PropertiesChecker()
+    android_dtd = PrincessAndroid()
+    dtd = DTDChecker()
+
+def getChecks(file):
+    check = None
+    if __checks.props.use(file):
+        check = __checks.props.check
+    elif __checks.android_dtd.use(file):
+        check = __checks.android_dtd.check
+    elif __checks.dtd.use(file):
+        check = __checks.dtd.check
+    return check
+
diff --git a/lib/Mozilla/CompareLocales.py b/lib/Mozilla/CompareLocales.py
new file mode 100644 (file)
index 0000000..651b982
--- /dev/null
@@ -0,0 +1,625 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+'Mozilla l10n compare locales tool'
+
+import codecs
+import os
+import os.path
+import shutil
+import re
+import logging
+from difflib import SequenceMatcher
+try:
+  from collections import defaultdict
+except ImportError:
+  class defaultdict(dict):
+    def __init__(self, defaultclass):
+      dict.__init__(self)
+      self.__defaultclass = defaultclass
+    def __getitem__(self, k):
+      if not dict.__contains__(self, k):
+        self[k] = self.__defaultclass()
+      return dict.__getitem__(self, k)
+# backwards compat hack for any(), new in python 2.5
+try:
+  any([True])
+except NameError:
+  def any(sequence):
+    for item in sequence:
+      if item:
+        return True
+    return False
+
+try:
+  from json import dumps
+except:
+  from simplejson import dumps
+
+
+import Parser
+import Paths
+import Checks
+
+class Tree(object):
+  def __init__(self, valuetype):
+    self.branches = dict()
+    self.valuetype = valuetype
+    self.value = None
+  def __getitem__(self, leaf):
+    parts = []
+    if isinstance(leaf, Paths.File):
+      parts = [p for p in [leaf.locale, leaf.module] if p] + \
+          leaf.file.split('/')
+    else:
+      parts = leaf.split('/')
+    return self.__get(parts)
+  def __get(self, parts):
+    common = None
+    old = None
+    new = tuple(parts)
+    t = self
+    for k, v in self.branches.iteritems():
+      for i, part in enumerate(zip(k, parts)):
+        if part[0] != part[1]:
+          i -= 1
+          break
+      if i < 0:
+        continue
+      i += 1
+      common = tuple(k[:i])
+      old = tuple(k[i:])
+      new = tuple(parts[i:])
+      break
+    if old:
+      self.branches.pop(k)
+      t = Tree(self.valuetype)
+      t.branches[old] = v
+      self.branches[common] = t
+    elif common:
+      t = self.branches[common]
+    if new:
+      if common:
+        return t.__get(new)
+      t2 = t
+      t = Tree(self.valuetype)
+      t2.branches[new] = t
+    if t.value is None:
+      t.value = t.valuetype()
+    return t.value
+  indent = '  '
+  def getContent(self, depth = 0):
+    '''
+    Returns iterator of (depth, flag, key_or_value) tuples.
+    If flag is 'value', key_or_value is a value object, otherwise
+    (flag is 'key') it's a key string.
+    '''
+    keys = self.branches.keys()
+    keys.sort()
+    if self.value is not None:
+      yield (depth, 'value', self.value)
+    for key in keys:
+      yield (depth, 'key', key)
+      for child in self.branches[key].getContent(depth + 1):
+        yield child
+  def toJSON(self):
+    '''
+    Returns this Tree as a JSON-able tree of hashes.
+    Only the values need to take care that they're JSON-able.
+    '''
+    json = {}
+    keys = self.branches.keys()
+    keys.sort()
+    if self.value is not None:
+      json['value'] = self.value
+    children = [('/'.join(key), self.branches[key].toJSON())
+                    for key in keys]
+    if children:
+      json['children'] = children
+    return json
+  def getStrRows(self):
+    def tostr(t):
+      if t[1] == 'key':
+        return self.indent * t[0] + '/'.join(t[2])
+      return self.indent * (t[0] + 1) + str(t[2])
+    return map(tostr, self.getContent())
+  def __str__(self):
+    return '\n'.join(self.getStrRows())
+
+class AddRemove(SequenceMatcher):
+  def __init__(self):
+    SequenceMatcher.__init__(self, None, None, None)
+  def set_left(self, left):
+    if not isinstance(left, list):
+      left = [l for l in left]
+    self.set_seq1(left)
+  def set_right(self, right):
+    if not isinstance(right, list):
+      right = [l for l in right]
+    self.set_seq2(right)
+  def __iter__(self):
+    for tag, i1, i2, j1, j2 in self.get_opcodes():
+      if tag == 'equal':
+        for pair in zip(self.a[i1:i2], self.b[j1:j2]):
+          yield ('equal', pair)
+      elif tag == 'delete':
+        for item in self.a[i1:i2]:
+          yield ('delete', item)
+      elif tag == 'insert':
+        for item in self.b[j1:j2]:
+          yield ('add', item)
+      else:
+        # tag == 'replace'
+        for item in self.a[i1:i2]:
+          yield ('delete', item)
+        for item in self.b[j1:j2]:
+          yield ('add', item)
+
+class DirectoryCompare(SequenceMatcher):
+  def __init__(self, reference):
+    SequenceMatcher.__init__(self, None, [i for i in reference],
+                             [])
+    self.watcher = None
+  def setWatcher(self, watcher):
+    self.watcher = watcher
+  def compareWith(self, other):
+    if not self.watcher:
+      return
+    self.set_seq2([i for i in other])
+    for tag, i1, i2, j1, j2 in self.get_opcodes():
+      if tag == 'equal':
+        for i, j in zip(xrange(i1,i2), xrange(j1,j2)):
+          self.watcher.compare(self.a[i], self.b[j])
+      elif tag == 'delete':
+        for i in xrange(i1,i2):
+          self.watcher.add(self.a[i], other.cloneFile(self.a[i]))
+      elif tag == 'insert':
+        for j in xrange(j1, j2):
+          self.watcher.remove(self.b[j])
+      else:
+        for j in xrange(j1, j2):
+          self.watcher.remove(self.b[j])
+        for i in xrange(i1,i2):
+          self.watcher.add(self.a[i], other.cloneFile(self.a[i]))
+
+class Observer(object):
+  stat_cats = ['missing', 'obsolete', 'missingInFiles', 'report',
+               'changed', 'unchanged', 'keys']
+  def __init__(self):
+    class intdict(defaultdict):
+      def __init__(self):
+        defaultdict.__init__(self, int)
+    self.summary = defaultdict(intdict)
+    self.details = Tree(dict)
+    self.filter = None
+  # support pickling
+  def __getstate__(self):
+    return dict(summary = self.getSummary(), details = self.details)
+  def __setstate__(self, state):
+    class intdict(defaultdict):
+      def __init__(self):
+        defaultdict.__init__(self, int)
+    self.summary = defaultdict(intdict)
+    if 'summary' in state:
+      for loc, stats in state['summary'].iteritems():
+        self.summary[loc].update(stats)
+    self.details = state['details']
+    self.filter = None
+  def getSummary(self):
+    plaindict = {}
+    for k, v in self.summary.iteritems():
+      plaindict[k] = dict(v)
+    return plaindict
+  def toJSON(self):
+    return dict(summary = self.getSummary(), details = self.details.toJSON())
+  def notify(self, category, file, data):
+    rv = "error"
+    if category in self.stat_cats:
+      # these get called post reporting just for stats
+      # return "error" to forward them to other observers
+      self.summary[file.locale][category] += data
+      return "error"
+    if category in ['missingFile', 'obsoleteFile']:
+      if self.filter is not None:
+        rv = self.filter(file)
+      if rv != "ignore":
+        self.details[file][category] = rv
+      return rv
+    if category in ['missingEntity', 'obsoleteEntity']:
+      if self.filter is not None:
+        rv = self.filter(file, data)
+      if rv == "ignore":
+        return rv
+      v = self.details[file]
+      try:
+        v[category].append(data)
+      except KeyError:
+        v[category] = [data]
+      return rv
+    if category == 'error':
+      try:
+        self.details[file][category].append(data)
+      except KeyError:
+        self.details[file][category] = [data]
+      self.summary[file.locale]['errors'] += 1
+    elif category == 'warning':
+      try:
+        self.details[file][category].append(data)
+      except KeyError:
+        self.details[file][category] = [data]
+      self.summary[file.locale]['warnings'] += 1
+    return rv
+  def toExhibit(self):
+    items = []
+    for locale in sorted(self.summary.iterkeys()):
+      summary = self.summary[locale]
+      if locale is not None:
+        item = {'id': 'xxx/' + locale,
+                'label': locale,
+                'locale': locale}
+      else:
+        item = {'id': 'xxx',
+                'label': 'xxx',
+                'locale': 'xxx'}
+      item['type'] = 'Build'
+      total = sum([summary[k]
+                   for k in ('changed','unchanged','report','missing',
+                             'missingInFiles')
+                   if k in summary])
+      rate = (('changed' in summary and summary['changed'] * 100)
+              or 0) / total
+      item.update((k, summary.get(k, 0))
+                  for k in ('changed','unchanged'))
+      item.update((k, summary[k]) 
+                  for k in ('report','errors','warnings')
+                  if k in summary)
+      item['missing'] = summary.get('missing', 0) + \
+          summary.get('missingInFiles', 0)
+      item['completion'] = rate
+      item['total'] = total
+      result = 'success'
+      if item.get('warnings',0):
+        result = 'warning'
+      if item.get('errors',0) or item.get('missing',0):
+        result = 'failure'
+      item['result'] = result
+      items.append(item)
+    data = {"properties": dict.fromkeys(
+        ("completion", "errors", "warnings", "missing", "report",
+         "unchanged", "changed", "obsolete"),
+        {"valueType": "number"}),
+              "types": {
+        "Build": {"pluralLabel": "Builds"}
+        }}
+    data['items'] = items
+    return dumps(data, indent=2)
+  def serialize(self, type="text/plain"):
+    if type=="application/json":
+      return self.toExhibit()
+    def tostr(t):
+      if t[1] == 'key':
+        return '  ' * t[0] + '/'.join(t[2])
+      o = []
+      indent = '  ' * (t[0] + 1)
+      if 'error' in t[2]:
+        o += [indent + 'ERROR: ' + e for e in t[2]['error']]
+      if 'warning' in t[2]:
+        o += [indent + 'WARNING: ' + e for e in t[2]['warning']]
+      if 'missingEntity' in t[2] or 'obsoleteEntity' in t[2]:
+        missingEntities = ('missingEntity' in t[2] and t[2]['missingEntity']) \
+            or []
+        obsoleteEntities = ('obsoleteEntity' in t[2] and
+                            t[2]['obsoleteEntity']) or []
+        entities = missingEntities + obsoleteEntities
+        entities.sort()
+        for entity in entities:
+          op = '+'
+          if entity in obsoleteEntities:
+            op = '-'
+          o.append(indent + op + entity)
+      elif 'missingFile' in t[2]:
+        o.append(indent + '// add and localize this file')
+      elif 'obsoleteFile' in t[2]:
+        o.append(indent + '// remove this file')
+      return '\n'.join(o)
+    out = []
+    for locale, summary in self.summary.iteritems():
+      if locale is not None:
+        out.append(locale + ':')
+      out += [k + ': ' + str(v) for k,v in summary.iteritems()]
+      total = sum([summary[k] \
+                     for k in ['changed','unchanged','report','missing',
+                               'missingInFiles'] \
+                     if k in summary])
+      rate = (('changed' in summary and summary['changed'] * 100)
+              or 0) / total
+      out.append('%d%% of entries changed' % rate)
+    return '\n'.join(map(tostr, self.details.getContent()) + out)
+  def __str__(self):
+    return 'observer'
+
+class ContentComparer:
+  keyRE = re.compile('[kK]ey')
+  nl = re.compile('\n', re.M)
+  def __init__(self, filterObserver):
+    '''Create a ContentComparer.
+    filterObserver is usually a instance of Observer. The return values
+    of the notify method are used to control the handling of missing
+    entities.
+    '''
+    self.reference = dict()
+    self.filterObserver = filterObserver
+    self.observers = []
+    self.merge_stage = None
+  def add_observer(self, obs):
+    '''Add a non-filtering observer.
+    Results from the notify calls are ignored.
+    '''
+    self.observers.append(obs)
+  def set_merge_stage(self, merge_stage):
+    self.merge_stage = merge_stage
+  def merge(self, ref_entities, ref_map, ref_file, l10n_file, missing, skips,
+            p):
+    outfile = os.path.join(self.merge_stage, l10n_file.module, l10n_file.file)
+    outdir = os.path.dirname(outfile)
+    if not os.path.isdir(outdir):
+      os.makedirs(outdir)
+    if not p.canMerge:
+      shutil.copyfile(ref_file.fullpath, outfile)
+      print "copied reference to " + outfile
+      return
+    trailing = (['\n'] + 
+                [ref_entities[ref_map[key]].all for key in missing] +
+                [ref_entities[ref_map[skip.key]].all for skip in skips])
+    if skips:
+      # we need to skip a few errornous blocks in the input, copy by hand
+      f = codecs.open(outfile, 'wb', p.encoding)
+      offset = 0
+      for skip in skips:
+        chunk = skip.span
+        f.write(p.contents[offset:chunk[0]])
+        offset = chunk[1]
+      f.write(p.contents[offset:])
+    else:
+      shutil.copyfile(l10n_file.fullpath, outfile)
+      f = codecs.open(outfile, 'ab', p.encoding)
+    print "adding to " + outfile
+    def ensureNewline(s):
+      if not s.endswith('\n'):
+        return s + '\n'
+      return s
+    f.write(''.join(map(ensureNewline,trailing)))
+    f.close()
+  def notify(self, category, file, data):
+    """Check filterObserver for the found data, and if it's
+    not to ignore, notify observers.
+    """
+    rv = self.filterObserver.notify(category, file, data)
+    if rv == 'ignore':
+      return rv
+    for obs in self.observers:
+      # non-filtering observers, ignore results
+      obs.notify(category, file, data)
+    return rv
+  def remove(self, obsolete):
+    self.notify('obsoleteFile', obsolete, None)
+    pass
+  def compare(self, ref_file, l10n):
+    try:
+      p = Parser.getParser(ref_file.file)
+      checks = Checks.getChecks(ref_file)
+    except UserWarning:
+      # no comparison, XXX report?
+      return
+    if ref_file not in self.reference:
+      # we didn't parse this before
+      try:
+        p.readContents(ref_file.getContents())
+      except Exception, e:
+        self.notify('error', ref_file, str(e))
+        return
+      self.reference[ref_file] = p.parse()
+    ref = self.reference[ref_file]
+    ref_list = ref[1].keys()
+    ref_list.sort()
+    try:
+      p.readContents(l10n.getContents())
+      l10n_entities, l10n_map = p.parse()
+    except Exception, e:
+      self.notify('error', l10n, str(e))
+      return
+    lines = []
+    def _getLine(offset):
+      if not lines:
+        lines.append(0)
+        for m in self.nl.finditer(p.contents):
+          lines.append(m.end())
+      _line = 1
+      for i in xrange(len(lines), 0, -1):
+        if offset >= lines[i-1]:
+          return (i, offset - lines[i-1])
+      return (1, offset)
+    l10n_list = l10n_map.keys()
+    l10n_list.sort()
+    ar = AddRemove()
+    ar.set_left(ref_list)
+    ar.set_right(l10n_list)
+    report = missing = obsolete = changed = unchanged = keys = 0
+    missings = []
+    skips = []
+    for action, item_or_pair in ar:
+      if action == 'delete':
+        # missing entity
+        _rv = self.notify('missingEntity', l10n, item_or_pair)
+        if _rv == "ignore":
+          continue
+        if _rv == "error":
+          # only add to missing entities for l10n-merge on error, not report
+          missings.append(item_or_pair)
+          missing += 1
+        else:
+          # just report
+          report += 1
+      elif action == 'add':
+        # obsolete entity or junk
+        if isinstance(l10n_entities[l10n_map[item_or_pair]], Parser.Junk):
+          junk = l10n_entities[l10n_map[item_or_pair]]
+          params = (junk.val,) + junk.span
+          self.notify('error', l10n, 'Unparsed content "%s" at %d-%d' % params)
+        elif self.notify('obsoleteEntity', l10n, item_or_pair) != 'ignore':
+          obsolete += 1
+      else:
+        # entity found in both ref and l10n, check for changed
+        entity = item_or_pair[0]
+        refent = ref[0][ref[1][entity]]
+        l10nent = l10n_entities[l10n_map[entity]]
+        if self.keyRE.search(entity):
+          keys += 1
+        else:
+          if refent.val == l10nent.val:
+            self.doUnchanged(l10nent)
+            unchanged += 1
+          else:
+            self.doChanged(ref_file, refent, l10nent)
+            changed += 1
+          # run checks:
+        if checks:
+          for tp, pos, msg, cat in checks(refent, l10nent):
+            # compute real src position, if first line, col needs adjustment
+            _l, _offset = _getLine(l10nent.val_span[0])
+            if isinstance(pos, tuple):
+              # line, column
+              if pos[0] == 1:
+                col = pos[1] + _offset
+              else:
+                col = pos[1]
+              _l += pos[0] - 1
+            else:
+              _l, col = _getLine(l10nent.val_span[0] + pos)
+             # skip error entities when merging
+            if tp == 'error' and self.merge_stage is not None:
+              skips.append(l10nent)
+            self.notify(tp, l10n,
+                        u"%s at line %d, column %d for %s" %
+                        (msg, _l, col, refent.key))
+        pass
+    if missing:
+      self.notify('missing', l10n, missing)
+    if self.merge_stage is not None and (missings or skips):
+      self.merge(ref[0], ref[1], ref_file, l10n, missings, skips, p)
+    if report:
+      self.notify('report', l10n, report)
+    if obsolete:
+      self.notify('obsolete', l10n, obsolete)
+    if changed:
+      self.notify('changed', l10n, changed)
+    if unchanged:
+      self.notify('unchanged', l10n, unchanged)
+    if keys:
+      self.notify('keys', l10n, keys)
+    pass
+  def add(self, orig, missing):
+    if self.notify('missingFile', missing, None) == "ignore":
+      # filter said that we don't need this file, don't count it
+      return
+    f = orig
+    try:
+      p = Parser.getParser(f.file)
+    except UserWarning:
+      return
+    try:
+      p.readContents(f.getContents())
+      entities, map = p.parse()
+    except Exception, e:
+      self.notify('error', f, str(e))
+      return
+    self.notify('missingInFiles', missing, len(map))
+  def doUnchanged(self, entity):
+    # overload this if needed
+    pass
+  def doChanged(self, file, ref_entity, l10n_entity):
+    # overload this if needed
+    pass
+
+def compareApp(app, otherObserver = None, merge_stage = None, clobber = False):
+  '''Compare locales set in app.
+
+  Optional arguments are:
+  - otherObserver. A object implementing 
+      notify(category, _file, data)
+    The return values of that callback are ignored.
+  - merge_stage. A directory to be used for staging the output of
+    l10n-merge.
+  - clobber. Clobber the module subdirectories of the merge dir as we go.
+    Use wisely, as it might cause data loss.
+  '''
+  o  = Observer()
+  cc = ContentComparer(o)
+  if otherObserver is not None:
+    cc.add_observer(otherObserver)
+  cc.set_merge_stage(merge_stage)
+  o.filter = app.filter
+  for module, reference, locales in app:
+    if merge_stage is not None and clobber:
+      # if clobber and merge is on, remove the stage for the module if it exists
+      clobberdir = os.path.join(merge_stage, module)
+      if os.path.exists(clobberdir):
+        shutil.rmtree(clobberdir)
+        print "clobbered " + clobberdir
+    dc = DirectoryCompare(reference)
+    dc.setWatcher(cc)
+    for locale, localization in locales:
+      dc.compareWith(localization)
+  return o
+
+def compareDirs(reference, locale, otherObserver = None, merge_stage = None):
+  '''Compare reference and locale dir.
+
+  Optional arguments are:
+  - otherObserver. A object implementing 
+      notify(category, _file, data)
+    The return values of that callback are ignored.
+  '''
+  o  = Observer()
+  cc = ContentComparer(o)
+  if otherObserver is not None:
+    cc.add_observer(otherObserver)
+  cc.set_merge_stage(merge_stage)
+  dc = DirectoryCompare(Paths.EnumerateDir(reference))
+  dc.setWatcher(cc)
+  dc.compareWith(Paths.EnumerateDir(locale))
+  return o
diff --git a/lib/Mozilla/Jars.py b/lib/Mozilla/Jars.py
new file mode 100644 (file)
index 0000000..f798427
--- /dev/null
@@ -0,0 +1,85 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2007
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+from zipfile import ZipFile
+from difflib import SequenceMatcher
+import os.path
+import re
+
+from Paths import File
+import CompareLocales
+
+class JarEntry(File):
+  def __init__(self, zf, file, fakefile):
+    File.__init__(self, None, fakefile)
+    self.realfile = file
+    self.zipfile = zf
+  def __str__(self):
+    return self.zipfile.filename + '!' + self.realfile
+  def getContents(self):
+    return self.zipfile.read(self.realfile)
+
+class EnumerateJar(object):
+  def __init__(self, basepath):
+    basepath = os.path.abspath(basepath)
+    if not basepath.endswith('.jar'):
+      raise RuntimeError("Only jar files supported")
+    self.basepath = basepath
+    # best guess we have on locale code
+    self.locale = os.path.split(basepath)[1].replace('.jar','')
+    self.zf = ZipFile(basepath, 'r')
+  def cloneFile(self, other):
+    return JarEntry(self.zf, other.realfile, other.file)
+  def __iter__(self):
+    # get all entries, drop those ending with '/', those are dirs.
+    files = [f for f in self.zf.namelist() if not f.endswith('/')]
+    files.sort()
+    # unfortunately, we have to fake file paths of the form
+    # locale/AB-CD/
+    # for comparison.
+    # For real, the corresponding manifest would tell us. Whichever.
+    localesub = re.compile('^locale/' + self.locale)
+    for f in files:
+      yield JarEntry(self.zf, f, localesub.sub('locale/@AB_CD@', f))
+
+def compareJars(ref, l10n):
+  o  = CompareLocales.Observer()
+  cc = CompareLocales.ContentComparer(o)
+  dc = CompareLocales.DirectoryCompare(EnumerateJar(ref))
+  dc.setWatcher(cc)
+  dc.compareWith(EnumerateJar(l10n))
+  return o
diff --git a/lib/Mozilla/Parser.py b/lib/Mozilla/Parser.py
new file mode 100644 (file)
index 0000000..9434ba3
--- /dev/null
@@ -0,0 +1,483 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#      Toshihiro Kura
+#      Tomoya Asai (dynamis)
+#       Clint Talbert <ctalbert@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import re
+import codecs
+import logging
+from HTMLParser import HTMLParser
+
+__constructors = []
+
+class Entity(object):
+  '''
+  Abstraction layer for a localizable entity.
+  Currently supported are grammars of the form:
+
+  1: pre white space
+  2: pre comments
+  3: entity definition
+  4: entity key (name)
+  5: entity value
+  6: post comment (and white space) in the same line (dtd only)
+                                            <--[1]
+  <!-- pre comments -->                     <--[2]
+  <!ENTITY key "value"> <!-- comment -->
+  
+  <-------[3]---------><------[6]------>
+  '''
+  def __init__(self, contents, pp,
+               span, pre_ws_span, pre_comment_span, def_span,
+               key_span, val_span, post_span):
+    self.contents = contents
+    self.span = span
+    self.pre_ws_span = pre_ws_span
+    self.pre_comment_span = pre_comment_span
+    self.def_span = def_span
+    self.key_span = key_span
+    self.val_span = val_span
+    self.post_span = post_span
+    self.pp = pp
+    pass
+
+  # getter helpers
+
+  def get_all(self):
+    return self.contents[self.span[0] : self.span[1]]
+  def get_pre_ws(self):
+    return self.contents[self.pre_ws_span[0] : self.pre_ws_span[1]]
+  def get_pre_comment(self):
+    return self.contents[self.pre_comment_span[0] : self.pre_comment_span[1]]
+  def get_def(self):
+    return self.contents[self.def_span[0] : self.def_span[1]]
+  def get_key(self):
+    return self.contents[self.key_span[0] : self.key_span[1]]
+  def get_val(self):
+    return self.pp(self.contents[self.val_span[0] : self.val_span[1]])
+  def get_post(self):
+    return self.contents[self.post_span[0] : self.post_span[1]]
+
+  # getters
+
+  all = property(get_all)
+  pre_ws = property(get_pre_ws)
+  pre_comment = property(get_pre_comment)
+  definition = property(get_def)
+  key = property(get_key)
+  val = property(get_val)
+  post = property(get_post)
+
+  def __repr__(self):
+    return self.key
+
+class Junk(object):
+  '''
+  An almost-Entity, representing junk data that we didn't parse.
+  This way, we can signal bad content as stuff we don't understand.
+  And the either fix that, or report real bugs in localizations.
+  '''
+  junkid = 0
+  def __init__(self, contents, span):
+    self.contents = contents
+    self.span = span
+    self.pre_ws = self.pre_comment = self.definition = self.post = ''
+    self.__class__.junkid += 1
+    self.key = '_junk_%d_%d-%d' % (self.__class__.junkid, span[0], span[1])
+
+  # getter helpers
+
+  def get_all(self):
+    return self.contents[self.span[0] : self.span[1]]
+
+  # getters
+
+  all = property(get_all)
+  val = property(get_all)
+  def __repr__(self):
+    return self.key
+
+
+class Parser:
+  canMerge = True
+  def __init__(self):
+    if not hasattr(self, 'encoding'):
+      self.encoding = 'utf-8';
+    pass
+  def readFile(self, file):
+    f = codecs.open(file, 'r', self.encoding)
+    try:
+      self.contents = f.read()
+    except UnicodeDecodeError, e:
+      logging.getLogger('locales').error("Can't read file: " + file + '; ' + str(e))
+      self.contents = u''
+    f.close()
+  def readContents(self, contents):
+    (self.contents, length) = codecs.getdecoder(self.encoding)(contents)
+  def parse(self):
+    l = []
+    m = {}
+    for e in self:
+      m[e.key] = len(l)
+      l.append(e)
+    return (l, m)
+  def postProcessValue(self, val):
+    return val
+  def __iter__(self):
+    contents = self.contents
+    offset = 0
+    self.header, offset = self.getHeader(contents, offset)
+    self.footer = ''
+    entity, offset = self.getEntity(contents, offset)
+    while entity:
+      yield entity
+      entity, offset = self.getEntity(contents, offset)
+    f = self.reFooter.match(contents, offset)
+    if f:
+      self.footer = f.group()
+      offset = f.end()
+    if len(contents) > offset:
+      yield Junk(contents, (offset, len(contents)))
+    pass
+  def getHeader(self, contents, offset):
+    header = ''
+    h = self.reHeader.match(contents)
+    if h:
+      header = h.group()
+      offset = h.end()
+    return (header, offset)
+  def getEntity(self, contents, offset):
+    m = self.reKey.match(contents, offset)
+    if m:
+      offset = m.end()
+      entity = self.createEntity(contents, m)
+      return (entity, offset)
+    m = self.reKey.search(contents, offset)
+    if m:
+      # we didn't match, but search, so there's junk between offset
+      # and start. We'll match() on the next turn
+      junkend = m.start()
+      return (Junk(contents, (offset, junkend)), junkend)
+    return (None, offset)
+  def createEntity(self, contents, m):
+    return Entity(contents, self.postProcessValue,
+                  *[m.span(i) for i in xrange(7)])
+
+def getParser(path):
+  for item in __constructors:
+    if re.search(item[0], path):
+      return item[1]
+  raise UserWarning, "Cannot find Parser"
+
+
+# Subgroups of the match will:
+# 1: pre white space
+# 2: pre comments
+# 3: entity definition
+# 4: entity key (name)
+# 5: entity value
+# 6: post comment (and white space) in the same line (dtd only)
+#                                           <--[1]
+# <!-- pre comments -->                     <--[2]
+# <!ENTITY key "value"> <!-- comment -->
+# 
+# <-------[3]---------><------[6]------>
+
+
+class DTDParser(Parser):
+  # http://www.w3.org/TR/2006/REC-xml11-20060816/#NT-NameStartChar
+  #":" | [A-Z] | "_" | [a-z] |
+  # [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x2FF] | [#x370-#x37D] | [#x37F-#x1FFF]
+  # | [#x200C-#x200D] | [#x2070-#x218F] | [#x2C00-#x2FEF] |
+  # [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] |
+  # [#x10000-#xEFFFF]
+  CharMinusDash = u'\x09\x0A\x0D\u0020-\u002C\u002E-\uD7FF\uE000-\uFFFD'
+  XmlComment = '<!--(?:-?[%s])*?-->' % CharMinusDash
+  NameStartChar = u':A-Z_a-z\xC0-\xD6\xD8-\xF6\xF8-\u02FF' + \
+      u'\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF'+\
+      u'\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD'
+  # + \U00010000-\U000EFFFF seems to be unsupported in python
+  
+  # NameChar ::= NameStartChar | "-" | "." | [0-9] | #xB7 |
+  #   [#x0300-#x036F] | [#x203F-#x2040]
+  NameChar = NameStartChar + ur'\-\.0-9' + u'\xB7\u0300-\u036F\u203F-\u2040'
+  Name = '[' + NameStartChar + '][' + NameChar + ']*'
+  reKey = re.compile('(?:(?P<pre>\s*)(?P<precomment>(?:' + XmlComment + '\s*)*)(?P<entity><!ENTITY\s+(?P<key>' + Name + ')\s+(?P<val>\"[^\"]*\"|\'[^\']*\'?)\s*>)(?P<post>[ \t]*(?:' + XmlComment + '\s*)*\n?)?)', re.DOTALL)
+  # add BOM to DTDs, details in bug 435002
+  reHeader = re.compile(u'^\ufeff?(\s*<!--.*(http://mozilla.org/MPL/2.0/|LICENSE BLOCK)([^-]+-)*[^-]+-->)?', re.S)
+  reFooter = re.compile('\s*(<!--([^-]+-)*[^-]+-->\s*)*$')
+  rePE = re.compile('(?:(\s*)((?:' + XmlComment + '\s*)*)(<!ENTITY\s+%\s+(' + Name + ')\s+SYSTEM\s+(\"[^\"]*\"|\'[^\']*\')\s*>\s*%' + Name + ';)([ \t]*(?:' + XmlComment + '\s*)*\n?)?)')
+  def getEntity(self, contents, offset):
+    '''
+    Overload Parser.getEntity to special-case ParsedEntities.
+    Just check for a parsed entity if that method claims junk.
+
+    <!ENTITY % foo SYSTEM "url">
+    %foo;
+    '''
+    entity, inneroffset = Parser.getEntity(self, contents, offset)
+    if (entity and isinstance(entity, Junk)) or entity is None:
+      m = self.rePE.match(contents, offset)
+      if m:
+        inneroffset = m.end()
+        entity = Entity(contents, self.postProcessValue,
+                        *[m.span(i) for i in xrange(7)])
+    return (entity, inneroffset)
+  def createEntity(self, contents, m):
+    valspan = m.span('val')
+    valspan = (valspan[0]+1, valspan[1]-1)
+    return Entity(contents, self.postProcessValue, m.span(),
+                  m.span('pre'), m.span('precomment'),
+                  m.span('entity'), m.span('key'), valspan,
+                  m.span('post'))
+
+class PropertiesParser(Parser):
+  def __init__(self):
+    self.reKey = re.compile('^(\s*)((?:[#!].*?\n\s*)*)([^#!\s\n][^=:\n]*?)\s*[:=][ \t]*',re.M)
+    self.reHeader = re.compile('^\s*([#!].*\s*)+')
+    self.reFooter = re.compile('\s*([#!].*\s*)*$')
+    self._escapedEnd = re.compile(r'\\+$')
+    self._trailingWS = re.compile(r'[ \t]*$')
+    self._post = re.compile('\\\\u([0-9a-fA-F]{0,4})')
+    self._multLine = re.compile('\\\\\n\s*', re.M)
+    self._back = re.compile('\\\\(.)')
+    Parser.__init__(self)
+  _arg_re = re.compile('%(?:(?P<cn>[0-9]+)\$)?(?P<width>[0-9]+)?(?:.(?P<pres>[0-9]+))?(?P<size>[hL]|(?:ll?))?(?P<type>[dciouxXefgpCSsn])')
+  def getHeader(self, contents, offset):
+    header = ''
+    h = self.reHeader.match(contents, offset)
+    if h:
+      candidate = h.group()
+      if 'http://mozilla.org/MPL/2.0/' in candidate or 'LICENSE BLOCK' in candidate:
+        header = candidate
+        offset = h.end()
+    return (header, offset)
+  def getEntity(self, contents, offset):
+    # overwritten to parse values line by line
+    m = self.reKey.match(contents, offset)
+    if m:
+      offset = m.end()
+      while True:
+        endval = nextline = contents.find('\n', offset)
+        if nextline == -1:
+          endval = offset = len(contents)
+          break
+        # is newline escaped?
+        _e = self._escapedEnd.search(contents, offset, nextline)
+        offset = nextline + 1
+        if _e is None:
+          break
+        # backslashes at end of line, if 2*n, not escaped
+        if len(_e.group()) % 2 == 0:
+          break;
+      # strip trailing whitespace
+      ws = self._trailingWS.search(contents, m.end(), offset)
+      if ws:
+        endval -= ws.end() - ws.start()
+      entity = Entity(contents, self.postProcessValue,
+                      (m.start(), offset),  # full span
+                      m.span(1),  # leading whitespan
+                      m.span(2),  # leading comment span
+                      (m.start(3), offset),  # entity def span
+                      m.span(3),  # key span
+                      (m.end(), endval),  # value span
+                      (offset, offset))  # post comment span, empty
+      return (entity, offset)
+    m = self.reKey.search(contents, offset)
+    if m:
+      # we didn't match, but search, so there's junk between offset
+      # and start. We'll match() on the next turn
+      junkend = m.start()
+      return (Junk(contents, (offset, junkend)), junkend)
+    return (None, offset)
+  def postProcessValue(self, val):
+    val = self._post.sub(lambda m: unichr(int(m.group(1), 16)), val)  # unicode escapes
+    val = self._multLine.sub('', val)  # multiline escapes
+    # ... and the rest
+    val = self._back.sub(lambda m: {'n': '\n', 'r': '\r', 't': '\t', '\\': '\\'}.get(m.group(1), m.group(1)), val)
+    return val
+
+class DefinesParser(Parser):
+  # can't merge, #unfilter needs to be the last item, which we don't support
+  canMerge = False
+  def __init__(self):
+    self.reKey = re.compile('^(\s*)((?:^#(?!define\s).*\s*)*)(#define[ \t]+(\w+)[ \t]+(.*?))([ \t]*$\n?)',re.M)
+    self.reHeader = re.compile('^\s*(#(?!define\s).*\s*)*')
+    self.reFooter = re.compile('\s*(#(?!define\s).*\s*)*$',re.M)
+    Parser.__init__(self)
+
+class IniParser(Parser):
+  '''
+  Parse files of the form:
+  # initial comment
+  [cat]
+  whitespace*
+  #comment
+  string=value
+  ...
+  '''
+  def __init__(self):
+    self.reHeader = re.compile('^(\s*[;#].*\n)*\[.+?\]\n', re.M)
+    self.reKey = re.compile('(\s*)((?:[;#].*\n\s*)*)((.+?)=(.*))(\n?)')
+    self.reFooter = re.compile('\s*')
+    Parser.__init__(self)
+
+
+DECL, COMMENT, START, END, CONTENT = range(5)
+
+class BookmarksParserInner(HTMLParser):
+
+  class Token(object):
+    _type = None
+    content = ''
+    def __str__(self):
+      return self.content
+  class DeclToken(Token):
+    _type = DECL
+    def __init__(self, decl):
+      self.content = decl
+      pass
+    def __str__(self):
+      return '<!%s>' % self.content
+    pass
+  class CommentToken(Token):
+    _type = COMMENT
+    def __init__(self, comment):
+      self.content = comment
+      pass
+    def __str__(self):
+      return '<!--%s-->' % self.content
+    pass
+  class StartToken(Token):
+    _type = START
+    def __init__(self, tag, attrs, content):
+      self.tag = tag
+      self.attrs = dict(attrs)
+      self.content = content
+      pass
+    pass
+  class EndToken(Token):
+    _type = END
+    def __init__(self, tag):
+      self.tag = tag
+      pass
+    def __str__(self):
+      return '</%s>' % self.tag.upper()
+    pass
+  class ContentToken(Token):
+    _type = CONTENT
+    def __init__(self, content):
+      self.content = content
+      pass
+    pass
+  
+  def __init__(self):
+    HTMLParser.__init__(self)
+    self.tokens = []
+
+  def parse(self, contents):
+    self.tokens = []
+    self.feed(contents)
+    self.close()
+    return self.tokens
+
+  # Called when we hit an end DL tag to reset the folder selections
+  def handle_decl(self, decl):
+    self.tokens.append(self.DeclToken(decl))
+
+  # Called when we hit an end DL tag to reset the folder selections
+  def handle_comment(self, comment):
+    self.tokens.append(self.CommentToken(comment))
+
+  def handle_starttag(self, tag, attrs):
+    self.tokens.append(self.StartToken(tag, attrs, self.get_starttag_text()))
+
+  # Called when text data is encountered
+  def handle_data(self, data):
+    if self.tokens[-1]._type == CONTENT:
+      self.tokens[-1].content += data
+    else:
+      self.tokens.append(self.ContentToken(data))
+
+  def handle_charref(self, data):
+    self.handle_data('&#%s;' % data)
+
+  def handle_entityref(self, data):
+    self.handle_data('&%s;' % data)
+
+  # Called when we hit an end DL tag to reset the folder selections
+  def handle_endtag(self, tag):
+    self.tokens.append(self.EndToken(tag))
+
+class BookmarksParser(Parser):
+  canMerge = False
+
+  class BMEntity(object):
+    def __init__(self, key, val):
+      self.key = key
+      self.val = val
+
+  def __iter__(self):
+    p = BookmarksParserInner()
+    tks = p.parse(self.contents)
+    i = 0
+    k = []
+    for i in xrange(len(tks)):
+      t = tks[i]
+      if t._type == START:
+        k.append(t.tag)
+        keys = t.attrs.keys()
+        keys.sort()
+        for attrname in keys:
+          yield self.BMEntity('.'.join(k) + '.@' + attrname,
+                              t.attrs[attrname])
+        if i + 1 < len(tks) and tks[i+1]._type == CONTENT:
+          i += 1
+          t = tks[i]
+          v = t.content.strip()
+          if v:
+            yield self.BMEntity('.'.join(k), v)
+      elif t._type == END:
+        k.pop()
+
+
+__constructors = [('\\.dtd$', DTDParser()),
+                  ('\\.properties$', PropertiesParser()),
+                  ('\\.ini$', IniParser()),
+                  ('\\.inc$', DefinesParser()),
+                  ('bookmarks\\.html$', BookmarksParser())]
diff --git a/lib/Mozilla/Paths.py b/lib/Mozilla/Paths.py
new file mode 100644 (file)
index 0000000..7d290b8
--- /dev/null
@@ -0,0 +1,412 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import os.path
+import os
+from ConfigParser import ConfigParser, NoSectionError, NoOptionError
+from urlparse import urlparse, urljoin
+from urllib import pathname2url, url2pathname
+from urllib2 import urlopen
+from Mozilla.CompareLocales import defaultdict
+
+class L10nConfigParser(object):
+  '''Helper class to gather application information from ini files.
+
+  This class is working on synchronous open to read files or web data.
+  Subclass this and overwrite loadConfigs and addChild if you need async.
+  '''
+  def __init__(self, inipath, **kwargs):
+    """Constructor for L10nConfigParsers
+    
+    inipath -- l10n.ini path
+    Optional keyword arguments are fowarded to the inner ConfigParser as
+    defaults.
+    """
+    if os.path.isabs(inipath):
+      self.inipath = 'file:%s' % pathname2url(inipath)
+    else:
+      pwdurl = 'file:%s/' % pathname2url(os.getcwd())
+      self.inipath = urljoin(pwdurl, inipath)
+    # l10n.ini files can import other l10n.ini files, store the 
+    # corresponding L10nConfigParsers
+    self.children = []
+    # we really only care about the l10n directories described in l10n.ini
+    self.dirs = []
+    # optional defaults to be passed to the inner ConfigParser (unused?)
+    self.defaults = kwargs
+
+  def getDepth(self, cp):
+    '''Get the depth for the comparison from the parsed l10n.ini.
+
+    Overloadable to get the source depth for fennec and friends.
+    '''
+    try:
+      depth = cp.get('general', 'depth')
+    except:
+      depth = '.'
+    return depth
+
+  def getFilters(self):
+    '''Get the test functions from this ConfigParser and all children.
+
+    Only works with synchronous loads, used by compare-locales, which
+    is local anyway.
+    '''
+    filterurl = urljoin(self.inipath, 'filter.py')
+    try:
+      l = {}
+      execfile(url2pathname(urlparse(filterurl).path), {}, l)
+      if 'test' in l and callable(l['test']):
+        filters = [l['test']]
+      else:
+        filters = []
+    except:
+      filters = []
+
+    for c in self.children:
+      filters += c.getFilters()
+
+    return filters
+
+
+  def loadConfigs(self):
+    """Entry point to load the l10n.ini file this Parser refers to.
+
+    This implementation uses synchronous loads, subclasses might overload
+    this behaviour. If you do, make sure to pass a file-like object
+    to onLoadConfig.
+    """
+    self.onLoadConfig(urlopen(self.inipath))
+
+  def onLoadConfig(self, inifile):
+    """Parse a file-like object for the loaded l10n.ini file."""
+    cp = ConfigParser(self.defaults)
+    cp.readfp(inifile)
+    depth = self.getDepth(cp)
+    self.baseurl = urljoin(self.inipath, depth)
+    # create child loaders for any other l10n.ini files to be included
+    try:
+      for title, path in cp.items('includes'):
+        # skip default items
+        if title in self.defaults:
+          continue
+        # add child config parser
+        self.addChild(title, path, cp)
+    except NoSectionError:
+      pass
+    # try to load the "dirs" defined in the "compare" section
+    try:
+      self.dirs.extend(cp.get('compare', 'dirs').split())
+    except (NoOptionError, NoSectionError):
+      pass
+    # try getting a top level compare dir, as used for fennec
+    try:
+      self.tld = cp.get('compare', 'tld')
+      # remove tld from comparison dirs
+      if self.tld in self.dirs:
+        self.dirs.remove(self.tld)
+    except (NoOptionError, NoSectionError):
+      self.tld = None
+    # try to set "all_path" and "all_url"
+    try:
+      self.all_path = cp.get('general', 'all')
+      self.all_url = urljoin(self.baseurl, self.all_path)
+    except (NoOptionError, NoSectionError):
+      self.all_path = None
+      self.all_url = None
+    return cp
+
+  def addChild(self, title, path, orig_cp):
+    """Create a child L10nConfigParser and load it.
+    
+    title -- indicates the module's name
+    path -- indicates the path to the module's l10n.ini file
+    orig_cp -- the configuration parser of this l10n.ini
+    """
+    cp = L10nConfigParser(urljoin(self.baseurl, path), **self.defaults)
+    cp.loadConfigs()
+    self.children.append(cp)
+
+  def getTLDPathsTuple(self, basepath):
+    """Given the basepath, return the path fragments to be used for 
+    self.tld. For build runs, this is (basepath, self.tld), for
+    source runs, just (basepath,).
+
+    @see overwritten method in SourceTreeConfigParser.
+    """
+    return (basepath, self.tld)
+
+  def dirsIter(self):
+    """Iterate over all dirs and our base path for this l10n.ini"""
+    url = urlparse(self.baseurl)
+    basepath = url2pathname(url.path)
+    if self.tld is not None:
+      yield self.tld, self.getTLDPathsTuple(basepath)
+    for dir in self.dirs:
+      yield dir, (basepath, dir)
+    
+
+  def directories(self):
+    """Iterate over all dirs and base paths for this l10n.ini as well
+    as the included ones.
+    """
+    for t in self.dirsIter():
+      yield t
+    for child in self.children:
+      for t in child.directories():
+        yield t
+
+  def allLocales(self):
+    """Return a list of all the locales of this project"""
+    return urlopen(self.all_url).read().splitlines()
+
+
+class SourceTreeConfigParser(L10nConfigParser):
+  '''Subclassing L10nConfigParser to work with just the repos
+  checked out next to each other instead of intermingled like
+  we do for real builds.
+  '''
+
+  def __init__(self, inipath, basepath):
+    '''Add additional arguments basepath.
+
+    basepath is used to resolve local paths via branchnames.
+    '''
+    L10nConfigParser.__init__(self, inipath)
+    self.basepath = basepath
+    self.tld = None
+
+  def getDepth(self, cp):
+    '''Get the depth for the comparison from the parsed l10n.ini.
+
+    Overloaded to get the source depth for fennec and friends.
+    '''
+    try:
+      depth = cp.get('general', 'source-depth')
+    except:
+      try:
+        depth = cp.get('general', 'depth')
+      except:
+        depth = '.'
+    return depth
+
+  def addChild(self, title, path, orig_cp):
+    # check if there's a section with details for this include
+    # we might have to check a different repo, or even VCS
+    # for example, projects like "mail" indicate in
+    # an "include_" section where to find the l10n.ini for "toolkit"
+    details = 'include_' + title
+    if orig_cp.has_section(details):
+      branch = orig_cp.get(details, 'mozilla')
+      inipath = orig_cp.get(details, 'l10n.ini')
+      path = self.basepath + '/' + branch + '/' + inipath
+    else:
+      path = urljoin(self.baseurl, path)
+    cp = SourceTreeConfigParser(path, self.basepath, **self.defaults)
+    cp.loadConfigs()
+    self.children.append(cp)
+
+  def getTLDPathsTuple(self, basepath):
+    """Overwrite L10nConfigParser's getTLDPathsTuple to just return 
+    the basepath.
+    """
+    return (basepath, )
+
+
+class File(object):
+  def __init__(self, fullpath, file, module = None, locale = None):
+    self.fullpath = fullpath
+    self.file = file
+    self.module = module
+    self.locale = locale
+    pass
+  def getContents(self):
+    # open with universal line ending support and read
+    return open(self.fullpath, 'rU').read()
+  def __hash__(self):
+    f = self.file
+    if self.module:
+      f = self.module + '/' + f
+    return hash(f)
+  def __str__(self):
+    return self.fullpath
+  def __cmp__(self, other):
+    if not isinstance(other, File):
+      raise NotImplementedError
+    rv = cmp(self.module, other.module)
+    if rv != 0:
+      return rv
+    return cmp(self.file, other.file)
+
+class EnumerateDir(object):
+  ignore_dirs = ['CVS', '.svn', '.hg']
+  def __init__(self, basepath, module = '', locale = None, ignore_subdirs = []):
+    self.basepath = basepath
+    self.module = module
+    self.locale = locale
+    self.ignore_subdirs = ignore_subdirs
+    pass
+  def cloneFile(self, other):
+    '''
+    Return a File object that this enumerator would return, if it had it.
+    '''
+    return File(os.path.join(self.basepath, other.file), other.file,
+                self.module, self.locale)
+  def __iter__(self):
+    # our local dirs are given as a tuple of path segments, starting off
+    # with an empty sequence for the basepath.
+    dirs = [()]
+    while dirs:
+      dir = dirs.pop(0)
+      fulldir = os.path.join(self.basepath, *dir)
+      try:
+        entries = os.listdir(fulldir)
+      except OSError:
+        # we probably just started off in a non-existing dir, ignore
+        continue
+      entries.sort()
+      for entry in entries:
+        leaf = os.path.join(fulldir, entry)
+        if os.path.isdir(leaf):
+          if entry not in self.ignore_dirs and \
+             leaf not in [os.path.join(self.basepath, d) for d in self.ignore_subdirs]:
+            dirs.append(dir + (entry,))
+          continue
+        yield File(leaf, '/'.join(dir + (entry,)),
+                   self.module, self.locale)
+
+class LocalesWrap(object):
+  def __init__(self, base, module, locales, ignore_subdirs = []):
+    self.base = base
+    self.module = module
+    self.locales = locales
+    self.ignore_subdirs = ignore_subdirs
+  def __iter__(self):
+    for locale in self.locales:
+      path = os.path.join(self.base, locale, self.module)
+      yield (locale, EnumerateDir(path, self.module, locale, self.ignore_subdirs))
+
+class EnumerateApp(object):
+  reference =  'en-US'
+  def __init__(self, inipath, l10nbase, locales = None):
+    self.setupConfigParser(inipath)
+    self.modules = defaultdict(dict)
+    self.l10nbase = os.path.abspath(l10nbase)
+    self.filters = []
+    drive, tail = os.path.splitdrive(inipath)
+    filterpath = drive + url2pathname(urlparse(urljoin(tail,'filter.py'))[2])
+    self.addFilters(*self.config.getFilters())
+    self.locales = locales or self.config.allLocales()
+    self.locales.sort()
+    pass
+  def setupConfigParser(self, inipath):
+    self.config = L10nConfigParser(inipath)
+    self.config.loadConfigs()
+  def addFilters(self, *args):
+    self.filters += args
+
+  value_map = {None:None, 'error':0, 'ignore':1, 'report':2}
+  def filter(self, l10n_file, entity = None):
+    '''Go through all added filters, and,
+    - map "error" -> 0, "ignore" -> 1, "report" -> 2
+    - if filter.test returns a bool, map that to
+      False -> "ignore" (1), True -> "error" (0)
+    - take the max of all reported
+    '''
+    rv = 0
+    for f in reversed(self.filters):
+      try: 
+        _r = f(l10n_file.module, l10n_file.file, entity)
+      except:
+        # XXX error handling
+        continue
+      if isinstance(_r, bool):
+        _r = [1, 0][_r]
+      else:
+        # map string return value to int, default to 'error', None is None
+        _r = self.value_map.get(_r, 0)
+      if _r is not None:
+        rv = max(rv, _r)
+    return ['error','ignore','report'][rv]
+
+  def __iter__(self):
+    '''
+    Iterate over all modules, return en-US directory enumerator, and an
+    iterator over all locales in each iteration. Per locale, the locale
+    code and an directory enumerator will be given.
+    '''
+    dirmap = dict(self.config.directories())
+    mods = dirmap.keys()
+    mods.sort()
+    for mod in mods:
+      if self.reference == 'en-US':
+        base = os.path.join(*(dirmap[mod] + ('locales', 'en-US')))
+      else:
+        base = os.path.join(self.l10nbase, self.reference, mod)
+      yield (mod, EnumerateDir(base, mod, self.reference),
+             LocalesWrap(self.l10nbase, mod, self.locales,
+                         [m[len(mod)+1:] for m in mods if m.startswith(mod+'/')]))
+
+class EnumerateSourceTreeApp(EnumerateApp):
+  '''Subclass EnumerateApp to work on side-by-side checked out
+  repos, and to no pay attention to how the source would actually
+  be checked out for building.
+
+  It's supporting applications like Fennec, too, which have
+  'locales/en-US/...' in their root dir, but claim to be 'mobile'.
+  '''
+
+  def __init__(self, inipath, basepath, l10nbase, locales=None):
+    self.basepath = basepath
+    EnumerateApp.__init__(self, inipath, l10nbase, locales)
+
+  def setupConfigParser(self, inipath):
+    self.config = SourceTreeConfigParser(inipath, self.basepath)
+    self.config.loadConfigs()
+
+
+def get_base_path(mod, loc):
+  'statics for path patterns and conversion'
+  __l10n = 'l10n/%(loc)s/%(mod)s'
+  __en_US = 'mozilla/%(mod)s/locales/en-US'
+  if loc == 'en-US':
+    return __en_US % {'mod': mod}
+  return __l10n % {'mod': mod, 'loc': loc}
+
+def get_path(mod, loc, leaf):
+  return get_base_path(mod, loc) + '/' + leaf
+
diff --git a/lib/Mozilla/__init__.py b/lib/Mozilla/__init__.py
new file mode 100644 (file)
index 0000000..8672afd
--- /dev/null
@@ -0,0 +1 @@
+version="0.9.5"
diff --git a/lib/Mozilla/tests/unitChecks.py b/lib/Mozilla/tests/unitChecks.py
new file mode 100644 (file)
index 0000000..8ea170f
--- /dev/null
@@ -0,0 +1,259 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+
+from Mozilla.Checks import getChecks
+from Mozilla.Parser import getParser, Entity
+from Mozilla.Paths import File
+
+
+class BaseHelper(unittest.TestCase):
+    file = None
+    refContent = None
+
+    def setUp(self):
+        p = getParser(self.file.file)
+        p.readContents(self.refContent)
+        self.refList, self.refMap = p.parse()
+
+    def _test(self, content, refWarnOrErrors):
+        p = getParser(self.file.file)
+        p.readContents(content)
+        l10n = [e for e in p]
+        assert len(l10n) == 1
+        l10n = l10n[0]
+        checks = getChecks(self.file)
+        ref = self.refList[self.refMap[l10n.key]]
+        found = tuple(checks(ref, l10n))
+        self.assertEqual(found, refWarnOrErrors)
+
+
+class TestPlurals(BaseHelper):
+    file = File('foo.properties', 'foo.properties')
+    refContent = '''# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of files
+# example: 111 files - Downloads
+downloadsTitleFiles=#1 file - Downloads;#1 files - #2
+'''
+
+    def testGood(self):
+        self._test('''# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of files
+# example: 111 files - Downloads
+downloadsTitleFiles=#1 file - Downloads;#1 files - #2;#1 filers
+''',
+                   tuple())
+
+    def testNotUsed(self):
+        self._test('''# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of files
+# example: 111 files - Downloads
+downloadsTitleFiles=#1 file - Downloads;#1 files - Downloads;#1 filers
+''',
+                   (('warning', 0, 'not all variables used in l10n', 'plural'),))
+
+    def testNotDefined(self):
+        self._test('''# LOCALIZATION NOTE (downloadsTitleFiles): Semi-colon list of plural forms.
+# See: http://developer.mozilla.org/en/docs/Localization_and_Plurals
+# #1 number of files
+# example: 111 files - Downloads
+downloadsTitleFiles=#1 file - Downloads;#1 files - #2;#1 #3
+''',
+                   (('error', 0, 'unreplaced variables in l10n', 'plural'),))
+
+
+class TestDTDs(BaseHelper):
+    file = File('foo.dtd', 'foo.dtd')
+    refContent = '''<!ENTITY foo "This is &apos;good&apos;">
+<!ENTITY width "10ch">
+<!ENTITY style "width: 20ch; height: 280px;">
+<!ENTITY minStyle "min-height: 50em;">
+<!ENTITY ftd "0">
+<!ENTITY formatPercent "This is 100&#037; correct">
+<!ENTITY some.key "K">
+'''
+    def testWarning(self):
+        self._test('''<!ENTITY foo "This is &not; good">
+''',
+                   (('warning',(0,0),'Referencing unknown entity `not`', 'xmlparse'),))
+        # make sure we only handle translated entity references
+        self._test(u'''<!ENTITY foo "This is &ƞǿŧ; good">
+'''.encode('utf-8'),
+                   (('warning',(0,0),u'Referencing unknown entity `ƞǿŧ`', 'xmlparse'),))
+    def testErrorFirstLine(self):
+        self._test('''<!ENTITY foo "This is </bad> stuff">
+''',
+                   (('error',(1,10),'mismatched tag', 'xmlparse'),))
+    def testErrorSecondLine(self):
+        self._test('''<!ENTITY foo "This is
+  </bad>
+stuff">
+''',
+                   (('error',(2,4),'mismatched tag', 'xmlparse'),))
+    def testKeyErrorSingleAmpersand(self):
+        self._test('''<!ENTITY some.key "&">
+''',
+                   (('error', (1, 1), 'not well-formed (invalid token)', 'xmlparse'),))
+    def testXMLEntity(self):
+        self._test('''<!ENTITY foo "This is &quot;good&quot;">
+''',
+                   tuple())
+    def testPercentEntity(self):
+        self._test('''<!ENTITY formatPercent "Another 100&#037;">
+''',
+                   tuple())
+        self._test('''<!ENTITY formatPercent "Bad 100% should fail">
+''',
+                   (('error', (0, 32), 'not well-formed (invalid token)', 'xmlparse'),))
+    def testNoNumber(self):
+        self._test('''<!ENTITY ftd "foo">''',
+                   (('warning', 0, 'reference is a number', 'number'),))
+    def testNoLength(self):
+        self._test('''<!ENTITY width "15miles">''',
+                   (('error', 0, 'reference is a CSS length', 'css'),))
+    def testNoStyle(self):
+        self._test('''<!ENTITY style "15ch">''',
+                   (('error', 0, 'reference is a CSS spec', 'css'),))
+        self._test('''<!ENTITY style "junk">''',
+                   (('error', 0, 'reference is a CSS spec', 'css'),))
+    def testStyleWarnings(self):
+        self._test('''<!ENTITY style "width:15ch">''',
+                   (('warning', 0, 'height only in reference', 'css'),))
+        self._test('''<!ENTITY style "width:15em;height:200px;">''',
+                   (('warning', 0, "units for width don't match (em != ch)", 'css'),))
+    def testNoWarning(self):
+        self._test('''<!ENTITY width "12em">''', tuple())
+        self._test('''<!ENTITY style "width:12ch;height:200px;">''', tuple())
+        self._test('''<!ENTITY ftd "0">''', tuple())
+
+
+class TestAndroid(unittest.TestCase):
+    """Test Android checker
+
+    Make sure we're hitting our extra rules only if
+    we're passing in a DTD file in the embedding/android module.
+    """
+    apos_msg = u"Apostrophes in Android DTDs need escaping with \\' or \\u0027, " + \
+               u"or use \u2019, or put string in quotes."
+    quot_msg = u"Quotes in Android DTDs need escaping with \\\" or \\u0022, " + \
+               u"or put string in apostrophes."
+    def getEntity(self, v):
+        return Entity(v, lambda s: s, (0, len(v)), (), (0, 0), (), (), (0, len(v)), ())
+    def getDTDEntity(self, v):
+        v = v.replace('"', '&quot;')
+        return Entity('<!ENTITY foo "%s">' % v,
+                      lambda s: s,
+            (0, len(v)+16), (), (0, 0), (), (9,12), (14, len(v)+14), ())
+    def test_android_dtd(self):
+        """Testing the actual android checks. The logic is involved, so this is a lot
+        of nitty gritty detail tests.
+        """
+        f = File("embedding/android/strings.dtd", "strings.dtd", "embedding/android")
+        checks = getChecks(f)
+        # good string
+        ref = self.getDTDEntity("plain string")
+        l10n = self.getDTDEntity("plain localized string")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+        # dtd warning
+        l10n = self.getDTDEntity("plain localized string &ref;")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('warning', (0, 0), 'Referencing unknown entity `ref`', 'xmlparse'),))
+        # no report on stray ampersand or quote, if not completely quoted
+        for i in xrange(3):
+            # make sure we're catching unescaped apostrophes, try 0..5 backticks
+            l10n = self.getDTDEntity("\\"*(2*i) + "'")
+            self.assertEqual(tuple(checks(ref, l10n)),
+                             (('error', 2*i, self.apos_msg, 'android'),))
+            l10n = self.getDTDEntity("\\"*(2*i + 1) + "'")
+            self.assertEqual(tuple(checks(ref, l10n)),
+                             ())
+            # make sure we don't report if apos string is quoted
+            l10n = self.getDTDEntity('"' + "\\"*(2*i) + "'\"")
+            tpl = tuple(checks(ref, l10n))
+            self.assertEqual(tpl, (), "`%s` shouldn't fail but got %s" % (l10n.val, str(tpl)))
+            l10n = self.getDTDEntity('"' + "\\"*(2*i+1) + "'\"")
+            tpl = tuple(checks(ref, l10n))
+            self.assertEqual(tpl, (), "`%s` shouldn't fail but got %s" % (l10n.val, str(tpl)))
+            # make sure we're catching unescaped quotes, try 0..5 backticks
+            l10n = self.getDTDEntity("\\"*(2*i) + "\"")
+            self.assertEqual(tuple(checks(ref, l10n)),
+                             (('error', 2*i, self.quot_msg, 'android'),))
+            l10n = self.getDTDEntity("\\"*(2*i + 1) + "'")
+            self.assertEqual(tuple(checks(ref, l10n)),
+                             ())
+            # make sure we don't report if quote string is single quoted
+            l10n = self.getDTDEntity("'" + "\\"*(2*i) + "\"'")
+            tpl = tuple(checks(ref, l10n))
+            self.assertEqual(tpl, (), "`%s` shouldn't fail but got %s" % (l10n.val, str(tpl)))
+            l10n = self.getDTDEntity('"' + "\\"*(2*i+1) + "'\"")
+            tpl = tuple(checks(ref, l10n))
+            self.assertEqual(tpl, (), "`%s` shouldn't fail but got %s" % (l10n.val, str(tpl)))
+        # check for mixed quotes and ampersands
+        l10n = self.getDTDEntity("'\"")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 0, self.apos_msg, 'android'),
+                          ('error', 1, self.quot_msg, 'android')))
+        l10n = self.getDTDEntity("''\"'")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 1, self.apos_msg, 'android'),))
+        l10n = self.getDTDEntity('"\'""')
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 2, self.quot_msg, 'android'),))
+        
+        # broken unicode escape
+        l10n = self.getDTDEntity("Some broken \u098 unicode")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 12, 'truncated \\uXXXX escape', 'android'),))
+        # broken unicode escape, try to set the error off
+        l10n = self.getDTDEntity(u"\u9690"*14+"\u006"+"  "+"\u0064")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 14, 'truncated \\uXXXX escape', 'android'),))
+    def test_android_prop(self):
+        f = File("embedding/android/strings.properties", "strings.properties", "embedding/android")
+        checks = getChecks(f)
+        # good plain string
+        ref = self.getEntity("plain string")
+        l10n = self.getEntity("plain localized string")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+        # no dtd warning
+        ref = self.getEntity("plain string")
+        l10n = self.getEntity("plain localized string &ref;")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+        # no report on stray ampersand
+        ref = self.getEntity("plain string")
+        l10n = self.getEntity("plain localized string with apos: '")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+        # report on bad printf
+        ref = self.getEntity("string with %s")
+        l10n = self.getEntity("string with %S")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('error', 0, 'argument 1 `S` should be `s`', 'printf'),))
+    def test_non_android_dtd(self):
+        f = File("browser/strings.dtd", "strings.dtd", "browser")
+        checks = getChecks(f)
+        # good string
+        ref = self.getDTDEntity("plain string")
+        l10n = self.getDTDEntity("plain localized string")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+        # dtd warning
+        ref = self.getDTDEntity("plain string")
+        l10n = self.getDTDEntity("plain localized string &ref;")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         (('warning', (0, 0), 'Referencing unknown entity `ref`', 'xmlparse'),))
+        # no report on stray ampersand
+        ref = self.getDTDEntity("plain string")
+        l10n = self.getDTDEntity("plain localized string with apos: '")
+        self.assertEqual(tuple(checks(ref, l10n)),
+                         ())
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/lib/Mozilla/tests/unitDTD.py b/lib/Mozilla/tests/unitDTD.py
new file mode 100644 (file)
index 0000000..36130b0
--- /dev/null
@@ -0,0 +1,113 @@
+import unittest
+import re
+
+from Mozilla.Parser import getParser, Junk
+
+class TestDTD(unittest.TestCase):
+
+    def testGood(self):
+        self._test('''<!ENTITY foo.label "stuff">''',
+                   (('foo.label','stuff'),))
+
+
+    quoteContent = '''<!ENTITY good.one "one">
+<!ENTITY bad.one "bad " quote">
+<!ENTITY good.two "two">
+<!ENTITY bad.two "bad "quoted" word">
+<!ENTITY good.three "three">
+<!ENTITY good.four "good ' quote">
+<!ENTITY good.five "good 'quoted' word">
+'''
+    quoteRef = (
+        ('good.one', 'one'),
+        ('_junk_1_25-56', '<!ENTITY bad.one "bad " quote">'),
+        ('good.two', 'two'),
+        ('_junk_2_82-119', '<!ENTITY bad.two "bad "quoted" word">'),
+        ('good.three', 'three'),
+        ('good.four', 'good \' quote'),
+        ('good.five', 'good \'quoted\' word'),
+        )
+    def testQuote(self):
+        self._test(self.quoteContent, self.quoteRef)
+
+    def testApos(self):
+        qr = re.compile('[\'"]', re.M)
+        def quot2apos(s):
+            return qr.sub(lambda m: m.group(0)=='"' and "'" or '"', s)
+            
+        self._test(quot2apos(self.quoteContent), 
+                   map(lambda t: (t[0], quot2apos(t[1])), self.quoteRef))
+
+    def testDTD(self):
+        self._test('''<!ENTITY % fooDTD SYSTEM "chrome://brand.dtd">
+  %fooDTD;
+''',
+                   (('fooDTD', '"chrome://brand.dtd"'),))
+
+    def _test(self, content, refs):
+        p = getParser('foo.dtd')
+        Junk.junkid = 0
+        p.readContents(content)
+        entities = [e for e in p]
+        self.assertEqual(len(entities), len(refs))
+        for e, ref in zip(entities, refs):
+            self.assertEqual(e.val, ref[1])
+            self.assertEqual(e.key, ref[0])
+
+    def testLicenseHeader(self):
+        p = getParser('foo.dtd')
+        p.readContents('''<!-- ***** BEGIN LICENSE BLOCK *****
+#if 0
+   - Version: MPL 1.1/GPL 2.0/LGPL 2.1
+   -
+   - The contents of this file are subject to the Mozilla Public License Version
+   - 1.1 (the "License"); you may not use this file except in compliance with
+   - the License. You may obtain a copy of the License at
+   - http://www.mozilla.org/MPL/
+   -
+   - Software distributed under the License is distributed on an "AS IS" basis,
+   - WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+   - for the specific language governing rights and limitations under the
+   - License.
+   -
+   - The Original Code is mozilla.org Code.
+   -
+   - The Initial Developer of the Original Code is dummy.
+   - Portions created by the Initial Developer are Copyright (C) 2005
+   - the Initial Developer. All Rights Reserved.
+   -
+   - Contributor(s):
+   -
+   - Alternatively, the contents of this file may be used under the terms of
+   - either the GNU General Public License Version 2 or later (the "GPL"), or
+   - the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+   - in which case the provisions of the GPL or the LGPL are applicable instead
+   - of those above. If you wish to allow use of your version of this file only
+   - under the terms of either the GPL or the LGPL, and not to allow others to
+   - use your version of this file under the terms of the MPL, indicate your
+   - decision by deleting the provisions above and replace them with the notice
+   - and other provisions required by the LGPL or the GPL. If you do not delete
+   - the provisions above, a recipient may use your version of this file under
+   - the terms of any one of the MPL, the GPL or the LGPL.
+   -
+#endif
+   - ***** END LICENSE BLOCK ***** -->
+
+<!ENTITY foo "value">
+''')
+        for e in p:
+            self.assertEqual(e.key, 'foo')
+            self.assertEqual(e.val, 'value')
+        self.assert_('MPL' in p.header)
+        p.readContents('''<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+   - You can obtain one at http://mozilla.org/MPL/2.0/.  -->
+<!ENTITY foo "value">
+''')
+        for e in p:
+            self.assertEqual(e.key, 'foo')
+            self.assertEqual(e.val, 'value')
+        self.assert_('MPL' in p.header)
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/lib/Mozilla/tests/unitMerge.py b/lib/Mozilla/tests/unitMerge.py
new file mode 100644 (file)
index 0000000..94f6526
--- /dev/null
@@ -0,0 +1,132 @@
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2010
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+import unittest
+import os
+from tempfile import mkdtemp
+import shutil
+
+from Mozilla.Parser import getParser
+from Mozilla.Paths import File
+from Mozilla.CompareLocales import ContentComparer, Observer
+
+class TestProperties(unittest.TestCase):
+    def setUp(self):
+        self.tmp = mkdtemp()
+        os.mkdir(os.path.join(self.tmp, "merge"))
+        self.ref = os.path.join(self.tmp, "en-reference.properties")
+        open(self.ref, "w").write("""foo = fooVal
+bar = barVal
+eff = effVal""")
+    def tearDown(self):
+        shutil.rmtree(self.tmp)
+        del self.tmp
+    def testGood(self):
+        self.assertTrue(os.path.isdir(self.tmp))
+        l10n = os.path.join(self.tmp, "l10n.properties")
+        open(l10n, "w").write("""foo = lFoo
+bar = lBar
+eff = lEff
+""")
+        obs = Observer()
+        cc = ContentComparer(obs)
+        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.compare(File(self.ref, "en-reference.properties", ""),
+                   File(l10n, "l10n.properties", ""))
+        print obs.serialize()
+    def testMissing(self):
+        self.assertTrue(os.path.isdir(self.tmp))
+        l10n = os.path.join(self.tmp, "l10n.properties")
+        open(l10n, "w").write("""bar = lBar
+""")
+        obs = Observer()
+        cc = ContentComparer(obs)
+        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.compare(File(self.ref, "en-reference.properties", ""),
+                   File(l10n, "l10n.properties", ""))
+        print obs.serialize()
+        mergefile = os.path.join(self.tmp, "merge", "l10n.properties")
+        self.assertTrue(os.path.isfile(mergefile))
+        p = getParser(mergefile)
+        p.readFile(mergefile)
+        [m,n] = p.parse()
+        self.assertEqual(map(lambda e:e.key,m), ["bar", "eff", "foo"])
+
+class TestDTD(unittest.TestCase):
+    def setUp(self):
+        self.tmp = mkdtemp()
+        os.mkdir(os.path.join(self.tmp, "merge"))
+        self.ref = os.path.join(self.tmp, "en-reference.dtd")
+        open(self.ref, "w").write("""<!ENTITY foo 'fooVal'>
+<!ENTITY bar 'barVal'>
+<!ENTITY eff 'effVal'>""")
+    def tearDown(self):
+        shutil.rmtree(self.tmp)
+        del self.tmp
+    def testGood(self):
+        self.assertTrue(os.path.isdir(self.tmp))
+        l10n = os.path.join(self.tmp, "l10n.dtd")
+        open(l10n, "w").write("""<!ENTITY foo 'lFoo'>
+<!ENTITY bar 'lBar'>
+<!ENTITY eff 'lEff'>
+""")
+        obs = Observer()
+        cc = ContentComparer(obs)
+        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.compare(File(self.ref, "en-reference.dtd", ""),
+                   File(l10n, "l10n.dtd", ""))
+        print obs.serialize()
+    def testMissing(self):
+        self.assertTrue(os.path.isdir(self.tmp))
+        l10n = os.path.join(self.tmp, "l10n.dtd")
+        open(l10n, "w").write("""<!ENTITY bar 'lBar'>
+""")
+        obs = Observer()
+        cc = ContentComparer(obs)
+        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.compare(File(self.ref, "en-reference.dtd", ""),
+                   File(l10n, "l10n.dtd", ""))
+        print obs.serialize()
+        mergefile = os.path.join(self.tmp, "merge", "l10n.dtd")
+        self.assertTrue(os.path.isfile(mergefile))
+        p = getParser(mergefile)
+        p.readFile(mergefile)
+        [m,n] = p.parse()
+        self.assertEqual(map(lambda e:e.key,m), ["bar", "eff", "foo"])
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/lib/Mozilla/tests/unitProperties.py b/lib/Mozilla/tests/unitProperties.py
new file mode 100644 (file)
index 0000000..0f712db
--- /dev/null
@@ -0,0 +1,235 @@
+# -*- coding: utf-8 -*-
+
+import unittest
+
+from Mozilla.Parser import getParser
+
+class TestLineWraps(unittest.TestCase):
+
+  def setUp(self):
+    self.p = getParser('foo.properties')
+
+  def tearDown(self):
+    del self.p
+
+  def testBackslashes(self):
+    self.p.readContents(r'''one_line = This is one line
+two_line = This is the first \
+of two lines
+one_line_trailing = This line ends in \\
+and has junk
+two_lines_triple = This line is one of two and ends in \\\
+and still has another line coming
+''')
+    ref = ['This is one line',
+           u'This is the first of two lines',
+           u'This line ends in \\']
+    i = iter(self.p)
+    for r, e in zip(ref, i):
+      self.assertEqual(e.val, r)
+    e = i.next()
+    self.assertEqual(e.key, '_junk_1_113-126')
+    for r, e in zip(('This line is one of two and ends in \\and still has another line coming',), i):
+      self.assertEqual(e.val, r)
+  
+  def testProperties(self):
+    # port of netwerk/test/PropertiesTest.cpp
+    self.p.readContents(r'''# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is mozilla.org code.
+#
+# The Initial Developer of the Original Code is
+# Netscape Communications Corporation.
+# Portions created by the Initial Developer are Copyright (C) 1998
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either of the GNU General Public License Version 2 or later (the "GPL"),
+# or the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+1=1
+ 2=2
+3 =3
+ 4 =4
+5=5
+6= 6
+7=7 
+8= 8 
+# this is a comment
+9=this is the first part of a continued line \
+ and here is the 2nd part
+'''.encode('utf-8'))
+    ref = ['1', '2', '3', '4', '5', '6', '7', '8', 'this is the first part of a continued line and here is the 2nd part']
+    i = iter(self.p)
+    for r, e in zip(ref, i):
+      self.assertEqual(e.val, r)
+
+  def test_bug121341(self):
+    # port of xpcom/tests/unit/test_bug121341.js
+    self.p.readContents(r'''# simple check
+1=abc
+# test whitespace trimming in key and value
+  2    =   xy  
+# test parsing of escaped values
+3 = \u1234\t\r\n\uAB\
+\u1\n
+# test multiline properties
+4 = this is \
+multiline property
+5 = this is \
+          another multiline property
+# property with DOS EOL\r
+6 = test\u0036\r
+# test multiline property with with DOS EOL
+7 = yet another multi\\r
+    line propery\r
+# trimming should not trim escaped whitespaces
+8 =    \ttest5\u0020   
+# another variant of #8
+9 =     \ test6\t          
+# test UTF-8 encoded property/value
+10aሴb = cì·¯d
+# next property should test unicode escaping at the boundary of parsing buffer
+# buffer size is expected to be 4096 so add comments to get to this offset
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+################################################################################
+###############################################################################
+11 = \uABCD
+'''
+      .replace('\r\n', '\n').replace('\r', '\n')  # fake universal line endings
+      )
+    ref = ['abc', 'xy', u"\u1234\t\r\n\u00AB\u0001\n","this is multiline property",
+           "this is another multiline property", u"test\u0036",
+           "yet another multiline propery", u"\ttest5\u0020", " test6\t",
+           u"c\uCDEFd", u"\uABCD"]
+    i = iter(self.p)
+    for r, e in zip(ref, i):
+      self.assertEqual(e.val, r)
+
+  def test_commentInMulti(self):
+    self.p.readContents(r'''bar=one line with a \
+# part that looks like a comment \
+and an end''')
+    entities = list(self.p)
+    self.assertEqual(len(entities), 1)
+    self.assertEqual(entities[0].val, 'one line with a # part that looks like a comment and an end')
+
+  def testLicenseHeader(self):
+    p = getParser('foo.properties')
+    p.readContents('''# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is mozilla.org code.
+#
+# The Initial Developer of the Original Code is
+# International Business Machines Corporation.
+# Portions created by the Initial Developer are Copyright (C) 2000
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+foo=value
+''')
+    for e in p:
+       self.assertEqual(e.key, 'foo')
+       self.assertEqual(e.val, 'value')
+    self.assert_('MPL' in p.header)
+    p.readContents('''# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this file,
+# You can obtain one at http://mozilla.org/MPL/2.0/.
+
+foo=value
+''')
+    for e in p:
+        self.assertEqual(e.key, 'foo')
+        self.assertEqual(e.val, 'value')
+    self.assert_('MPL' in p.header)
+
+
+if __name__ == '__main__':
+  unittest.main()
diff --git a/scripts/compare-dirs b/scripts/compare-dirs
new file mode 100755 (executable)
index 0000000..35689d1
--- /dev/null
@@ -0,0 +1,76 @@
+#! python
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+
+import logging
+from optparse import OptionParser
+import codecs
+
+from Mozilla.CompareLocales import compareDirs
+
+usage = 'usage: %prog [options] reference locale'
+parser = OptionParser(usage=usage)
+
+parser.add_option('-v', '--verbose', action='count', dest='v', default=0,
+                  help='Make more noise')
+parser.add_option('-q', '--quiet', action='count', dest='q', default=0,
+                  help='Make more noise')
+parser.add_option('-m', '--merge',
+                  help='Use this directory to stage merged files')
+
+(options, args) = parser.parse_args()
+if len(args) != 2:
+  parser.error('Reference and language required')
+
+# log as verbose or quiet as we want, warn by default
+logging.basicConfig()
+logging.getLogger().setLevel(logging.WARNING - (options.v - options.q)*10)
+
+reference, locale = args
+o = compareDirs(reference, locale, merge_stage = options.merge)
+print codecs.utf_8_encode(o.serialize())[0]
+
+if not options.merge:
+  # if not merging, error on really missing strings
+  totalMissing = 0
+  for k in ('missing', 'missingInFiles'):
+    for summary in o.summary.values():
+      if k in summary:
+        totalMissing = summary[k]
+  if totalMissing:
+    parser.exit(1)
diff --git a/scripts/compare-locales b/scripts/compare-locales
new file mode 100755 (executable)
index 0000000..7f8b4ec
--- /dev/null
@@ -0,0 +1,100 @@
+#! /usr/bin/python
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2006
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+
+import logging
+from optparse import OptionParser
+import codecs
+
+from Mozilla.Paths import EnumerateApp
+from Mozilla.CompareLocales import compareApp
+from Mozilla import version
+
+usage = 'usage: %prog [options] l10n.ini l10n_base_dir language1 [language2 ...]'
+parser = OptionParser(usage = usage, version = "%%prog %s" % version)
+
+parser.add_option('-v', '--verbose', action='count', dest='v', default=0,
+                  help='Make more noise')
+parser.add_option('-q', '--quiet', action='count', dest='q', default=0,
+                  help='Make more noise')
+parser.add_option('-r', '--reference', default='en-US', dest='reference',
+                  help='Explicitly set the reference '+
+                  'localization. [default: en-US]')
+parser.add_option('-m', '--merge',
+                  help='Use this directory to stage merged files')
+parser.add_option('--clobber-merge', action="store_true", default=False, dest='clobber',
+                  help="""WARNING: DATALOSS.
+Use this option with care. If specified, the merge directory will
+be clobbered for each module. That means, the subdirectory will
+be completely removed, any files that were there are lost.
+Be careful to specify the right merge directory when using this option.""")
+parser.add_option('--json', action='store_true', dest='json',
+                  help='Dump just summary as exhibit JSON')
+
+(options, args) = parser.parse_args()
+if len(args) < 3:
+  parser.error('At least one language required')
+inipath, l10nbase = args[:2]
+locales = args[2:]
+
+# log as verbose or quiet as we want, warn by default
+logging.basicConfig()
+logging.getLogger().setLevel(logging.WARNING - (options.v - options.q)*10)
+
+app = EnumerateApp(inipath, l10nbase, locales)
+app.reference = options.reference
+
+try:
+  o = compareApp(app, merge_stage = options.merge, clobber = options.clobber)
+except (OSError, IOError), e:
+  print "FAIL: " + str(e)
+  parser.exit(2)
+so = {}
+if options.json:
+  so['type']='application/json'
+print codecs.utf_8_encode(o.serialize(**so))[0]
+
+if not options.merge:
+  # if not merging, error on really missing strings
+  totalMissing = 0
+  for k in ('missing', 'missingInFiles'):
+    for summary in o.summary.values():
+      if k in summary:
+        totalMissing = summary[k]
+  if totalMissing:
+    parser.exit(1)
diff --git a/scripts/compare-packs b/scripts/compare-packs
new file mode 100755 (executable)
index 0000000..04e3715
--- /dev/null
@@ -0,0 +1,69 @@
+#! python
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2007
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+
+import logging
+import os.path
+import re
+from optparse import OptionParser
+from pprint   import pprint
+from zipfile  import ZipFile
+
+from Mozilla.Jars import compareJars
+
+usage = 'usage: %prog [options] language-pack reference-pack'
+parser = OptionParser(usage=usage)
+
+parser.add_option('-v', '--verbose', action='count', dest='v', default=0,
+                  help='Report more detail')
+parser.add_option('-q', '--quiet', action='count', dest='q', default=0,
+                  help='Report less detail')
+
+(options, args) = parser.parse_args()
+if len(args) != 2:
+  parser.error('language pack and reference pack required')
+
+# log as verbose or quiet as we want, warn by default
+logging.basicConfig(level=(logging.WARNING - (options.v - options.q)*10))
+
+# we expect two jar files
+if not (args[0].endswith('.jar') and args[1].endswith('.jar')):
+  parser.error('Please specify two jar files to compare')
+
+o = compareJars(*args)
+print o.serialize()
diff --git a/scripts/enum-checks b/scripts/enum-checks
new file mode 100755 (executable)
index 0000000..e044f63
--- /dev/null
@@ -0,0 +1,90 @@
+#! /usr/bin/python
+# ***** BEGIN LICENSE BLOCK *****
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1
+#
+# The contents of this file are subject to the Mozilla Public License Version
+# 1.1 (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+# http://www.mozilla.org/MPL/
+#
+# Software distributed under the License is distributed on an "AS IS" basis,
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
+# for the specific language governing rights and limitations under the
+# License.
+#
+# The Original Code is l10n test automation.
+#
+# The Initial Developer of the Original Code is
+# Mozilla Foundation
+# Portions created by the Initial Developer are Copyright (C) 2011
+# the Initial Developer. All Rights Reserved.
+#
+# Contributor(s):
+#      Axel Hecht <l10n@mozilla.com>
+#
+# Alternatively, the contents of this file may be used under the terms of
+# either the GNU General Public License Version 2 or later (the "GPL"), or
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
+# in which case the provisions of the GPL or the LGPL are applicable instead
+# of those above. If you wish to allow use of your version of this file only
+# under the terms of either the GPL or the LGPL, and not to allow others to
+# use your version of this file under the terms of the MPL, indicate your
+# decision by deleting the provisions above and replace them with the notice
+# and other provisions required by the GPL or the LGPL. If you do not delete
+# the provisions above, a recipient may use your version of this file under
+# the terms of any one of the MPL, the GPL or the LGPL.
+#
+# ***** END LICENSE BLOCK *****
+
+
+from optparse import OptionParser
+import os.path
+import codecs
+
+from Mozilla.Paths import EnumerateApp, EnumerateSourceTreeApp
+from Mozilla.Checks import getChecks
+from Mozilla.Parser import getParser, Entity
+from Mozilla import version
+
+usage = 'usage: %prog [options] l10n.ini'
+parser = OptionParser(usage = usage, version = "%%prog %s" % version)
+parser.add_option('--source-tree',
+                  dest='source_tree',
+                  help='''Use independent source repos like on hg.m.o''')
+
+(options, inis) = parser.parse_args()
+if len(inis) < 1:
+  parser.error('At least one l10n.ini required')
+
+emptyEnt = Entity('', lambda v: v,
+                  (0, 0), (0, 0), (0, 0), (0, 0), (0, 0), (0, 0), (0, 0))
+
+covered = set()
+for inipath in inis:
+  if options.source_tree:
+    app = EnumerateSourceTreeApp(inipath, options.source_tree, '.', [])
+  else:
+    app = EnumerateApp(inipath, '.', [])
+  for module, reference, locales in app:
+    if module in covered:
+      continue
+    covered.add(module)
+    print module
+    for ref_file in reference:
+      #print ref_file.module
+      checks = getChecks(ref_file)
+      if not checks:
+        continue
+      try:
+        p = getParser(ref_file.file)
+      except UserWarning:
+        # no comparison, XXX report?
+        continue
+      try:
+        p.readContents(ref_file.getContents())
+      except Exception, e:
+        print 'ERROR', ref_file, str(e)
+        continue
+      for refent in p:
+        for tp, pos, msg, cat in checks(refent, emptyEnt):
+          print codecs.utf_8_encode('%s\t%s\t%s\t%s\t%s\t%s' % (cat, tp, ref_file.module, ref_file.file, refent.key, refent.val))[0]
diff --git a/scripts/verify-rss-redirects b/scripts/verify-rss-redirects
new file mode 100755 (executable)
index 0000000..f01260a
--- /dev/null
@@ -0,0 +1,105 @@
+#!python\r
+# ***** BEGIN LICENSE BLOCK *****\r
+# Version: MPL 1.1/GPL 2.0/LGPL 2.1\r
+#\r
+# The contents of this file are subject to the Mozilla Public License Version\r
+# 1.1 (the "License"); you may not use this file except in compliance with\r
+# the License. You may obtain a copy of the License at\r
+# http://www.mozilla.org/MPL/\r
+#\r
+# Software distributed under the License is distributed on an "AS IS" basis,\r
+# WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License\r
+# for the specific language governing rights and limitations under the\r
+# License.\r
+#\r
+# The Original Code is l10n test automation.\r
+#\r
+# The Initial Developer of the Original Code is\r
+# Mozilla Foundation\r
+# Portions created by the Initial Developer are Copyright (C) 2006\r
+# the Initial Developer. All Rights Reserved.\r
+#\r
+# Contributor(s):\r
+#      Axel Hecht <l10n@mozilla.com>\r
+#\r
+# Alternatively, the contents of this file may be used under the terms of\r
+# either the GNU General Public License Version 2 or later (the "GPL"), or\r
+# the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),\r
+# in which case the provisions of the GPL or the LGPL are applicable instead\r
+# of those above. If you wish to allow use of your version of this file only\r
+# under the terms of either the GPL or the LGPL, and not to allow others to\r
+# use your version of this file under the terms of the MPL, indicate your\r
+# decision by deleting the provisions above and replace them with the notice\r
+# and other provisions required by the GPL or the LGPL. If you do not delete\r
+# the provisions above, a recipient may use your version of this file under\r
+# the terms of any one of the MPL, the GPL or the LGPL.\r
+#\r
+# ***** END LICENSE BLOCK *****\r
+\r
+import sys\r
+import logging\r
+from optparse import OptionParser\r
+from urlparse import urlparse\r
+import httplib\r
+from urllib2 import urlopen\r
+\r
+EN_US_FEED = 'http://newsrss.bbc.co.uk/rss/newsonline_world_edition/front_page/rss.xml'\r
+FEEDSERVER = '%s.fxfeeds.mozilla.com'\r
+FEEDPATH   = '/%s/firefox/headlines.xml'\r
+\r
+L10NFEEDS = 'https://wiki.mozilla.org/index.php?title=Firefox/L10n_Feed_Redirects&action=raw'\r
+\r
+op = OptionParser()\r
+op.add_option('--oldid', dest='oldid',\r
+              help='explicitly give a version number on the wiki')\r
+op.add_option('-v', dest='verbose', action='count', default=0,\r
+              help='increase verbosity')\r
+(options, args) = op.parse_args()\r
+\r
+if len(args) != 1:\r
+  sys.exit('all-locales or shipped-locales path expected')\r
+\r
+logging.basicConfig(level=(logging.WARNING - options.verbose*10))\r
+\r
+if options.oldid:\r
+  L10NFEEDS += '&oldid=' + options.oldid\r
+\r
+lFeeds = urlopen(L10NFEEDS)\r
+redirs = {}\r
+\r
+for row in lFeeds:\r
+  d, loc, url = row.split(' ', 2)\r
+  redirs[loc] = url.strip()\r
+\r
+# parse all-locales and shipped-locales, only take first bunch\r
+lFile = open(args[0])\r
+locales = [ln.split(' ', 1)[0].strip() for ln in lFile]\r
+# ignore ja-JP-mac, same bookmarks as ja\r
+def not_ja_JP_mac(loc): return loc != 'ja-JP-mac'\r
+locales = filter(not_ja_JP_mac, locales)\r
+\r
+for loc in locales:\r
+  logging.debug('testing ' + loc)\r
+  server = FEEDSERVER % loc\r
+  path   = FEEDPATH % loc\r
+  while server.endswith('mozilla.com'):\r
+    url = None\r
+    conn = httplib.HTTPConnection(server)\r
+    conn.request('HEAD', path)\r
+    r = conn.getresponse()\r
+    if r.status != 302:\r
+      logging.error('mozilla.com loses feed for ' + loc)\r
+      server = ''\r
+      continue\r
+    url = r.getheader('location')\r
+    server, path = urlparse(url)[1:3]\r
+    conn.close()\r
+  refurl = EN_US_FEED\r
+  if redirs.has_key(loc):\r
+    refurl = redirs[loc]\r
+  if url != refurl:\r
+    print loc, "FAIL"\r
+    logging.info(str(url) + ' is not ' + refurl)\r
+  else:\r
+    logging.debug(loc + ' PASS')\r
+\r
diff --git a/setup.py b/setup.py
new file mode 100755 (executable)
index 0000000..c3524ad
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,50 @@
+"""Python library and scripts to assist in localizing Mozilla applications
+
+Localization of XUL applications in general and Mozilla applications in
+particular are done by a number of different file formats. Independent
+of the format, the Mozilla architecture does not provide fallback strings
+at runtime. This library and the calling scripts provide a way to check
+a given localization for completeness. For more information see
+https://developer.mozilla.org/en/docs/Compare-locales
+"""
+
+docstrings = __doc__.split("\n")
+
+try:
+  from setuptools import setup
+except ImportError:
+  from distutils.core import setup
+
+import sys
+import os.path
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'lib'))
+
+from Mozilla import version
+
+classifiers = """\
+Development Status :: 4 - Beta
+Intended Audience :: Developers
+License :: OSI Approved :: GNU General Public License (GPL)
+License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)
+License :: OSI Approved :: Mozilla Public License 1.1 (MPL 1.1)
+Operating System :: OS Independent
+Programming Language :: Python
+Topic :: Software Development :: Libraries :: Python Modules
+Topic :: Software Development :: Localization
+Topic :: Software Development :: Testing
+"""
+
+setup(name="compare-locales",
+      version=version,
+      author="Axel Hecht",
+      author_email="axel@mozilla.com",
+      description=docstrings[0],
+      long_description="\n".join(docstrings[2:]),
+      license="MPL 1.1/GPL 2.0/LGPL 2.1",
+      classifiers=filter(None, classifiers.split("\n")),
+      platforms=["any"],
+      scripts=['scripts/compare-locales',
+               'scripts/compare-dirs'],
+      package_dir={'': 'lib'},
+      packages=['Mozilla'],
+      )
diff --git a/util/dump_property.js b/util/dump_property.js
new file mode 100644 (file)
index 0000000..1cc8427
--- /dev/null
@@ -0,0 +1,29 @@
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cr = Components.results;
+
+function run_test()
+{
+    var s = Cc["@mozilla.org/io/string-input-stream;1"]
+              .createInstance(Ci.nsIStringInputStream);
+    var body =
+"foo:bar\\u0020\\u61\\u\n\
+# this is a comment\n\
+9=this is the first part of a continued line \\\n\
+ and here is the 2nd part\n\
+mark=this is a string \\\n\
+# possibly a comment \\\n\
+and trail\n\
+foz=baz\n";
+    s.setData(body, body.length);
+    var props = Cc["@mozilla.org/persistent-properties;1"]
+                  .createInstance(Ci.nsIPersistentProperties);
+    props.load(s);
+    var p_enum = props.enumerate();
+    while (p_enum.hasMoreElements()) {
+      var p = p_enum.getNext().QueryInterface(Ci.nsIPropertyElement);
+      print("key: " + p.key);
+      print(p.value);
+    }
+}
+run_test()
\ No newline at end of file