bug 1361037, part 0: convert from os.path to mozpath for consistent seperators in paths, use plain IO, r=flod, stas
authorAxel Hecht <axel@pike.org>
Tue, 09 May 2017 14:09:10 +0200
changeset 231 62b1337ce244a8da728fe1a18d89b9dfda937a08
parent 221 fdca6dbd1a5f0b3d763afd04547a9c6d5842b47d
child 232 a6ac3254f7dccb89ebc695b553f3a64271df0c30
push id48
push useraxel@mozilla.com
push dateFri, 26 May 2017 11:10:47 +0000
reviewersflod, stas
bugs1361037
bug 1361037, part 0: convert from os.path to mozpath for consistent seperators in paths, use plain IO, r=flod, stas Use plain file io instead of urllib/urllib2. We might abstract IO out and support more, including VCS stores. This uses a copy of mozbuild.mozpack.path, which is under MPL2. MozReview-Commit-ID: 518gPBVpRGY
compare_locales/compare.py
compare_locales/mozpath.py
compare_locales/paths.py
compare_locales/tests/test_apps.py
compare_locales/tests/test_merge.py
compare_locales/tests/test_mozpath.py
--- a/compare_locales/compare.py
+++ b/compare_locales/compare.py
@@ -1,29 +1,28 @@
 # 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/.
 
 'Mozilla l10n compare locales tool'
 
 import codecs
 import os
-import os.path
 import shutil
 import re
 from difflib import SequenceMatcher
 from collections import defaultdict
 
 try:
     from json import dumps
 except:
     from simplejson import dumps
 
 from compare_locales import parser
-from compare_locales import paths
+from compare_locales import paths, mozpath
 from compare_locales.checks import getChecker
 
 
 class Tree(object):
     def __init__(self, valuetype):
         self.branches = dict()
         self.valuetype = valuetype
         self.value = None
@@ -378,19 +377,19 @@ class ContentComparer:
         '''
         self.other_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, ctx, canMerge, encoding):
-        outfile = os.path.join(self.merge_stage, l10n_file.module,
+        outfile = mozpath.join(self.merge_stage, l10n_file.module,
                                l10n_file.file)
-        outdir = os.path.dirname(outfile)
+        outdir = mozpath.dirname(outfile)
         if not os.path.isdir(outdir):
             os.makedirs(outdir)
         if not canMerge:
             shutil.copyfile(ref_file.fullpath, outfile)
             print "copied reference to " + outfile
             return
         if skips:
             # skips come in ordered by key name, we need them in file order
@@ -597,14 +596,14 @@ def compareApp(app, other_observer=None,
         dir_comp = DirectoryCompare(reference)
         dir_comp.setWatcher(comparer)
         for _, localization in locales:
             if merge_stage is not None:
                 locale_merge = merge_stage.format(ab_CD=localization.locale)
                 comparer.set_merge_stage(locale_merge)
                 if clobber:
                     # if clobber, remove the stage for the module if it exists
-                    clobberdir = os.path.join(locale_merge, module)
+                    clobberdir = mozpath.join(locale_merge, module)
                     if os.path.exists(clobberdir):
                         shutil.rmtree(clobberdir)
                         print "clobbered " + clobberdir
             dir_comp.compareWith(localization)
     return comparer.observer
new file mode 100644
--- /dev/null
+++ b/compare_locales/mozpath.py
@@ -0,0 +1,137 @@
+# 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/.
+
+from __future__ import absolute_import
+
+import posixpath
+import os
+import re
+
+'''
+Like os.path, with a reduced set of functions, and with normalized path
+separators (always use forward slashes).
+Also contains a few additional utilities not found in os.path.
+'''
+
+
+def normsep(path):
+    '''
+    Normalize path separators, by using forward slashes instead of whatever
+    os.sep is.
+    '''
+    if os.sep != '/':
+        path = path.replace(os.sep, '/')
+    if os.altsep and os.altsep != '/':
+        path = path.replace(os.altsep, '/')
+    return path
+
+
+def relpath(path, start):
+    rel = normsep(os.path.relpath(path, start))
+    return '' if rel == '.' else rel
+
+
+def realpath(path):
+    return normsep(os.path.realpath(path))
+
+
+def abspath(path):
+    return normsep(os.path.abspath(path))
+
+
+def join(*paths):
+    return normsep(os.path.join(*paths))
+
+
+def normpath(path):
+    return posixpath.normpath(normsep(path))
+
+
+def dirname(path):
+    return posixpath.dirname(normsep(path))
+
+
+def commonprefix(paths):
+    return posixpath.commonprefix([normsep(path) for path in paths])
+
+
+def basename(path):
+    return os.path.basename(path)
+
+
+def splitext(path):
+    return posixpath.splitext(normsep(path))
+
+
+def split(path):
+    '''
+    Return the normalized path as a list of its components.
+        split('foo/bar/baz') returns ['foo', 'bar', 'baz']
+    '''
+    return normsep(path).split('/')
+
+
+def basedir(path, bases):
+    '''
+    Given a list of directories (bases), return which one contains the given
+    path. If several matches are found, the deepest base directory is returned.
+        basedir('foo/bar/baz', ['foo', 'baz', 'foo/bar']) returns 'foo/bar'
+        ('foo' and 'foo/bar' both match, but 'foo/bar' is the deepest match)
+    '''
+    path = normsep(path)
+    bases = [normsep(b) for b in bases]
+    if path in bases:
+        return path
+    for b in sorted(bases, reverse=True):
+        if b == '' or path.startswith(b + '/'):
+            return b
+
+
+re_cache = {}
+
+
+def match(path, pattern):
+    '''
+    Return whether the given path matches the given pattern.
+    An asterisk can be used to match any string, including the null string, in
+    one part of the path:
+        'foo' matches '*', 'f*' or 'fo*o'
+    However, an asterisk matching a subdirectory may not match the null string:
+        'foo/bar' does *not* match 'foo/*/bar'
+    If the pattern matches one of the ancestor directories of the path, the
+    patch is considered matching:
+        'foo/bar' matches 'foo'
+    Two adjacent asterisks can be used to match files and zero or more
+    directories and subdirectories.
+        'foo/bar' matches 'foo/**/bar', or '**/bar'
+    '''
+    if not pattern:
+        return True
+    if pattern not in re_cache:
+        p = re.escape(pattern)
+        p = re.sub(r'(^|\\\/)\\\*\\\*\\\/', r'\1(?:.+/)?', p)
+        p = re.sub(r'(^|\\\/)\\\*\\\*$', r'(?:\1.+)?', p)
+        p = p.replace(r'\*', '[^/]*') + '(?:/.*)?$'
+        re_cache[pattern] = re.compile(p)
+    return re_cache[pattern].match(path) is not None
+
+
+def rebase(oldbase, base, relativepath):
+    '''
+    Return relativepath relative to base instead of oldbase.
+    '''
+    if base == oldbase:
+        return relativepath
+    if len(base) < len(oldbase):
+        assert basedir(oldbase, [base]) == base
+        relbase = relpath(oldbase, base)
+        result = join(relbase, relativepath)
+    else:
+        assert basedir(base, [oldbase]) == oldbase
+        relbase = relpath(base, oldbase)
+        result = relpath(relativepath, relbase)
+    result = normpath(result)
+    if relativepath.endswith('/') and not result.endswith('/'):
+        result += '/'
+    return result
--- a/compare_locales/paths.py
+++ b/compare_locales/paths.py
@@ -1,40 +1,32 @@
 # 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/.
 
-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 collections import defaultdict
-from compare_locales import util
+from compare_locales import util, mozpath
 
 
 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)
+        self.inipath = mozpath.normpath(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
 
@@ -48,20 +40,20 @@ class L10nConfigParser(object):
         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')
+        filter_path = mozpath.join(mozpath.dirname(self.inipath), 'filter.py')
         try:
             l = {}
-            execfile(url2pathname(urlparse(filterurl).path), {}, l)
+            execfile(filter_path, {}, l)
             if 'test' in l and callable(l['test']):
                 filters = [l['test']]
             else:
                 filters = []
         except:
             filters = []
 
         for c in self.children:
@@ -71,24 +63,20 @@ class L10nConfigParser(object):
 
     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)
+        cp.read(self.inipath)
         depth = self.getDepth(cp)
-        self.baseurl = urljoin(self.inipath, depth)
+        self.base = mozpath.join(mozpath.dirname(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)
@@ -96,87 +84,83 @@ class L10nConfigParser(object):
             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 to set "all_path" and "all_url"
         try:
-            self.all_path = cp.get('general', 'all')
-            self.all_url = urljoin(self.baseurl, self.all_path)
+            self.all_path = mozpath.join(self.base, cp.get('general', 'all'))
         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 = L10nConfigParser(mozpath.join(self.base, path), **self.defaults)
         cp.loadConfigs()
         self.children.append(cp)
 
     def dirsIter(self):
         """Iterate over all dirs and our base path for this l10n.ini"""
-        url = urlparse(self.baseurl)
-        basepath = url2pathname(url.path)
         for dir in self.dirs:
-            yield dir, (basepath, dir)
+            yield dir, (self.base, 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 util.parseLocales(urlopen(self.all_url).read())
+        return util.parseLocales(open(self.all_path).read())
 
 
 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, redirects):
+    def __init__(self, inipath, base, redirects):
         '''Add additional arguments basepath.
 
         basepath is used to resolve local paths via branchnames.
         redirects is used in unified repository, mapping upstream
         repos to local clones.
         '''
         L10nConfigParser.__init__(self, inipath)
-        self.basepath = basepath
+        self.base = base
         self.redirects = redirects
 
     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')
             branch = self.redirects.get(branch, branch)
             inipath = orig_cp.get(details, 'l10n.ini')
-            path = self.basepath + '/' + branch + '/' + inipath
+            path = mozpath.join(self.base, branch, inipath)
         else:
-            path = urljoin(self.baseurl, path)
-        cp = SourceTreeConfigParser(path, self.basepath, self.redirects,
+            path = mozpath.join(self.base, path)
+        cp = SourceTreeConfigParser(path, self.base, self.redirects,
                                     **self.defaults)
         cp.loadConfigs()
         self.children.append(cp)
 
 
 class File(object):
 
     def __init__(self, fullpath, file, module=None, locale=None):
@@ -217,37 +201,37 @@ class EnumerateDir(object):
         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,
+        return File(mozpath.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)
+            fulldir = mozpath.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)
+                leaf = mozpath.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)
+                        leaf not in [mozpath.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):
@@ -255,30 +239,29 @@ 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)
+            path = mozpath.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.l10nbase = mozpath.abspath(l10nbase)
         self.filters = []
-        drive, tail = os.path.splitdrive(inipath)
         self.addFilters(*self.config.getFilters())
         self.locales = locales or self.config.allLocales()
         self.locales.sort()
 
     def setupConfigParser(self, inipath):
         self.config = L10nConfigParser(inipath)
         self.config.loadConfigs()
 
@@ -317,19 +300,19 @@ class EnumerateApp(object):
         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')))
+                base = mozpath.join(*(dirmap[mod] + ('locales', 'en-US')))
             else:
-                base = os.path.join(self.l10nbase, self.reference, mod)
+                base = mozpath.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
@@ -341,21 +324,8 @@ class EnumerateSourceTreeApp(EnumerateAp
         self.basepath = basepath
         self.redirects = redirects
         EnumerateApp.__init__(self, inipath, l10nbase, locales)
 
     def setupConfigParser(self, inipath):
         self.config = SourceTreeConfigParser(inipath, self.basepath,
                                              self.redirects)
         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
new file mode 100644
--- /dev/null
+++ b/compare_locales/tests/test_apps.py
@@ -0,0 +1,76 @@
+import unittest
+import os
+import tempfile
+import shutil
+
+from compare_locales import mozpath
+from compare_locales.paths import EnumerateApp
+
+MAIL_INI = '''\
+[general]
+depth = ../..
+all = mail/locales/all-locales
+
+[compare]
+dirs = mail
+
+[includes]
+# non-central apps might want to use %(topsrcdir)s here, or other vars
+# RFE: that needs to be supported by compare-locales, too, though
+toolkit = mozilla/toolkit/locales/l10n.ini
+
+[include_toolkit]
+type = hg
+mozilla = mozilla-central
+repo = http://hg.mozilla.org/
+l10n.ini = toolkit/locales/l10n.ini
+'''
+
+
+MAIL_ALL_LOCALES = '''af
+de
+fr
+'''
+
+MAIL_FILTER_PY = '''
+def test(mod, path, entity = None):
+    if mod == 'toolkit' and path == 'ignored_path':
+        return 'ignore'
+    return 'error'
+'''
+
+TOOLKIT_INI = '''[general]
+depth = ../..
+
+[compare]
+dirs = toolkit
+'''
+
+
+class TestApp(unittest.TestCase):
+    def setUp(self):
+        self.stage = tempfile.mkdtemp()
+        mail = mozpath.join(self.stage, 'comm', 'mail', 'locales')
+        toolkit = mozpath.join(
+            self.stage, 'comm', 'mozilla', 'toolkit', 'locales')
+        os.makedirs(mail)
+        os.makedirs(toolkit)
+        with open(mozpath.join(mail, 'l10n.ini'), 'w') as f:
+            f.write(MAIL_INI)
+        with open(mozpath.join(mail, 'all-locales'), 'w') as f:
+            f.write(MAIL_ALL_LOCALES)
+        with open(mozpath.join(mail, 'filter.py'), 'w') as f:
+            f.write(MAIL_FILTER_PY)
+        with open(mozpath.join(toolkit, 'l10n.ini'), 'w') as f:
+            f.write(TOOLKIT_INI)
+
+    def tearDown(self):
+        shutil.rmtree(self.stage)
+
+    def test_app(self):
+        'Test parsing a App'
+        app = EnumerateApp(
+            mozpath.join(self.stage, 'comm', 'mail', 'locales', 'l10n.ini'),
+            mozpath.join(self.stage, 'l10n-central'))
+        self.assertListEqual(app.locales, ['af', 'de', 'fr'])
+        self.assertEqual(len(app.config.children), 1)
--- a/compare_locales/tests/test_merge.py
+++ b/compare_locales/tests/test_merge.py
@@ -5,110 +5,111 @@
 import unittest
 import os
 from tempfile import mkdtemp
 import shutil
 
 from compare_locales.parser import getParser
 from compare_locales.paths import File
 from compare_locales.compare import ContentComparer
+from compare_locales import mozpath
 
 
 class ContentMixin(object):
     extension = None  # OVERLOAD
 
     def reference(self, content):
-        self.ref = os.path.join(self.tmp, "en-reference" + self.extension)
+        self.ref = mozpath.join(self.tmp, "en-reference" + self.extension)
         open(self.ref, "w").write(content)
 
     def localized(self, content):
-        self.l10n = os.path.join(self.tmp, "l10n" + self.extension)
+        self.l10n = mozpath.join(self.tmp, "l10n" + self.extension)
         open(self.l10n, "w").write(content)
 
 
 class TestProperties(unittest.TestCase, ContentMixin):
     extension = '.properties'
 
     def setUp(self):
         self.maxDiff = None
         self.tmp = mkdtemp()
-        os.mkdir(os.path.join(self.tmp, "merge"))
+        os.mkdir(mozpath.join(self.tmp, "merge"))
 
     def tearDown(self):
         shutil.rmtree(self.tmp)
         del self.tmp
 
     def testGood(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""foo = fooVal
 bar = barVal
 eff = effVal""")
         self.localized("""foo = lFoo
 bar = lBar
 eff = lEff
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.properties", ""),
                    File(self.l10n, "l10n.properties", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 3
                 }},
              'details': {}
              }
         )
-        self.assert_(not os.path.exists(os.path.join(cc.merge_stage,
+        self.assert_(not os.path.exists(mozpath.join(cc.merge_stage,
                                                      'l10n.properties')))
 
     def testMissing(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""foo = fooVal
 bar = barVal
 eff = effVal""")
         self.localized("""bar = lBar
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.properties", ""),
                    File(self.l10n, "l10n.properties", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 1, 'missing': 2
                 }},
              'details': {
                  'children': [
                      ('l10n.properties',
                          {'value': {'missingEntity': [u'eff', u'foo']}}
                       )
                  ]}
              }
         )
-        mergefile = os.path.join(self.tmp, "merge", "l10n.properties")
+        mergefile = mozpath.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"])
 
     def testError(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""foo = fooVal
 bar = %d barVal
 eff = effVal""")
         self.localized("""\
 bar = %S lBar
 eff = leffVal
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.properties", ""),
                    File(self.l10n, "l10n.properties", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 2, 'errors': 1, 'missing': 1
                 }},
@@ -118,34 +119,34 @@ eff = leffVal
                          {'value': {
                           'error': [u'argument 1 `S` should be `d` '
                                     u'at line 1, column 7 for bar'],
                           'missingEntity': [u'foo']}}
                       )
                  ]}
              }
         )
-        mergefile = os.path.join(self.tmp, "merge", "l10n.properties")
+        mergefile = mozpath.join(self.tmp, "merge", "l10n.properties")
         self.assertTrue(os.path.isfile(mergefile))
         p = getParser(mergefile)
         p.readFile(mergefile)
         [m, n] = p.parse()
         self.assertEqual([e.key for e in m], ["eff", "foo", "bar"])
         self.assertEqual(m[n['bar']].val, '%d barVal')
 
     def testObsolete(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""foo = fooVal
 eff = effVal""")
         self.localized("""foo = fooVal
 other = obsolete
 eff = leffVal
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.properties", ""),
                    File(self.l10n, "l10n.properties", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 1, 'obsolete': 1, 'unchanged': 1
                 }},
@@ -158,90 +159,90 @@ eff = leffVal
 
 
 class TestDTD(unittest.TestCase, ContentMixin):
     extension = '.dtd'
 
     def setUp(self):
         self.maxDiff = None
         self.tmp = mkdtemp()
-        os.mkdir(os.path.join(self.tmp, "merge"))
+        os.mkdir(mozpath.join(self.tmp, "merge"))
 
     def tearDown(self):
         shutil.rmtree(self.tmp)
         del self.tmp
 
     def testGood(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""<!ENTITY foo 'fooVal'>
 <!ENTITY bar 'barVal'>
 <!ENTITY eff 'effVal'>""")
         self.localized("""<!ENTITY foo 'lFoo'>
 <!ENTITY bar 'lBar'>
 <!ENTITY eff 'lEff'>
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.dtd", ""),
                    File(self.l10n, "l10n.dtd", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 3
                 }},
              'details': {}
              }
         )
         self.assert_(
-            not os.path.exists(os.path.join(cc.merge_stage, 'l10n.dtd')))
+            not os.path.exists(mozpath.join(cc.merge_stage, 'l10n.dtd')))
 
     def testMissing(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""<!ENTITY foo 'fooVal'>
 <!ENTITY bar 'barVal'>
 <!ENTITY eff 'effVal'>""")
         self.localized("""<!ENTITY bar 'lBar'>
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.dtd", ""),
                    File(self.l10n, "l10n.dtd", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'changed': 1, 'missing': 2
                 }},
              'details': {
                  'children': [
                      ('l10n.dtd',
                          {'value': {'missingEntity': [u'eff', u'foo']}}
                       )
                  ]}
              }
         )
-        mergefile = os.path.join(self.tmp, "merge", "l10n.dtd")
+        mergefile = mozpath.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"])
 
     def testJunk(self):
         self.assertTrue(os.path.isdir(self.tmp))
         self.reference("""<!ENTITY foo 'fooVal'>
 <!ENTITY bar 'barVal'>
 <!ENTITY eff 'effVal'>""")
         self.localized("""<!ENTITY foo 'fooVal'>
 <!ENTY bar 'gimmick'>
 <!ENTITY eff 'effVal'>
 """)
         cc = ContentComparer()
-        cc.set_merge_stage(os.path.join(self.tmp, "merge"))
+        cc.set_merge_stage(mozpath.join(self.tmp, "merge"))
         cc.compare(File(self.ref, "en-reference.dtd", ""),
                    File(self.l10n, "l10n.dtd", ""))
         self.assertDictEqual(
             cc.observer.toJSON(),
             {'summary':
                 {None: {
                     'errors': 1, 'missing': 1, 'unchanged': 2
                 }},
@@ -253,17 +254,17 @@ class TestDTD(unittest.TestCase, Content
                                        u'\'gimmick\'>" '
                                        u'from line 2 column 1 to '
                                        u'line 2 column 22'],
                              'missingEntity': [u'bar']}}
                       )
                  ]}
              }
         )
-        mergefile = os.path.join(self.tmp, "merge", "l10n.dtd")
+        mergefile = mozpath.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), ["foo", "eff", "bar"])
 
 
 if __name__ == '__main__':
new file mode 100644
--- /dev/null
+++ b/compare_locales/tests/test_mozpath.py
@@ -0,0 +1,138 @@
+# 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/.
+
+from compare_locales.mozpath import (
+    relpath,
+    join,
+    normpath,
+    dirname,
+    commonprefix,
+    basename,
+    split,
+    splitext,
+    basedir,
+    match,
+    rebase,
+)
+import unittest
+import os
+
+
+class TestPath(unittest.TestCase):
+    SEP = os.sep
+
+    def test_relpath(self):
+        self.assertEqual(relpath('foo', 'foo'), '')
+        self.assertEqual(relpath(self.SEP.join(('foo', 'bar')), 'foo/bar'), '')
+        self.assertEqual(relpath(self.SEP.join(('foo', 'bar')), 'foo'), 'bar')
+        self.assertEqual(relpath(self.SEP.join(('foo', 'bar', 'baz')), 'foo'),
+                         'bar/baz')
+        self.assertEqual(relpath(self.SEP.join(('foo', 'bar')), 'foo/bar/baz'),
+                         '..')
+        self.assertEqual(relpath(self.SEP.join(('foo', 'bar')), 'foo/baz'),
+                         '../bar')
+        self.assertEqual(relpath('foo/', 'foo'), '')
+        self.assertEqual(relpath('foo/bar/', 'foo'), 'bar')
+
+    def test_join(self):
+        self.assertEqual(join('foo', 'bar', 'baz'), 'foo/bar/baz')
+        self.assertEqual(join('foo', '', 'bar'), 'foo/bar')
+        self.assertEqual(join('', 'foo', 'bar'), 'foo/bar')
+        self.assertEqual(join('', 'foo', '/bar'), '/bar')
+
+    def test_normpath(self):
+        self.assertEqual(normpath(self.SEP.join(('foo', 'bar', 'baz',
+                                                 '..', 'qux'))), 'foo/bar/qux')
+
+    def test_dirname(self):
+        self.assertEqual(dirname('foo/bar/baz'), 'foo/bar')
+        self.assertEqual(dirname('foo/bar'), 'foo')
+        self.assertEqual(dirname('foo'), '')
+        self.assertEqual(dirname('foo/bar/'), 'foo/bar')
+
+    def test_commonprefix(self):
+        self.assertEqual(commonprefix([self.SEP.join(('foo', 'bar', 'baz')),
+                                       'foo/qux', 'foo/baz/qux']), 'foo/')
+        self.assertEqual(commonprefix([self.SEP.join(('foo', 'bar', 'baz')),
+                                       'foo/qux', 'baz/qux']), '')
+
+    def test_basename(self):
+        self.assertEqual(basename('foo/bar/baz'), 'baz')
+        self.assertEqual(basename('foo/bar'), 'bar')
+        self.assertEqual(basename('foo'), 'foo')
+        self.assertEqual(basename('foo/bar/'), '')
+
+    def test_split(self):
+        self.assertEqual(split(self.SEP.join(('foo', 'bar', 'baz'))),
+                         ['foo', 'bar', 'baz'])
+
+    def test_splitext(self):
+        self.assertEqual(splitext(self.SEP.join(('foo', 'bar', 'baz.qux'))),
+                         ('foo/bar/baz', '.qux'))
+
+    def test_basedir(self):
+        foobarbaz = self.SEP.join(('foo', 'bar', 'baz'))
+        self.assertEqual(basedir(foobarbaz, ['foo', 'bar', 'baz']), 'foo')
+        self.assertEqual(basedir(foobarbaz, ['foo', 'foo/bar', 'baz']),
+                         'foo/bar')
+        self.assertEqual(basedir(foobarbaz, ['foo/bar', 'foo', 'baz']),
+                         'foo/bar')
+        self.assertEqual(basedir(foobarbaz, ['foo', 'bar', '']), 'foo')
+        self.assertEqual(basedir(foobarbaz, ['bar', 'baz', '']), '')
+
+    def test_match(self):
+        self.assertTrue(match('foo', ''))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/bar'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo'))
+        self.assertTrue(match('foo', '*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/bar/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/bar/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/bar/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/bar/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/*/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '*/bar/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '*/*/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '*/*/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/*/*'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/*/*.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/b*/*z.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/b*r/ba*z.qux'))
+        self.assertFalse(match('foo/bar/baz.qux', 'foo/b*z/ba*r.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '**'))
+        self.assertTrue(match('foo/bar/baz.qux', '**/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '**/bar/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/*.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '**/foo/bar/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/bar/baz.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/bar/*.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/*.qux'))
+        self.assertTrue(match('foo/bar/baz.qux', '**/*.qux'))
+        self.assertFalse(match('foo/bar/baz.qux', '**.qux'))
+        self.assertFalse(match('foo/bar', 'foo/*/bar'))
+        self.assertTrue(match('foo/bar/baz.qux', 'foo/**/bar/**'))
+        self.assertFalse(match('foo/nobar/baz.qux', 'foo/**/bar/**'))
+        self.assertTrue(match('foo/bar', 'foo/**/bar/**'))
+
+    def test_rebase(self):
+        self.assertEqual(rebase('foo', 'foo/bar', 'bar/baz'), 'baz')
+        self.assertEqual(rebase('foo', 'foo', 'bar/baz'), 'bar/baz')
+        self.assertEqual(rebase('foo/bar', 'foo', 'baz'), 'bar/baz')
+
+
+if os.altsep:
+    class TestAltPath(TestPath):
+        SEP = os.altsep
+
+    class TestReverseAltPath(TestPath):
+        def setUp(self):
+            sep = os.sep
+            os.sep = os.altsep
+            os.altsep = sep
+
+        def tearDown(self):
+            self.setUp()
+
+    class TestAltReverseAltPath(TestReverseAltPath):
+        SEP = os.altsep