# 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