#!/usr/bin/env python3 # -*- coding: utf-8 -*- # crowdin_sync.py # # Updates Crowdin source translations and pushes translations # directly to AICP Gerrit. # # Copyright (C) 2014-2016 The CyanogenMod Project # Copyright (C) 2017-2020 The LineageOS Project # This code has been modified. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # ################################# IMPORTS ################################## # import argparse import json import git import os import re import shutil import subprocess import sys import yaml from lxml import etree from signal import signal, SIGINT # ################################# GLOBALS ################################## # _DIR = os.path.dirname(os.path.realpath(__file__)) _COMMITS_CREATED = False # ################################ FUNCTIONS ################################# # def run_subprocess(cmd, silent=False): p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) comm = p.communicate() exit_code = p.returncode if exit_code != 0 and not silent: print("There was an error running the subprocess.\n" "cmd: %s\n" "exit code: %d\n" "stdout: %s\n" "stderr: %s" % (cmd, exit_code, comm[0], comm[1]), file=sys.stderr) return comm, exit_code def add_target_paths(config_files, repo, base_path, project_path): # Add or remove the files given in the config files to the commit count = 0 file_paths = [] for f in config_files: fh = open(f, "r") try: config = yaml.safe_load(fh) for tf in config['files']: if project_path in tf['source']: target_path = tf['translation'] lang_codes = tf['languages_mapping']['android_code'] for l in lang_codes: lpath = get_target_path(tf['translation'], tf['source'], lang_codes[l], project_path) file_paths.append(lpath) except yaml.YAMLError as e: print(e, '\n Could not parse YAML.') exit() fh.close() # Strip all comments for f in file_paths: clean_xml_file(base_path, project_path, f) # Modified and untracked files modified = repo.git.ls_files(m=True, o=True) for m in modified.split('\n'): if m in file_paths: repo.git.add(m) count += 1 deleted = repo.git.ls_files(d=True) for d in deleted.split('\n'): if d in file_paths: repo.git.rm(d) count += 1 return count def split_path(path): # Split the given string to path and filename if '/' in path: original_file_name = path[1:][path.rfind("/"):] original_path = path[:path.rfind("/")] else: original_file_name = path original_path = '' return original_path, original_file_name def get_target_path(pattern, source, lang, project_path): # Make strings like '/%original_path%-%android_code%/%original_file_name%' valid file paths # based on the source string's path original_path, original_file_name = split_path(source) target_path = pattern #.lstrip('/') target_path = target_path.replace('%original_path%', original_path) target_path = target_path.replace('%android_code%', lang) target_path = target_path.replace('%original_file_name%', original_file_name) target_path = target_path.replace(project_path, '') target_path = target_path.lstrip('/') return target_path def clean_xml_file(base_path, project_path, filename): path = base_path + '/' + project_path + '/' + filename # We don't want to create every file, just work with those already existing if not os.path.isfile(path): return try: fh = open(path, 'r+') except: print(f'\nSomething went wrong while opening file {path}') return XML = fh.read() content = '' # Take the original xml declaration and prepend it declaration = XML.split('\n')[0] if '\n', '?>\n' + header) # Sometimes spaces are added, we don't want them content = re.sub("[ ]*<\/resources>", "", content) # Overwrite file with content stripped by all comments fh.seek(0) fh.write(content) fh.truncate() fh.close() # Remove files which don't have any translated strings contentList = list(tree) if len(contentList) == 0: print(f'\nRemoving {path}') os.remove(path) # For files we can't process due to errors, create a backup # and checkout the file to get it back to the previous state def reset_file(filepath, repo): backupFile = None parts = filepath.split("/") found = False for s in parts: curPart = s if not found and s.startswith("res"): curPart = s + "_backup" found = True if backupFile is None: backupFile = curPart else: backupFile = backupFile + '/' + curPart path, filename = os.path.split(backupFile) if not os.path.exists(path): os.makedirs(path) if os.path.exists(backupFile): i = 1 while os.path.exists(backupFile + str(i)): i+=1 backupFile = backupFile + str(i) shutil.copy(filepath, backupFile) repo.git.checkout(filepath) def push_as_commit(config_files, base_path, path, name, branch, username): global _COMMITS_CREATED print(f'\nCommitting {name} on branch {branch}: ', end='') # Get path project_path = path path = os.path.join(base_path, path) if not path.endswith('.git'): path = os.path.join(path, '.git') # Create repo object repo = git.Repo(path) # Add all files to commit count = add_target_paths(config_files, repo, base_path, project_path) if count == 0: print('Nothing to commit') return # Create commit; if it fails, probably empty so skipping try: repo.git.commit(m='Automatic AICP translation import') except: print('Failed, probably empty: skipping', file=sys.stderr) return # Push commit try: repo.git.push(f'ssh://{username}@gerrit.aicp-rom.com:29418/{name}', f'HEAD:refs/for/{branch}', '-o', f'topic=Translations-{branch}') print('Success') except Exception as e: print(e, '\nFailed to push!', file=sys.stderr) return _COMMITS_CREATED = True def submit_gerrit(branch, username, owner): # If an owner is specified, modify the query so we only get the ones wanted ownerArg = '' if owner is not None: ownerArg = f'owner:{owner}' # Find all open translation changes cmd = ['ssh', '-p', '29418', f'{username}@gerrit.aicp-rom.com', 'gerrit', 'query', 'status:open', f'branch:{branch}', ownerArg, 'message:"Automatic AICP translation import"', f'topic:Translations-{branch}', '--current-patch-set', '--format=JSON'] commits = 0 msg, code = run_subprocess(cmd) if code != 0: print(f'Failed: {msg[1]}') return # Each line is one valid JSON object, except the last one, which is empty for line in msg[0].strip('\n').split('\n'): js = json.loads(line) # We get valid JSON, but not every result line is a line we want if not 'currentPatchSet' in js or not 'revision' in js['currentPatchSet']: continue # Add Code-Review +2 and Verified +1 labels and submit cmd = ['ssh', '-p', '29418', f'{username}@gerrit.aicp-rom.com', 'gerrit', 'review', '--verified +1', '--code-review +2', '--submit', js['currentPatchSet']['revision']] msg, code = run_subprocess(cmd, True) print('Submitting commit %s: ' % js['url'], end='') if code != 0: errorText = msg[1].replace('\n\n', '; ').replace('\n', '') print(f'Failed: {errorText}') else: print('Success') commits += 1 if commits == 0: print("Nothing to submit!") return def check_run(cmd): p = subprocess.Popen(cmd, stdout=sys.stdout, stderr=sys.stderr) ret = p.wait() if ret != 0: joined = ' '.join(cmd) print(f'Failed to run cmd: {joined}', file=sys.stderr) sys.exit(ret) def find_xml(base_path): for dp, dn, file_names in os.walk(base_path): for f in file_names: if os.path.splitext(f)[1] == '.xml': yield os.path.join(dp, f) # ############################################################################ # def parse_args(): parser = argparse.ArgumentParser( description="Synchronising AICP translations with Crowdin") sync = parser.add_mutually_exclusive_group() parser.add_argument('--username', help='Gerrit username') parser.add_argument('--branch', help='AICP branch', required=True) parser.add_argument('-c', '--config', help='Custom yaml config') parser.add_argument('--upload-sources', action='store_true', help='Upload sources to AICP Crowdin') parser.add_argument('--upload-translations', action='store_true', help='Upload AICP translations to Crowdin') parser.add_argument('--download', action='store_true', help='Download AICP translations from Crowdin') parser.add_argument('--local-download', action='store_true', help='Locally download AICP translations from Crowdin') parser.add_argument('--submit', action='store_true', help='Auto-Merge open AICP translations on Gerrit') parser.add_argument('-o', '--owner', help='Specify the owner of the commits to submit') return parser.parse_args() # ################################# PREPARE ################################## # def check_dependencies(): # Check if crowdin is installed somewhere cmd = ['which', 'crowdin'] if run_subprocess(cmd, silent=True)[1] != 0: print('You have not installed crowdin properly.', file=sys.stderr) return False return True def load_xml(x): try: return etree.parse(x) except etree.XMLSyntaxError: print(f'Malformed {x}', file=sys.stderr) return None except Exception: print(f'You have no {x}', file=sys.stderr) return None def check_files(files): for f in files: if not os.path.isfile(f): print(f'You have no {f}.', file=sys.stderr) return False return True # ################################### MAIN ################################### # def upload_sources_crowdin(project_id, branch, config): if config: print('\nUploading sources to Crowdin (custom config)') check_run(['crowdin', 'upload', 'sources', f'--project-id={project_id}', f'--branch={branch}', '--auto-update', f'--config={_DIR}/config/{config}']) else: print('\nUploading sources to Crowdin (AOSP supported languages)') check_run(['crowdin', 'upload', 'sources', f'--project-id={project_id}', f'--branch={branch}', '--auto-update', f'--config={_DIR}/config/{branch}.yml']) def upload_translations_crowdin(project_id, branch, config): if config: print('\nUploading translations to Crowdin (custom config)') check_run(['crowdin', 'upload', 'translations', f'--project-id={project_id}', f'--branch={branch}', '--import-eq-suggestions', '--auto-approve-imported', f'--config={_DIR}/config/{config}']) else: print('\nUploading translations to Crowdin ' '(AOSP supported languages)') check_run(['crowdin', 'upload', 'translations', f'--project-id={project_id}', f'--branch={branch}', '--import-eq-suggestions', '--auto-approve-imported', f'--config={_DIR}/config/{branch}.yml']) def local_download(project_id, base_path, branch, xml, config): if config: print('\nDownloading translations from Crowdin (custom config)') check_run(['crowdin', 'download', f'--project-id={project_id}', f'--branch={branch}', '--skip-untranslated-strings', '--export-only-approved', f'--config={_DIR}/config/{config}']) else: print('\nDownloading translations from Crowdin ' '(AOSP supported languages)') check_run(['crowdin', 'download', f'--project-id={project_id}', f'--branch={branch}', '--skip-untranslated-strings', '--export-only-approved', f'--config={_DIR}/config/{branch}.yml']) print('\nRemoving useless empty translation files (AOSP supported languages)') empty_contents = { '', '', (''), (''), (''), ('\n'), ('\n'), ('\n'), ('\n'), ('\n') } xf = None cmd = ['crowdin', 'list', 'translations', f'--project-id={project_id}', '--plain', f'--config={_DIR}/config/{branch}.yml'] comm, ret = run_subprocess(cmd) if ret != 0: sys.exit(ret) # Split in list and remove last empty entry xml_list=str(comm[0]).split("\n")[:-1] for xml_file in xml_list: try: print("Checking: " + base_path + '/' + xml_file) tree = etree.XML(open(base_path + '/' + xml_file).read().encode()) etree.strip_tags(tree,etree.Comment) treestring = etree.tostring(tree,encoding='UTF-8') xf = "".join([s for s in treestring.decode().strip().splitlines(True) if s.strip()]) for line in empty_contents: if line in xf: print("Removing: " + base_path + '/' + xml_file) os.remove(base_path + '/' + xml_file) break except IOError: print("File not found: " + xml_file) sys.exit(1) except etree.XMLSyntaxError: print("XML Syntax error in file: " + xml_file) sys.exit(1) del xf def download_crowdin(project_id, base_path, branch, xml, username, config): local_download(project_id, base_path, branch, xml, config) print('\nCreating a list of pushable translations') # Get all files that Crowdin pushed paths = [] if config: files = [f'{_DIR}/config/{config}'] else: files = [f'{_DIR}/config/{branch}.yml'] for c in files: cmd = ['crowdin', 'list', 'sources', f'--branch={branch}', f'--config={c}', '--plain'] comm, ret = run_subprocess(cmd) if ret != 0: sys.exit(ret) for p in str(comm[0]).split("\n"): paths.append(p.replace(f'/{branch}', '')) print('\nUploading translations to AICP Gerrit') items = [x for xmlfile in xml for x in xmlfile.findall("//project")] all_projects = [] for path in paths: path = path.strip() if not path: continue if "/res" not in path: print(f'WARNING: Cannot determine project root dir of [{path}], skipping.') continue # Usually the project root is everything before /res # but there are special cases where /res is part of the repo name as well parts = path.split("/res") if len(parts) == 2: result = parts[0] elif len(parts) == 3: result = parts[0] + '/res' + parts[1] else: print(f'WARNING: Splitting the path not successful for [{path}], skipping') continue result = result.strip('/') if result == path.strip('/'): print(f'WARNING: Cannot determine project root dir of [{path}], skipping.') continue if result in all_projects: continue # When a project has multiple translatable files, Crowdin will # give duplicates. # We don't want that (useless empty commits), so we save each # project in all_projects and check if it's already in there. all_projects.append(result) # Search AICP/platform_manifest/*.xml or # config/%(branch)_extra_packages.xml for the project's name resultPath = None resultProject = None for project in items: path = project.get('path') if not (result + '/').startswith(path +'/'): continue # We want the longest match, so projects in subfolders of other projects are also # taken into account if resultPath is None or len(path) > len(resultPath): resultPath = path resultProject = project # Just in case no project was found if resultPath is None: continue if result != resultPath: if resultPath in all_projects: continue result = resultPath all_projects.append(result) br = resultProject.get('revision') or branch push_as_commit(files, base_path, result, resultProject.get('name'), br, username) def sig_handler(signal_received, frame): print('') print('SIGINT or CTRL-C detected. Exiting gracefully') exit(0) def main(): global _COMMITS_CREATED signal(SIGINT, sig_handler) args = parse_args() default_branch = args.branch if args.submit: if args.username is None: print('Argument -u/--username is required for submitting!') sys.exit(1) submit_gerrit(default_branch, args.username, args.owner) sys.exit(0) project_id_env = 'AICP_CROWDIN_PROJECT_ID' project_id = os.getenv(project_id_env) base_path_branch_suffix = default_branch.replace('.', '_') base_path_env = f'AICP_CROWDIN_BASE_PATH_{base_path_branch_suffix}' base_path = os.getenv(base_path_env) if base_path is None: cwd = os.getcwd() print(f'You have not set {base_path_env}. Defaulting to {cwd}') base_path = cwd if not os.path.isdir(base_path): print(f'{base_path_env} is not a real directory: {base_path}') sys.exit(1) if not check_dependencies(): sys.exit(1) xml_default = load_xml(x=f'{base_path}/platform_manifest/crowdin.xml') if xml_default is None: sys.exit(1) xml_extra = load_xml(x=f'{_DIR}/config/{default_branch}_extra_packages.xml') if xml_extra is not None: xml_files = (xml_default, xml_extra) else: xml_files = (xml_default) if args.config: files = [f'{_DIR}/config/{args.config}'] else: files = [f'{_DIR}/config/{default_branch}.yml'] if not check_files(files): sys.exit(1) if args.download and args.username is None: print('Argument --username is required to perform this action') sys.exit(1) if args.upload_sources: upload_sources_crowdin(project_id, default_branch, args.config) if args.upload_translations: upload_translations_crowdin(project_id, default_branch, args.config) if args.local_download: local_download(project_id, base_path, default_branch, xml_files, args.config) if args.download: download_crowdin(project_id, base_path, default_branch, xml_files, args.username, args.config) if _COMMITS_CREATED: print('\nDone!') sys.exit(0) else: print('\nFinished! Nothing to do or commit anymore.') sys.exit(2) if __name__ == '__main__': main()