summaryrefslogtreecommitdiffstats
path: root/python/l10n/mozxchannel
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--python/l10n/mozxchannel/__init__.py150
-rw-r--r--python/l10n/mozxchannel/projectconfig.py77
-rw-r--r--python/l10n/mozxchannel/source.py84
3 files changed, 311 insertions, 0 deletions
diff --git a/python/l10n/mozxchannel/__init__.py b/python/l10n/mozxchannel/__init__.py
new file mode 100644
index 0000000000..66ee3966ca
--- /dev/null
+++ b/python/l10n/mozxchannel/__init__.py
@@ -0,0 +1,150 @@
+# 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 shutil
+from collections import defaultdict
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import Path
+
+import hglib
+from compare_locales import merge
+from mozpack import path as mozpath
+
+from . import projectconfig, source
+
+
+def get_default_config(topsrcdir, strings_path):
+ assert isinstance(topsrcdir, Path)
+ assert isinstance(strings_path, Path)
+ return {
+ "strings": {
+ "path": strings_path,
+ "url": "https://hg.mozilla.org/l10n/gecko-strings-quarantine/",
+ "heads": {"default": "default"},
+ "update_on_pull": True,
+ "push_url": "ssh://hg.mozilla.org/l10n/gecko-strings-quarantine/",
+ },
+ "source": {
+ "mozilla-unified": {
+ "path": topsrcdir,
+ "url": "https://hg.mozilla.org/mozilla-unified/",
+ "heads": {
+ # This list of repositories is ordered, starting with the
+ # one with the most recent content (central) to the oldest
+ # (ESR). In case two ESR versions are supported, the oldest
+ # ESR goes last (e.g. esr78 goes after esr91).
+ "central": "mozilla-central",
+ "beta": "releases/mozilla-beta",
+ "release": "releases/mozilla-release",
+ "esr102": "releases/mozilla-esr102",
+ },
+ "config_files": [
+ "browser/locales/l10n.toml",
+ "mobile/android/locales/l10n.toml",
+ ],
+ },
+ },
+ }
+
+
+@dataclass
+class TargetRevs:
+ target: bytes = None
+ revs: list = field(default_factory=list)
+
+
+@dataclass
+class CommitRev:
+ repo: str
+ rev: bytes
+
+ @property
+ def message(self):
+ return (
+ f"X-Channel-Repo: {self.repo}\n"
+ f'X-Channel-Revision: {self.rev.decode("ascii")}'
+ )
+
+
+class CrossChannelCreator:
+ def __init__(self, config):
+ self.config = config
+ self.strings_path = config["strings"]["path"]
+ self.message = (
+ f"cross-channel content for {datetime.utcnow().strftime('%Y-%m-%d %H:%M')}"
+ )
+
+ def create_content(self):
+ self.prune_target()
+ revs = []
+ for repo_name, repo_config in self.config["source"].items():
+ with hglib.open(repo_config["path"]) as repo:
+ revs.extend(self.create_for_repo(repo, repo_name, repo_config))
+ self.commit(revs)
+ return 0
+
+ def prune_target(self):
+ for leaf in self.config["strings"]["path"].iterdir():
+ if leaf.name == ".hg":
+ continue
+ shutil.rmtree(leaf)
+
+ def create_for_repo(self, repo, repo_name, repo_config):
+ print(f"Processing {repo_name} in {repo_config['path']}")
+ source_target_revs = defaultdict(TargetRevs)
+ revs_for_commit = []
+ parse_kwargs = {
+ "env": {"l10n_base": str(self.strings_path.parent)},
+ "ignore_missing_includes": True,
+ }
+ for head, head_name in repo_config["heads"].items():
+ print(f"Gathering files for {head}")
+ rev = repo.log(revrange=head)[0].node
+ revs_for_commit.append(CommitRev(head_name, rev))
+ p = source.HgTOMLParser(repo, rev)
+ project_configs = []
+ for config_file in repo_config["config_files"]:
+ project_configs.append(p.parse(config_file, **parse_kwargs))
+ project_configs[-1].set_locales(["en-US"], deep=True)
+ hgfiles = source.HGFiles(repo, rev, project_configs)
+ for targetpath, refpath, _, _ in hgfiles:
+ source_target_revs[refpath].revs.append(rev)
+ source_target_revs[refpath].target = targetpath
+ root = repo.root()
+ print(f"Writing {repo_name} content to target")
+ for refpath, targetrevs in source_target_revs.items():
+ local_ref = mozpath.relpath(refpath, root)
+ content = self.get_content(local_ref, repo, targetrevs.revs)
+ target_dir = mozpath.dirname(targetrevs.target)
+ if not os.path.isdir(target_dir):
+ os.makedirs(target_dir)
+ with open(targetrevs.target, "wb") as fh:
+ fh.write(content)
+ return revs_for_commit
+
+ def commit(self, revs):
+ message = self.message + "\n\n"
+ if "TASK_ID" in os.environ:
+ message += f"X-Task-ID: {os.environ['TASK_ID']}\n\n"
+ message += "\n".join(rev.message for rev in revs)
+ with hglib.open(self.strings_path) as repo:
+ repo.commit(message=message, addremove=True)
+
+ def get_content(self, local_ref, repo, revs):
+ if local_ref.endswith(b".toml"):
+ return self.get_config_content(local_ref, repo, revs)
+ if len(revs) < 2:
+ return repo.cat([b"path:" + local_ref], rev=revs[0])
+ contents = [repo.cat([b"path:" + local_ref], rev=rev) for rev in revs]
+ try:
+ return merge.merge_channels(local_ref.decode("utf-8"), contents)
+ except merge.MergeNotSupportedError:
+ return contents[0]
+
+ def get_config_content(self, local_ref, repo, revs):
+ # We don't support merging toml files
+ content = repo.cat([b"path:" + local_ref], rev=revs[0])
+ return projectconfig.process_config(content)
diff --git a/python/l10n/mozxchannel/projectconfig.py b/python/l10n/mozxchannel/projectconfig.py
new file mode 100644
index 0000000000..23d8120a3c
--- /dev/null
+++ b/python/l10n/mozxchannel/projectconfig.py
@@ -0,0 +1,77 @@
+# 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
+
+# The local path where we write the config files to
+TARGET_PATH = b"_configs"
+
+
+def process_config(toml_content):
+ """Process TOML configuration content to match the l10n setup for
+ the reference localization, return target_path and content.
+
+ The code adjusts basepath, [[paths]], and [[includes]]
+ """
+ # adjust basepath in content. '.' works in practice, also in theory?
+ new_base = mozpath.relpath(b".", TARGET_PATH)
+ if not new_base:
+ new_base = b"." # relpath to '.' is '', sadly
+ base_line = b'\nbasepath = "%s"' % new_base
+ content1 = re.sub(br"^\s*basepath\s*=\s*.+", base_line, toml_content, flags=re.M)
+
+ # process [[paths]]
+ start = 0
+ content2 = b""
+ for m in re.finditer(
+ br"\[\[\s*paths\s*\]\].+?(?=\[|\Z)", content1, re.M | re.DOTALL
+ ):
+ content2 += content1[start : m.start()]
+ path_content = m.group()
+ l10n_line = re.search(br"^\s*l10n\s*=.*$", path_content, flags=re.M).group()
+ # remove variable expansions
+ new_reference = re.sub(br"{\s*\S+\s*}", b"", l10n_line)
+ # make the l10n a reference line
+ new_reference = re.sub(br"^(\s*)l10n(\s*=)", br"\1reference\2", new_reference)
+ content2 += re.sub(
+ br"^\s*reference\s*=.*$", new_reference, path_content, flags=re.M
+ )
+ start = m.end()
+ content2 += content1[start:]
+
+ start = 0
+ content3 = b""
+ for m in re.finditer(
+ br"\[\[\s*includes\s*\]\].+?(?=\[|\Z)", content2, re.M | re.DOTALL
+ ):
+ content3 += content2[start : m.start()]
+ include_content = m.group()
+ m_ = re.search(br'^\s*path = "(.+?)"', include_content, flags=re.M)
+ content3 += (
+ include_content[: m_.start(1)]
+ + generate_filename(m_.group(1))
+ + include_content[m_.end(1) :]
+ )
+ start = m.end()
+ content3 += content2[start:]
+
+ return content3
+
+
+def generate_filename(path):
+ segs = path.split(b"/")
+ # strip /locales/ from filename
+ segs = [seg for seg in segs if seg != b"locales"]
+ # strip variables from filename
+ segs = [seg for seg in segs if not seg.startswith(b"{") and not seg.endswith(b"}")]
+ if segs[-1] == b"l10n.toml":
+ segs.pop()
+ segs[-1] += b".toml"
+ outpath = b"-".join(segs)
+ if TARGET_PATH != b".":
+ # prepend the target path, if it's not '.'
+ outpath = mozpath.join(TARGET_PATH, outpath)
+ return outpath
diff --git a/python/l10n/mozxchannel/source.py b/python/l10n/mozxchannel/source.py
new file mode 100644
index 0000000000..683a7b0640
--- /dev/null
+++ b/python/l10n/mozxchannel/source.py
@@ -0,0 +1,84 @@
+# 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 pytoml as toml
+from compare_locales import mozpath, paths
+from compare_locales.paths.matcher import expand
+
+from .projectconfig import generate_filename
+
+
+class HGFiles(paths.ProjectFiles):
+ def __init__(self, repo, rev, projects):
+ self.repo = repo
+ self.ctx = repo[rev]
+ self.root = repo.root()
+ self.manifest = None
+ self.configs_map = {}
+ # get paths for our TOML files
+ for p in projects:
+ all_configpaths = {
+ mozpath.abspath(c.path).encode("utf-8") for c in p.configs
+ }
+ for refpath in all_configpaths:
+ local_path = mozpath.relpath(refpath, self.root)
+ if local_path not in self.ctx:
+ print("ignoring", refpath)
+ continue
+ targetpath = b"/".join(
+ (
+ expand(None, "{l10n_base}", p.environ).encode("utf-8"),
+ b"en-US",
+ generate_filename(local_path),
+ )
+ )
+ self.configs_map[refpath] = targetpath
+ super(HGFiles, self).__init__("en-US", projects)
+ for m in self.matchers:
+ m["l10n"].encoding = "utf-8"
+ if "reference" in m:
+ m["reference"].encoding = "utf-8"
+ if self.exclude:
+ for m in self.exclude.matchers:
+ m["l10n"].encoding = "utf-8"
+ if "reference" in m:
+ m["reference"].encoding = "utf-8"
+
+ def _files(self, matcher):
+ for f in self.ctx.manifest():
+ f = mozpath.join(self.root, f)
+ if matcher.match(f):
+ yield f
+
+ def __iter__(self):
+ for t in super(HGFiles, self).__iter__():
+ yield t
+ for refpath, targetpath in self.configs_map.items():
+ yield targetpath, refpath, None, set()
+
+ def match(self, path):
+ m = super(HGFiles, self).match(path)
+ if m:
+ return m
+ for refpath, targetpath in self.configs_map.items():
+ if path in [refpath, targetpath]:
+ return targetpath, refpath, None, set()
+
+
+class HgTOMLParser(paths.TOMLParser):
+ "subclass to load from our hg context"
+
+ def __init__(self, repo, rev):
+ self.repo = repo
+ self.rev = rev
+ self.root = repo.root().decode("utf-8")
+
+ def load(self, parse_ctx):
+ try:
+ path = parse_ctx.path
+ local_path = "path:" + mozpath.relpath(path, self.root)
+ data = self.repo.cat(files=[local_path.encode("utf-8")], rev=self.rev)
+ except Exception:
+ raise paths.ConfigNotFound(parse_ctx.path)
+ parse_ctx.data = toml.loads(data, filename=parse_ctx.path)