diff options
| author | Ralf Luther <luther.ralf@gmail.com> | 2020-02-22 13:18:43 +0100 |
|---|---|---|
| committer | Ralf Luther <luther.ralf@gmail.com> | 2020-02-22 13:22:42 +0100 |
| commit | 3e9ef50bbf8b830f427d84aacc23ff0f6b5751fa (patch) | |
| tree | 01a9afbe5a27e17dc98680778b741d2ad8ee78f8 | |
| parent | 82c9d0b684fe41b0cddb461b4f592ceda4f1db1e (diff) | |
crowdin: squash a few updates
We need to update our script to make it work properly again
and add recent changes from LineageOS that were authored
by "Michael W <baddaemon87@gmail.com>" (and a bit adapted).
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/268071
crowdin: PyYAML yaml.load(input) deprecation
* YAMLLoadWarning: calling yaml.load() without Loader=... is deprecated,
as the default Loader is unsafe.
Please read https://msg.pyyaml.org/load for full details
* Use yaml.safe_load(input) instead - we only use a small subset of YAML
so this works as expected
* Test: Compared the results of yaml.load() and yaml.safe_load()
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/268087
crowdin: Make downloading a parallel thing
* Instead of relying on the API, call the download for several languages
at once
* Limit the amount of simultaneous calls to a maximum of 20 as per
https://support.crowdin.com/api/api-integration-setup/#rate-limits
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/267360/1
crowdin: Improve console output
* Reduce the amount of output lines - this makes it more readable
* Add a signal handler to handle aborts - we don't need a stacktrace here
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/253482
crowdin: Don't clean non-xml and handle errors for xml-files
* For non-xml files, just don't touch the file
* For xml-files, create a backup:
- create the same folder structure from /res(.*)/(.*) and place the
wrong file in there, appending a number if already existing
- then checkout the original xml file to get rid of the errors
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/250431
crowdin: Remove invalid strings
* aapt2 fails when linking resources which contain strings with a product
attribute but don't have the same string with product=default set
* Search for all strings in the relevant files which have a product
attribute and look for the corresponding default one
* Remove all strings with that name if it doesn't exist
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/250488
crowdin: Improve empty resource file removal
* Instead of maintaining a list of all variants of empty resource tags,
just count the number of sub elements
* Equal to zero -> No child elements -> remove
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/244152
crowdin: Fix for subprojects
* If a project is stored within another projects folder structure,
the current implementation will treat it as "already known" and therefore
never process the project itself
* Search for the biggest path match before continuing
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/244128
crowdin: Fix for devicesettings
* Due to a split at /res, the devicesettings-repo was always treated wrong
* Check the length of the list after split and treat that case properly
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/243912
crowdin: Add missing import
* Forgot "re", will result in an error when trying to download from crowdin
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/242731
crowdin: Re-add removing empty xml files
* Process every file and remove all comments
* If only the root tags are left, remove the whole file
https://review.lineageos.org/c/LineageOS/cm_crowdin/+/242685
crowdin: Only push the translation changes
* Instead of commiting all the changed files, parse the config files
and only commit changes made to files which are mentioned there
* This ensures that changes to other files which exist locally aren't
added to the commits and have to be manually removed again
Change-Id: Ie7fd0743000bf19e352f35520eb829bdadd4958e
| -rwxr-xr-x | crowdin_sync.py | 232 |
1 files changed, 178 insertions, 54 deletions
diff --git a/crowdin_sync.py b/crowdin_sync.py index ac822c6..7f8e117 100755 --- a/crowdin_sync.py +++ b/crowdin_sync.py @@ -1,12 +1,12 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# crowdin-cli_sync.py +# crowdin_sync.py # # Updates Crowdin source translations and pushes translations # directly to AICP Gerrit. # # Copyright (C) 2014-2016 The CyanogenMod Project -# Copyright (C) 2017-2019 The LineageOS Project +# Copyright (C) 2017-2020 The LineageOS Project # This code has been modified. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -30,11 +30,14 @@ import json import git import os import re +import shutil import subprocess import sys import yaml +from itertools import islice from lxml import etree +from signal import signal, SIGINT from xml.dom import minidom # ################################# GLOBALS ################################## # @@ -67,7 +70,7 @@ def add_target_paths(config_files, repo, base_path, project_path): for f in config_files: fh = open(f, "r") try: - config = yaml.load(fh, Loader=yaml.FullLoader) + config = yaml.safe_load(fh) for tf in config['files']: if project_path in tf['source']: target_path = tf['translation'] @@ -83,7 +86,7 @@ def add_target_paths(config_files, repo, base_path, project_path): # Strip all comments for f in file_paths: - clean_file(base_path, project_path, f) + clean_xml_file(base_path, project_path, f) # Modified and untracked files modified = repo.git.ls_files(m=True, o=True) @@ -127,7 +130,7 @@ def get_target_path(pattern, source, lang, project_path): return target_path -def clean_file(base_path, project_path, filename): +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 @@ -141,7 +144,39 @@ def clean_file(base_path, project_path, filename): return XML = fh.read() - tree = etree.fromstring(XML) + try: + tree = etree.fromstring(XML) + except etree.XMLSyntaxError as err: + print('%s: XML Error: %s' % (filename, err.error_log)) + filename, ext = os.path.splitext(path) + if ext == '.xml': + reset_file(path, repo) + return + + # Remove strings with 'product=*' attribute but no 'product=default' + # This will ensure aapt2 will not throw an error when building these + productStrings = tree.xpath("//string[@product]") + for ps in productStrings: + stringName = ps.get('name') + stringsWithSameName = tree.xpath("//string[@name='{0}']" + .format(stringName)) + + # We want to find strings with product='default' or no product attribute at all + hasProductDefault = False + for string in stringsWithSameName: + product = string.get('product') + if product is None or product == 'default': + hasProductDefault = True + break + + # Every occurance of the string has to be removed when no string with the same name and + # 'product=default' (or no product attribute) was found + if not hasProductDefault: + print("{0}: Found string '{1}' with missing 'product=default' attribute" + .format(path, stringName)) + for string in stringsWithSameName: + tree.remove(string) + productStrings.remove(string) header = '' comments = tree.xpath('//comment()') @@ -175,30 +210,42 @@ def clean_file(base_path, project_path, filename): fh.close() # Remove files which don't have any translated strings - empty_contents = { - '<resources/>', - '<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"/>', - ('<resources xmlns:android=' - '"http://schemas.android.com/apk/res/android"/>'), - ('<resources xmlns:android="http://schemas.android.com/apk/res/android"' - ' xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"/>'), - ('<resources xmlns:tools="http://schemas.android.com/tools"' - ' xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"/>'), - ('<resources xmlns:android="http://schemas.android.com/apk/res/android">\n</resources>'), - ('<resources xmlns:android="http://schemas.android.com/apk/res/android"' - ' xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">\n</resources>'), - ('<resources xmlns:tools="http://schemas.android.com/tools"' - ' xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">\n</resources>'), - ('<resources>\n</resources>') - } - for line in empty_contents: - if line in content: - print('Removing ' + path) - os.remove(path) - break + contentList = list(tree) + if len(contentList) == 0: + print('Removing ' + 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): - print('Committing %s on branch %s' % (name, branch)) + print('Committing %s on branch %s: ' % (name, branch), end='') # Get path project_path = path @@ -220,17 +267,16 @@ def push_as_commit(config_files, base_path, path, name, branch, username): try: repo.git.commit(m='Automatic AICP translation import') except: - print('Failed to create commit for %s, probably empty: skipping' - % name, file=sys.stderr) + print('Failed, probably empty: skipping', file=sys.stderr) return # Push commit try: repo.git.push('ssh://%s@gerrit.aicp-rom.com:29418/%s' % (username, name), 'HEAD:refs/for/%s%%topic=Translations-%s' % (branch, branch)) - print('Successfully pushed commit for %s' % name) + print('Success') except: - print('Failed to push commit for %s' % name, file=sys.stderr) + print('Failed', file=sys.stderr) _COMMITS_CREATED = True @@ -266,11 +312,12 @@ def submit_gerrit(branch, username): '--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('Submitting commit {0} failed: {1}'.format(js['url'], errorText)) + print('Failed: %s' % errorText) else: - print('Success when submitting commit {0}'.format(js['url'])) + print('Success') commits += 1 @@ -293,14 +340,54 @@ def find_xml(base_path): if os.path.splitext(f)[1] == '.xml': yield os.path.join(dp, f) + +def get_languages(configPath): + try: + fh = open(configPath, 'r+') + data = yaml.safe_load(fh); + fh.close(); + + languages = [] + for elem in data['files'][0]['languages_mapping']['android_code']: + languages.append(elem); + return languages + except: + return [] + + +def available_cpu_count(): + try: + import multiprocessing + return multiprocessing.cpu_count() + except (ImportError, NotImplementedError): + return 4; # Some probably safe default + + +def run_parallel(commands): + processes = (subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=sys.stderr) + for cmd in commands) + slice_size = min(20, available_cpu_count()) + running_processes = list(islice(processes, slice_size)) # split into slices and start processes + while running_processes: + for i, process in enumerate(running_processes): + # see if the process has finished + if process.poll() is not None: + out, err = process.communicate() + print(out); + # get and start next process + running_processes[i] = next(processes, None) + if running_processes[i] is None: + del running_processes[i] + break + # ############################################################################ # def parse_args(): parser = argparse.ArgumentParser( description="Synchronising AICP translations with Crowdin") - parser.add_argument('--username', help='Gerrit username', - required=True) + 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') @@ -311,7 +398,7 @@ def parse_args(): parser.add_argument('--download', action='store_true', help='Download AICP translations from Crowdin') parser.add_argument('--local-download', action='store_true', - help='local Download AICP translations from Crowdin') + help='Locally download AICP translations from Crowdin') parser.add_argument('--submit', action='store_true', help='Auto-Merge open AICP translations on Gerrit') return parser.parse_args() @@ -400,18 +487,25 @@ def local_download(base_path, branch, xml, config): '--config=%s/config/%s' % (_DIR, config), 'download', '--branch=%s' % branch]) else: - print('\nDownloading translations from Crowdin ' - '(AOSP supported languages)') - check_run(['java', '-jar', '/usr/local/bin/crowdin-cli.jar', - '--config=%s/config/%s.yaml' % (_DIR, branch), - 'download', '--branch=%s' % branch]) - print('\nDownloading GZOSP translations from Crowdin ' '(AOSP supported languages)') check_run(['java', '-jar', '/usr/local/bin/crowdin-cli.jar', '--config=%s/config/%s_gzosp.yaml' % (_DIR, branch), 'download', '--branch=%s' % branch]) + print('\nDownloading translations from Crowdin ' + '(AOSP supported languages)') + commands = [] + config_path = '%s/config/%s.yaml' % (_DIR, branch); + languages = get_languages(config_path) + for lang in languages: + cmd = ['java', '-jar', '/usr/local/bin/crowdin-cli.jar', + '--config=%s' % config_path, + 'download', '--branch=%s' % branch, + '-l=%s' % lang] + commands.append(cmd) + run_parallel(commands) + def download_crowdin(base_path, branch, xml, username, config): local_download(base_path, branch, xml, config) @@ -446,7 +540,19 @@ def download_crowdin(base_path, branch, xml, username, config): print('WARNING: Cannot determine project root dir of ' '[%s], skipping.' % path) continue - result = path.split('/res')[0].strip('/') + + # 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('WARNING: Splitting the path not successful for [%s], skipping' % path) + continue + + result = result.strip('/') if result == path.strip('/'): print('WARNING: Cannot determine project root dir of ' '[%s], skipping.' % path) @@ -461,26 +567,44 @@ def download_crowdin(base_path, branch, xml, username, config): # project in all_projects and check if it's already in there. all_projects.append(result) - # Search %(branch)/platform_manifest/*_default.xml or + # 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.attributes['path'].value if not (result + '/').startswith(path +'/'): continue - if result != path: - if path in all_projects: - break - result = path - all_projects.append(result) + # 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.getAttribute('revision') or branch + + push_as_commit(files, base_path, result, + resultProject.getAttribute('name'), br, username) - br = project.getAttribute('revision') or branch - push_as_commit(files, base_path, result, - project.getAttribute('name'), br, username) - break +def sig_handler(signal_received, frame): + print('') + print('SIGINT or CTRL-C detected. Exiting gracefully') + exit(0) def main(): + signal(SIGINT, sig_handler) args = parse_args() default_branch = args.branch |
