239 lines
8.3 KiB
Python
239 lines
8.3 KiB
Python
# 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 re
|
|
from compare_locales import mozpath
|
|
from .matcher import Matcher
|
|
|
|
|
|
class ExcludeError(ValueError):
|
|
pass
|
|
|
|
|
|
class ProjectConfig:
|
|
"""Abstraction of l10n project configuration data."""
|
|
|
|
def __init__(self, path):
|
|
self.filter_py = None # legacy filter code
|
|
# {
|
|
# 'l10n': pattern,
|
|
# 'reference': pattern, # optional
|
|
# 'locales': [], # optional
|
|
# 'test': [], # optional
|
|
# }
|
|
self.path = path
|
|
self.root = None
|
|
self.paths = []
|
|
self.rules = []
|
|
self.locales = None
|
|
# cache for all_locales, as that's not in `filter`
|
|
self._all_locales = None
|
|
self.environ = {}
|
|
self.children = []
|
|
self.excludes = []
|
|
self._cache = None
|
|
|
|
def same(self, other):
|
|
"""Equality test, ignoring locales."""
|
|
if other.__class__ is not self.__class__:
|
|
return False
|
|
if len(self.children) != len(other.children):
|
|
return False
|
|
for prop in ("path", "root", "paths", "rules", "environ"):
|
|
if getattr(self, prop) != getattr(other, prop):
|
|
return False
|
|
for this_child, other_child in zip(self.children, other.children):
|
|
if not this_child.same(other_child):
|
|
return False
|
|
return True
|
|
|
|
def set_root(self, basepath):
|
|
if self.path is None:
|
|
self.root = None
|
|
return
|
|
self.root = mozpath.abspath(mozpath.join(mozpath.dirname(self.path), basepath))
|
|
|
|
def add_environment(self, **kwargs):
|
|
self.environ.update(kwargs)
|
|
|
|
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.
|
|
"""
|
|
self._all_locales = None # clear cache
|
|
for d in paths:
|
|
rv = {
|
|
"l10n": Matcher(d["l10n"], env=self.environ, root=self.root),
|
|
"module": d.get("module"),
|
|
}
|
|
if "reference" in d:
|
|
rv["reference"] = Matcher(
|
|
d["reference"], env=self.environ, root=self.root
|
|
)
|
|
if "test" in d:
|
|
rv["test"] = d["test"]
|
|
if "locales" in d:
|
|
rv["locales"] = d["locales"][:]
|
|
self.paths.append(rv)
|
|
|
|
def set_filter_py(self, filter_function):
|
|
"""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_function(module, path, entity=entity)
|
|
except BaseException: # we really want to handle EVERYTHING here
|
|
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 add_child(self, child):
|
|
self._all_locales = None # clear cache
|
|
if child.excludes:
|
|
raise ExcludeError("Included configs cannot declare their own excludes.")
|
|
self.children.append(child)
|
|
|
|
def exclude(self, child):
|
|
for config in child.configs:
|
|
if config.excludes:
|
|
raise ExcludeError(
|
|
"Excluded configs cannot declare their own excludes."
|
|
)
|
|
self.excludes.append(child)
|
|
|
|
def set_locales(self, locales, deep=False):
|
|
self._all_locales = None # clear cache
|
|
self.locales = locales
|
|
if not deep:
|
|
return
|
|
for child in self.children:
|
|
child.set_locales(locales, deep=deep)
|
|
|
|
@property
|
|
def configs(self):
|
|
"Recursively get all configs in this project and its children"
|
|
yield self
|
|
for child in self.children:
|
|
yield from child.configs
|
|
|
|
@property
|
|
def all_locales(self):
|
|
"Recursively get all locales in this project and its paths"
|
|
if self._all_locales is None:
|
|
all_locales = set()
|
|
for config in self.configs:
|
|
if config.locales is not None:
|
|
all_locales.update(config.locales)
|
|
for paths in config.paths:
|
|
if "locales" in paths:
|
|
all_locales.update(paths["locales"])
|
|
self._all_locales = sorted(all_locales)
|
|
return self._all_locales
|
|
|
|
def filter(self, l10n_file, entity=None):
|
|
"""Filter a localization file or entities within, according to
|
|
this configuration file."""
|
|
if l10n_file.locale not in self.all_locales:
|
|
return "ignore"
|
|
if self.filter_py is not None:
|
|
return self.filter_py(l10n_file.module, l10n_file.file, entity=entity)
|
|
rv = self._filter(l10n_file, entity=entity)
|
|
if rv is None:
|
|
return "ignore"
|
|
return rv
|
|
|
|
class FilterCache:
|
|
def __init__(self, locale):
|
|
self.locale = locale
|
|
self.rules = []
|
|
self.l10n_paths = []
|
|
|
|
def cache(self, locale):
|
|
if self._cache and self._cache.locale == locale:
|
|
return self._cache
|
|
self._cache = self.FilterCache(locale)
|
|
for paths in self.paths:
|
|
if "locales" in paths and locale not in paths["locales"]:
|
|
continue
|
|
self._cache.l10n_paths.append(paths["l10n"].with_env({"locale": locale}))
|
|
for rule in self.rules:
|
|
cached_rule = rule.copy()
|
|
cached_rule["path"] = rule["path"].with_env({"locale": locale})
|
|
self._cache.rules.append(cached_rule)
|
|
return self._cache
|
|
|
|
def _filter(self, l10n_file, entity=None):
|
|
if any(exclude.filter(l10n_file) == "error" for exclude in self.excludes):
|
|
return
|
|
actions = {child._filter(l10n_file, entity=entity) for child in self.children}
|
|
if "error" in actions:
|
|
# return early if we know we'll error
|
|
return "error"
|
|
|
|
cached = self.cache(l10n_file.locale)
|
|
if any(p.match(l10n_file.fullpath) for p in cached.l10n_paths):
|
|
action = "error"
|
|
for rule in reversed(cached.rules):
|
|
if not rule["path"].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):
|
|
continue
|
|
action = rule["action"]
|
|
break
|
|
actions.add(action)
|
|
if "error" in actions:
|
|
return "error"
|
|
if "warning" in actions:
|
|
return "warning"
|
|
if "ignore" in actions:
|
|
return "ignore"
|
|
|
|
def _compile_rule(self, rule):
|
|
assert "path" in rule
|
|
if isinstance(rule["path"], list):
|
|
for path in rule["path"]:
|
|
_rule = rule.copy()
|
|
_rule["path"] = Matcher(path, env=self.environ, root=self.root)
|
|
yield from self._compile_rule(_rule)
|
|
return
|
|
if isinstance(rule["path"], str):
|
|
rule["path"] = Matcher(rule["path"], env=self.environ, root=self.root)
|
|
if "key" not in rule:
|
|
yield rule
|
|
return
|
|
if not isinstance(rule["key"], str):
|
|
for key in rule["key"]:
|
|
_rule = rule.copy()
|
|
_rule["key"] = key
|
|
yield from self._compile_rule(_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
|