bug 1361037, part 1: ProjectConfig abstraction, new and old filters, path matching and file iteration, r=flod, stas
authorAxel Hecht <axel@pike.org>
Thu, 11 May 2017 16:15:04 +0200
changeset 232 a6ac3254f7dccb89ebc695b553f3a64271df0c30
parent 231 62b1337ce244a8da728fe1a18d89b9dfda937a08
child 233 8ed0fa3e420b5417e0ddb926b5193d3854c32c4e
push id48
push useraxel@mozilla.com
push dateFri, 26 May 2017 11:10:47 +0000
reviewersflod, stas
bugs1361037
bug 1361037, part 1: ProjectConfig abstraction, new and old filters, path matching and file iteration, r=flod, stas The new code is only tested, not actually used yet. MozReview-Commit-ID: 2iQDhRek4E0
compare_locales/paths.py
compare_locales/tests/test_apps.py
compare_locales/tests/test_paths.py
--- a/compare_locales/paths.py
+++ b/compare_locales/paths.py
@@ -1,18 +1,259 @@
 # 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
+import re
 from ConfigParser import ConfigParser, NoSectionError, NoOptionError
 from collections import defaultdict
+import itertools
 from compare_locales import util, mozpath
 
 
+class Matcher(object):
+    '''Path pattern matcher
+    Supports path matching similar to mozpath.match(), but does
+    not match trailing file paths without trailing wildcards.
+    Also gets a prefix, which is the path before the first wildcard,
+    which is good for filesystem iterations, and allows to replace
+    the own matches in a path on a different Matcher. compare-locales
+    uses that to transform l10n and en-US paths back and forth.
+    '''
+    _locale = re.compile(r'\{\s*locale\s*\}')
+
+    def __init__(self, pattern, locale=None):
+        '''Create regular expression similar to mozpath.match().
+        '''
+        if locale is not None:
+            pattern = self._locale.sub(locale, pattern)
+        prefix = pattern.split("*", 1)[0]
+        p = re.escape(pattern)
+        p = re.sub(r'(^|\\\/)\\\*\\\*\\\/', r'\1(.+/)?', p)
+        p = re.sub(r'(^|\\\/)\\\*\\\*$', r'(\1.+)?', p)
+        p = p.replace(r'\*', '([^/]*)') + '$'
+        r = re.escape(pattern)
+        r = re.sub(r'(^|\\\/)\\\*\\\*\\\/', r'\\\\0', r)
+        r = re.sub(r'(^|\\\/)\\\*\\\*$', r'\\\\0', r)
+        r = r.replace(r'\*', r'\\0')
+        backref = itertools.count(1)
+        r = re.sub(r'\\0', lambda m: '\\%s' % backref.next(), r)
+        r = re.sub(r'\\(.)', r'\1', r)
+        self.prefix = prefix
+        self.regex = re.compile(p)
+        self.placable = r
+
+    def match(self, path):
+        '''
+        True if the given path matches the file pattern.
+        '''
+        return self.regex.match(path) is not None
+
+    def sub(self, other, path):
+        '''
+        Replace the wildcard matches in this pattern into the
+        pattern of the other Match object.
+        '''
+        if not self.match(path):
+            return None
+        return self.regex.sub(other.placable, path)
+
+
+class ProjectConfig(object):
+    '''Abstraction of l10n project configuration data.
+    '''
+
+    def __init__(self):
+        self.filter_py = None  # legacy filter code
+        self.paths = []  # {'reference': pattern, 'l10n': pattern, 'test': []}
+        self.rules = []
+        self.locales = []
+        self.projects = []  # TODO: add support for sub-projects
+
+    def add_paths(self, *paths):
+        '''Add path dictionaries to this config.
+        The dictionaries must have a `l10n` key. For monolingual files,
+        `reference` is also required.
+        An optional key `test` is allowed to enable additional tests for this
+        path pattern.
+        TODO: We may support an additional locale key in the future.
+        '''
+        for d in paths:
+            rv = {
+                'l10n': d['l10n'],
+                'module': d.get('module')
+            }
+            if 'reference' in d:
+                rv['reference'] = Matcher(d['reference'])
+            if 'test' in d:
+                rv['test'] = d['test']
+            # TODO: locale
+            self.paths.append(rv)
+
+    def set_filter_py(self, filter):
+        '''Set legacy filter.py code.
+        Assert that no rules are set.
+        Also, normalize output already here.
+        '''
+        assert not self.rules
+
+        def filter_(module, path, entity=None):
+            try:
+                rv = filter(module, path, entity=entity)
+            except:
+                return 'error'
+            rv = {
+                True: 'error',
+                False: 'ignore',
+                'report': 'warning'
+            }.get(rv, rv)
+            assert rv in ('error', 'ignore', 'warning', None)
+            return rv
+        self.filter_py = filter_
+
+    def add_rules(self, *rules):
+        '''Add rules to filter on.
+        Assert that there's no legacy filter.py code hooked up.
+        '''
+        assert self.filter_py is None
+        for rule in rules:
+            self.rules.extend(self._compile_rule(rule))
+
+    def filter(self, l10n_file, entity=None):
+        '''Filter a localization file or entities within, according to
+        this configuration file.'''
+        if self.filter_py is not None:
+            return self.filter_py(l10n_file.module, l10n_file.file,
+                                  entity=entity)
+        for rule in reversed(self.rules):
+            matcher = Matcher(
+                rule.get('path', '.'),
+                l10n_file.locale)
+            if not matcher.match(l10n_file.fullpath):
+                continue
+            if ('key' in rule) ^ (entity is not None):
+                # key/file mismatch, not a matching rule
+                continue
+            if 'key' in rule and not rule['key'].match(entity.key):
+                continue
+            return rule['action']
+
+    def _compile_rule(self, rule):
+        assert 'path' in rule
+        if not isinstance(rule['path'], basestring):
+            for path in rule['path']:
+                _rule = rule.copy()
+                _rule['path'] = path
+                for __rule in self._compile_rule(_rule):
+                    yield __rule
+            return
+        if 'key' not in rule:
+            yield rule
+            return
+        if not isinstance(rule['key'], basestring):
+            for key in rule['key']:
+                _rule = rule.copy()
+                _rule['key'] = key
+                for __rule in self._compile_rule(_rule):
+                    yield __rule
+            return
+        rule = rule.copy()
+        key = rule['key']
+        if key.startswith('re:'):
+            key = key[3:]
+        else:
+            key = re.escape(key) + '$'
+        rule['key'] = re.compile(key)
+        yield rule
+
+
+class ProjectFiles(object):
+    '''Iterator object to get all files and tests for a locale and a
+    list of ProjectConfigs.
+    '''
+    def __init__(self, locale, *projects):
+        self.locale = locale
+        self.matchers = []
+        for pc in projects:
+            if locale not in pc.locales:
+                continue
+            for paths in pc.paths:
+                m = {
+                    'l10n': Matcher(paths['l10n'], locale),
+                    'module': paths.get('module')
+                }
+                if 'reference' in paths:
+                    m['reference'] = paths['reference']
+                m['test'] = set(paths.get('test', []))
+                self.matchers.append(m)
+        self.matchers.reverse()  # we always iterate last first
+        # Remove duplicate patterns, comparing each matcher
+        # against all other matchers.
+        # Avoid n^2 comparisons by only scanning the upper triangle
+        # of a n x n matrix of all possible combinations.
+        # Using enumerate and keeping track of indexes, as we can't
+        # modify the list while iterating over it.
+        drops = set()  # duplicate matchers to remove
+        for i, m in enumerate(self.matchers[:-1]):
+            if i in drops:
+                continue  # we're dropping this anyway, don't search again
+            for i_, m_ in enumerate(self.matchers[(i+1):]):
+                if (mozpath.realpath(m['l10n'].prefix) !=
+                        mozpath.realpath(m_['l10n'].prefix)):
+                    # ok, not the same thing, continue
+                    continue
+                # check that we're comparing the same thing
+                if 'reference' in m:
+                    if (mozpath.realpath(m['reference'].prefix) !=
+                            mozpath.realpath(m_.get('reference').prefix)):
+                        raise RuntimeError('Mismatch in reference for ' +
+                                           mozpath.realpath(m['l10n'].prefix))
+                drops.add(i_ + i + 1)
+                m['test'] |= m_['test']
+        drops = sorted(drops, reverse=True)
+        for i in drops:
+            del self.matchers[i]
+
+    def __iter__(self):
+        known = {}
+        for matchers in self.matchers:
+            matcher = matchers['l10n']
+            for path in self._files(matcher.prefix):
+                if matcher.match(path) and path not in known:
+                    known[path] = {'test': matchers.get('test')}
+                    if 'reference' in matchers:
+                        known[path]['reference'] = matcher.sub(
+                            matchers['reference'], path)
+            if 'reference' not in matchers:
+                continue
+            matcher = matchers['reference']
+            for path in self._files(matcher.prefix):
+                if not matcher.match(path):
+                    continue
+                l10npath = matcher.sub(matchers['l10n'], path)
+                if l10npath not in known:
+                    known[l10npath] = {
+                        'reference': path,
+                        'test': matchers.get('test')
+                    }
+        for path, d in sorted(known.items()):
+            yield (path, d.get('reference'), d['test'])
+
+    def _files(self, base):
+        '''Base implementation of getting all files in a hierarchy
+        using the file system.
+        Subclasses might replace this method to support different IO
+        patterns.
+        '''
+        for d, dirs, files in os.walk(base):
+            for f in files:
+                yield mozpath.join(d, f)
+
+
 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
@@ -263,16 +504,40 @@ class EnumerateApp(object):
 
     def setupConfigParser(self, inipath):
         self.config = L10nConfigParser(inipath)
         self.config.loadConfigs()
 
     def addFilters(self, *args):
         self.filters += args
 
+    def asConfig(self):
+        config = ProjectConfig()
+        self._config_for_ini(config, self.config)
+        filters = self.config.getFilters()
+        if filters:
+            config.set_filter_py(filters[0])
+        config.locales += self.locales
+        return config
+
+    def _config_for_ini(self, projectconfig, aConfig):
+        for k, (basepath, module) in aConfig.dirsIter():
+            paths = {
+                'module': module,
+                'reference': mozpath.normpath('%s/%s/locales/en-US/**' %
+                                              (basepath, module)),
+                'l10n': mozpath.normpath('%s/{locale}/%s/**' %
+                                         (self.l10nbase, module))
+            }
+            if module == 'mobile/android/base':
+                paths['test'] = ['android-dtd']
+            projectconfig.add_paths(paths)
+        for child in aConfig.children:
+            self._config_for_ini(projectconfig, child)
+
     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
--- a/compare_locales/tests/test_apps.py
+++ b/compare_locales/tests/test_apps.py
@@ -1,15 +1,15 @@
 import unittest
 import os
 import tempfile
 import shutil
 
 from compare_locales import mozpath
-from compare_locales.paths import EnumerateApp
+from compare_locales.paths import EnumerateApp, ProjectFiles
 
 MAIL_INI = '''\
 [general]
 depth = ../..
 all = mail/locales/all-locales
 
 [compare]
 dirs = mail
@@ -48,29 +48,66 @@ 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)
+        l10n = mozpath.join(self.stage, 'l10n-central', 'de', 'toolkit')
+        os.makedirs(mozpath.join(mail, 'en-US'))
+        os.makedirs(mozpath.join(toolkit, 'en-US'))
+        os.makedirs(l10n)
         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)
+        with open(mozpath.join(mail, 'en-US', 'mail.ftl'), 'w') as f:
+            f.write('')
+        with open(mozpath.join(toolkit, 'en-US', 'platform.ftl'), 'w') as f:
+            f.write('')
+        with open(mozpath.join(l10n, 'localized.ftl'), 'w') as f:
+            f.write('')
 
     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)
+        projectconfig = app.asConfig()
+        self.assertListEqual(projectconfig.locales, ['af', 'de', 'fr'])
+        files = ProjectFiles('de', projectconfig)
+        files = list(files)
+        self.assertEqual(len(files), 3)
+
+        l10nfile, reffile, test = files[0]
+        self.assertListEqual(mozpath.split(l10nfile)[-3:],
+                             ['de', 'mail', 'mail.ftl'])
+        self.assertListEqual(mozpath.split(reffile)[-4:],
+                             ['mail', 'locales', 'en-US', 'mail.ftl'])
+        self.assertSetEqual(test, set())
+
+        l10nfile, reffile, test = files[1]
+        self.assertListEqual(mozpath.split(l10nfile)[-3:],
+                             ['de', 'toolkit', 'localized.ftl'])
+        self.assertListEqual(
+            mozpath.split(reffile)[-6:],
+            ['comm', 'mozilla', 'toolkit',
+             'locales', 'en-US', 'localized.ftl'])
+        self.assertSetEqual(test, set())
+
+        l10nfile, reffile, test = files[2]
+        self.assertListEqual(mozpath.split(l10nfile)[-3:],
+                             ['de', 'toolkit', 'platform.ftl'])
+        self.assertListEqual(
+            mozpath.split(reffile)[-6:],
+            ['comm', 'mozilla', 'toolkit', 'locales', 'en-US', 'platform.ftl'])
+        self.assertSetEqual(test, set())
new file mode 100644
--- /dev/null
+++ b/compare_locales/tests/test_paths.py
@@ -0,0 +1,326 @@
+# -*- coding: utf-8 -*-
+# 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 unittest
+
+from compare_locales.parser import Parser, Entity
+from compare_locales.paths import ProjectConfig, File, ProjectFiles, Matcher
+from compare_locales import mozpath
+
+
+class TestMatcher(unittest.TestCase):
+
+    def test_matcher(self):
+        one = Matcher('foo/*')
+        self.assertTrue(one.match('foo/baz'))
+        self.assertFalse(one.match('foo/baz/qux'))
+        other = Matcher('bar/*')
+        self.assertTrue(other.match('bar/baz'))
+        self.assertFalse(other.match('bar/baz/qux'))
+        self.assertEqual(one.sub(other, 'foo/baz'), 'bar/baz')
+        one = Matcher('foo/**')
+        self.assertTrue(one.match('foo/baz'))
+        self.assertTrue(one.match('foo/baz/qux'))
+        other = Matcher('bar/**')
+        self.assertTrue(other.match('bar/baz'))
+        self.assertTrue(other.match('bar/baz/qux'))
+        self.assertEqual(one.sub(other, 'foo/baz'), 'bar/baz')
+        self.assertEqual(one.sub(other, 'foo/baz/qux'), 'bar/baz/qux')
+        one = Matcher('foo/*/one/**')
+        self.assertTrue(one.match('foo/baz/one/qux'))
+        self.assertFalse(one.match('foo/baz/bez/one/qux'))
+        other = Matcher('bar/*/other/**')
+        self.assertTrue(other.match('bar/baz/other/qux'))
+        self.assertFalse(other.match('bar/baz/bez/other/qux'))
+        self.assertEqual(one.sub(other, 'foo/baz/one/qux'),
+                         'bar/baz/other/qux')
+        self.assertEqual(one.sub(other, 'foo/baz/one/qux/zzz'),
+                         'bar/baz/other/qux/zzz')
+        self.assertIsNone(one.sub(other, 'foo/baz/bez/one/qux'))
+
+
+class SetupMixin(object):
+    def setUp(self):
+        self.cfg = ProjectConfig()
+        self.file = File(
+            '/tmp/somedir/de/browser/one/two/file.ftl',
+            'file.ftl',
+            module='browser', locale='de')
+        self.other_file = File(
+            '/tmp/somedir/de/toolkit/two/one/file.ftl',
+            'file.ftl',
+            module='toolkit', locale='de')
+        key_name = 'one_entity'
+        ctx = Parser.Context(key_name)
+        self.entity = Entity(ctx, lambda s: s, '', (0, len(key_name)), (), (),
+                             (0, len(key_name)), (), ())
+
+
+class TestConfigLegacy(SetupMixin, unittest.TestCase):
+
+    def test_filter_empty(self):
+        'Test that an empty config works'
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, None)
+
+    def test_filter_py_true(self):
+        'Test filter.py just return bool(True)'
+        def filter(mod, path, entity=None):
+            return True
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'error')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'error')
+
+    def test_filter_py_false(self):
+        'Test filter.py just return bool(False)'
+        def filter(mod, path, entity=None):
+            return False
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'ignore')
+
+    def test_filter_py_error(self):
+        'Test filter.py just return str("error")'
+        def filter(mod, path, entity=None):
+            return 'error'
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'error')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'error')
+
+    def test_filter_py_ignore(self):
+        'Test filter.py just return str("ignore")'
+        def filter(mod, path, entity=None):
+            return 'ignore'
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'ignore')
+
+    def test_filter_py_report(self):
+        'Test filter.py just return str("report") and match to "warning"'
+        def filter(mod, path, entity=None):
+            return 'report'
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'warning')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'warning')
+
+    def test_filter_py_module(self):
+        'Test filter.py to return str("error") for browser or "ignore"'
+        def filter(mod, path, entity=None):
+            return 'error' if mod == 'browser' else 'ignore'
+        self.cfg.set_filter_py(filter)
+        with self.assertRaises(AssertionError):
+            self.cfg.add_rules({})
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'error')
+        rv = self.cfg.filter(self.file, entity=self.entity)
+        self.assertEqual(rv, 'error')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file, entity=self.entity)
+        self.assertEqual(rv, 'ignore')
+
+
+class TestConfigRules(SetupMixin, unittest.TestCase):
+
+    def test_single_file_rule(self):
+        'Test a single rule for just a single file, no key'
+        self.cfg.add_rules({
+            'path': '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.file, self.entity)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.other_file, self.entity)
+        self.assertEqual(rv, None)
+
+    def test_single_key_rule(self):
+        'Test a single rule with file and key'
+        self.cfg.add_rules({
+            'path': '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+            'key': 'one_entity',
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.file, self.entity)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.other_file, self.entity)
+        self.assertEqual(rv, None)
+
+    def test_single_non_matching_key_rule(self):
+        'Test a single key rule with regex special chars that should not match'
+        self.cfg.add_rules({
+            'path': '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+            'key': '.ne_entit.',
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file, self.entity)
+        self.assertEqual(rv, None)
+
+    def test_single_matching_re_key_rule(self):
+        'Test a single key with regular expression'
+        self.cfg.add_rules({
+            'path': '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+            'key': 're:.ne_entit.$',
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file, self.entity)
+        self.assertEqual(rv, 'ignore')
+
+    def test_double_file_rule(self):
+        'Test path shortcut, one for each of our files'
+        self.cfg.add_rules({
+            'path': [
+                '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+                '/tmp/somedir/{locale}/toolkit/two/one/file.ftl',
+            ],
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, 'ignore')
+
+    def test_double_file_key_rule(self):
+        'Test path and key shortcut, one key matching, one not'
+        self.cfg.add_rules({
+            'path': [
+                '/tmp/somedir/{locale}/browser/one/two/file.ftl',
+                '/tmp/somedir/{locale}/toolkit/two/one/file.ftl',
+            ],
+            'key': [
+                'one_entity',
+                'other_entity',
+            ],
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.file, self.entity)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, None)
+        rv = self.cfg.filter(self.other_file, self.entity)
+        self.assertEqual(rv, 'ignore')
+
+    def test_single_wildcard_rule(self):
+        'Test single wildcard'
+        self.cfg.add_rules({
+            'path': [
+                '/tmp/somedir/{locale}/browser/one/*/*',
+            ],
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, None)
+
+    def test_double_wildcard_rule(self):
+        'Test double wildcard'
+        self.cfg.add_rules({
+            'path': [
+                '/tmp/somedir/{locale}/**',
+            ],
+            'action': 'ignore'
+        })
+        rv = self.cfg.filter(self.file)
+        self.assertEqual(rv, 'ignore')
+        rv = self.cfg.filter(self.other_file)
+        self.assertEqual(rv, 'ignore')
+
+
+class MockProjectFiles(ProjectFiles):
+    def __init__(self, mocks, locale, *projects):
+        super(MockProjectFiles, self).__init__(locale, *projects)
+        self.mocks = mocks
+
+    def _files(self, base):
+        for path in self.mocks.get(base, []):
+            yield mozpath.join(base, path)
+
+
+class TestProjectPaths(unittest.TestCase):
+    def test_l10n_path(self):
+        cfg = ProjectConfig()
+        cfg.locales.append('de')
+        cfg.add_paths({
+            'l10n': '/tmp/{locale}/*'
+        })
+        mocks = {
+            '/tmp/de/': [
+                'good.ftl',
+                'not/subdir/bad.ftl'
+            ],
+            '/tmp/fr/': [
+                'good.ftl',
+                'not/subdir/bad.ftl'
+            ],
+        }
+        files = MockProjectFiles(mocks, 'de', cfg)
+        self.assertListEqual(list(files), [('/tmp/de/good.ftl', None, set())])
+        # 'fr' is not in the locale list, should return no files
+        files = MockProjectFiles(mocks, 'fr', cfg)
+        self.assertListEqual(list(files), [])
+
+    def test_reference_path(self):
+        cfg = ProjectConfig()
+        cfg.locales.append('de')
+        cfg.add_paths({
+            'l10n': '/tmp/l10n/{locale}/*',
+            'reference': '/tmp/reference/*'
+        })
+        mocks = {
+            '/tmp/l10n/de/': [
+                'good.ftl',
+                'not/subdir/bad.ftl'
+            ],
+            '/tmp/l10n/fr/': [
+                'good.ftl',
+                'not/subdir/bad.ftl'
+            ],
+            '/tmp/reference/': [
+                'ref.ftl',
+                'not/subdir/bad.ftl'
+            ],
+        }
+        files = MockProjectFiles(mocks, 'de', cfg)
+        self.assertListEqual(
+            list(files),
+            [
+                ('/tmp/l10n/de/good.ftl', '/tmp/reference/good.ftl', set()),
+                ('/tmp/l10n/de/ref.ftl', '/tmp/reference/ref.ftl', set()),
+            ])
+        # 'fr' is not in the locale list, should return no files
+        files = MockProjectFiles(mocks, 'fr', cfg)
+        self.assertListEqual(list(files), [])