blob: 655c48e954740ff3d5135675b6d21da385aed764 [file] [log] [blame]
David 'Digit' Turner00b5f272019-04-10 21:15:271#!/usr/bin/env vpython
2# Copyright 2019 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""Helper script used to manage locale-related files in Chromium.
7
8This script is used to check, and potentially fix, many locale-related files
9in your Chromium workspace, such as:
10
11 - GRIT input files (.grd) and the corresponding translations (.xtb).
12
13 - BUILD.gn files listing Android localized resource string resource .xml
14 generated by GRIT for all supported Chrome locales. These correspond to
15 <output> elements that use the type="android" attribute.
16
17The --scan-dir <dir> option can be used to check for all files under a specific
18directory, and the --fix-inplace option can be used to try fixing any file
19that doesn't pass the check.
20
21This can be very handy to avoid tedious and repetitive work when adding new
22translations / locales to the Chrome code base, since this script can update
23said input files for you.
24
25Important note: checks and fix may fail on some input files. For example
26remoting/resources/remoting_strings.grd contains an in-line comment element
27inside its <outputs> section that breaks the script. The check will fail, and
28trying to fix it too, but at least the file will not be modified.
29"""
30import argparse
31import json
32import os
33import re
34import shutil
35import subprocess
36import sys
37
38# Assume this script is under build/
39_SCRIPT_DIR = os.path.dirname(__file__)
40_SCRIPT_NAME = os.path.join(_SCRIPT_DIR, os.path.basename(__file__))
41_TOP_SRC_DIR = os.path.join(_SCRIPT_DIR, '..')
42
43# Need to import android/gyp/util/resource_utils.py here.
44sys.path.insert(0, os.path.join(_SCRIPT_DIR, 'android/gyp'))
45
46from util import build_utils
47from util import resource_utils
48
49
50# This locale is the default and doesn't have translations.
51_DEFAULT_LOCALE = 'en-US'
52
53# Misc terminal codes to provide human friendly progress output.
54_CONSOLE_CODE_MOVE_CURSOR_TO_COLUMN_0 = '\x1b[0G'
55_CONSOLE_CODE_ERASE_LINE = '\x1b[K'
56_CONSOLE_START_LINE = (
57 _CONSOLE_CODE_MOVE_CURSOR_TO_COLUMN_0 + _CONSOLE_CODE_ERASE_LINE)
58
59##########################################################################
60##########################################################################
61#####
62##### G E N E R I C H E L P E R F U N C T I O N S
63#####
64##########################################################################
65##########################################################################
66
67def _FixChromiumLangAttribute(lang):
68 """Map XML "lang" attribute values to Chromium locale names."""
69 _CHROMIUM_LANG_FIXES = {
70 'en': 'en-US', # For now, Chromium doesn't have an 'en' locale.
71 'iw': 'he', # 'iw' is the obsolete form of ISO 639-1 for Hebrew
72 'no': 'nb', # 'no' is used by the Translation Console for Norwegian (nb).
73 }
74 return _CHROMIUM_LANG_FIXES.get(lang, lang)
75
76
77def _CompareLocaleLists(list_a, list_expected, list_name):
78 """Compare two lists of locale names. Print errors if they differ.
79
80 Args:
81 list_a: First list of locales.
82 list_expected: Second list of locales, as expected.
83 list_name: Name of list printed in error messages.
84 Returns:
85 On success, return False. On error, print error messages and return True.
86 """
87 errors = []
88 missing_locales = sorted(set(list_a) - set(list_expected))
89 if missing_locales:
90 errors.append('Missing locales: %s' % missing_locales)
91
92 extra_locales = sorted(set(list_expected) - set(list_a))
93 if extra_locales:
94 errors.append('Unexpected locales: %s' % extra_locales)
95
96 if errors:
97 print 'Errors in %s definition:' % list_name
98 for error in errors:
99 print ' %s\n' % error
100 return True
101
102 return False
103
104
105def _BuildIntervalList(input_list, predicate):
106 """Find ranges of contiguous list items that pass a given predicate.
107
108 Args:
109 input_list: An input list of items of any type.
110 predicate: A function that takes a list item and return True if it
111 passes a given test.
112 Returns:
113 A list of (start_pos, end_pos) tuples, where all items in
114 [start_pos, end_pos) pass the predicate.
115 """
116 result = []
117 size = len(input_list)
118 start = 0
119 while True:
120 # Find first item in list that passes the predicate.
121 while start < size and not predicate(input_list[start]):
122 start += 1
123
124 if start >= size:
125 return result
126
127 # Find first item in the rest of the list that does not pass the
128 # predicate.
129 end = start + 1
130 while end < size and predicate(input_list[end]):
131 end += 1
132
133 result.append((start, end))
134 start = end + 1
135
136
137def _SortListSubRange(input_list, start, end, key_func):
138 """Sort an input list's sub-range according to a specific key function.
139
140 Args:
141 input_list: An input list.
142 start: Sub-range starting position in list.
143 end: Sub-range limit position in list.
144 key_func: A function that extracts a sort key from a line.
145 Returns:
146 A copy of |input_list|, with all items in [|start|, |end|) sorted
147 according to |key_func|.
148 """
149 result = input_list[:start]
150 inputs = []
151 for pos in xrange(start, end):
152 line = input_list[pos]
153 key = key_func(line)
154 inputs.append((key, line))
155
156 for _, line in sorted(inputs):
157 result.append(line)
158
159 result += input_list[end:]
160 return result
161
162
163def _SortElementsRanges(lines, element_predicate, element_key):
164 """Sort all elements of a given type in a list of lines by a given key.
165
166 Args:
167 lines: input lines.
168 element_predicate: predicate function to select elements to sort.
169 element_key: lambda returning a comparison key for each element that
170 passes the predicate.
171 Returns:
172 A new list of input lines, with lines [start..end) sorted.
173 """
174 intervals = _BuildIntervalList(lines, element_predicate)
175 for start, end in intervals:
176 lines = _SortListSubRange(lines, start, end, element_key)
177
178 return lines
179
180
181def _ProcessFile(input_file, locales, check_func, fix_func):
182 """Process a given input file, potentially fixing it.
183
184 Args:
185 input_file: Input file path.
186 locales: List of Chrome locales to consider / expect.
187 check_func: A lambda called to check the input file lines with
188 (input_lines, locales) argument. It must return an list of error
189 messages, or None on success.
190 fix_func: None, or a lambda called to fix the input file lines with
191 (input_lines, locales). It must return the new list of lines for
192 the input file, and may raise an Exception in case of error.
193 Returns:
194 True at the moment.
195 """
196 print '%sProcessing %s...' % (_CONSOLE_START_LINE, input_file),
197 sys.stdout.flush()
198 with open(input_file) as f:
199 input_lines = f.readlines()
200 errors = check_func(input_file, input_lines, locales)
201 if errors:
202 print '\n%s%s' % (_CONSOLE_START_LINE, '\n'.join(errors))
203 if fix_func:
204 try:
205 input_lines = fix_func(input_file, input_lines, locales)
206 output = ''.join(input_lines)
207 with open(input_file, 'wt') as f:
208 f.write(output)
209 print 'Fixed %s.' % input_file
210 except Exception as e: # pylint: disable=broad-except
211 print 'Skipped %s: %s' % (input_file, e)
212
213 return True
214
215
216def _ScanDirectoriesForFiles(scan_dirs, file_predicate):
217 """Scan a directory for files that match a given predicate.
218
219 Args:
220 scan_dir: A list of top-level directories to start scan in.
221 file_predicate: lambda function which is passed the file's base name
222 and returns True if its full path, relative to |scan_dir|, should be
223 passed in the result.
224 Returns:
225 A list of file full paths.
226 """
227 result = []
228 for src_dir in scan_dirs:
229 for root, _, files in os.walk(src_dir):
230 result.extend(os.path.join(root, f) for f in files if file_predicate(f))
231 return result
232
233
234def _WriteFile(file_path, file_data):
235 """Write |file_data| to |file_path|."""
236 with open(file_path, 'w') as f:
237 f.write(file_data)
238
239
240def _FindGnExecutable():
241 """Locate the real GN executable used by this Chromium checkout.
242
243 This is needed because the depot_tools 'gn' wrapper script will look
244 for .gclient and other things we really don't need here.
245
246 Returns:
247 Path of real host GN executable from current Chromium src/ checkout.
248 """
249 # Simply scan buildtools/*/gn and return the first one found so we don't
250 # have to guess the platform-specific sub-directory name (e.g. 'linux64'
251 # for 64-bit Linux machines).
252 buildtools_dir = os.path.join(_TOP_SRC_DIR, 'buildtools')
253 for subdir in os.listdir(buildtools_dir):
254 subdir_path = os.path.join(buildtools_dir, subdir)
255 if not os.path.isdir(subdir_path):
256 continue
257 gn_path = os.path.join(subdir_path, 'gn')
258 if os.path.exists(gn_path):
259 return gn_path
260 return None
261
262
263##########################################################################
264##########################################################################
265#####
266##### L O C A L E S L I S T S
267#####
268##########################################################################
269##########################################################################
270
271# Various list of locales that will be extracted from build/config/locales.gni
272# Do not use these directly, use ChromeLocales(), AndroidOmittedLocales() and
273# IosUnsupportedLocales() instead to access these lists.
274_INTERNAL_CHROME_LOCALES = []
275_INTERNAL_ANDROID_OMITTED_LOCALES = []
276_INTERNAL_IOS_UNSUPPORTED_LOCALES = []
277
278
279def ChromeLocales():
280 """Return the list of all locales supported by Chrome."""
281 if not _INTERNAL_CHROME_LOCALES:
282 _ExtractAllChromeLocalesLists()
283 return _INTERNAL_CHROME_LOCALES
284
David 'Digit' Turner00b5f272019-04-10 21:15:27285def AndroidOmittedLocales():
286 """Reutrn the list of locales omitted from Android APKs."""
287 if not _INTERNAL_ANDROID_OMITTED_LOCALES:
288 _ExtractAllChromeLocalesLists()
289 return _INTERNAL_ANDROID_OMITTED_LOCALES
290
291
292def IosUnsupportedLocales():
293 """Return the list of locales that are unsupported on iOS."""
294 if not _INTERNAL_IOS_UNSUPPORTED_LOCALES:
295 _ExtractAllChromeLocalesLists()
296 return _INTERNAL_IOS_UNSUPPORTED_LOCALES
297
298
299def _PrepareTinyGnWorkspace(work_dir, out_subdir_name='out'):
300 """Populate an empty directory with a tiny set of working GN config files.
301
302 This allows us to run 'gn gen <out> --root <work_dir>' as fast as possible
303 to generate files containing the locales list. This takes about 300ms on
304 a decent machine, instead of more than 5 seconds when running the equivalent
305 commands from a real Chromium workspace, which requires regenerating more
306 than 23k targets.
307
308 Args:
309 work_dir: target working directory.
310 out_subdir_name: Name of output sub-directory.
311 Returns:
312 Full path of output directory created inside |work_dir|.
313 """
314 # Create top-level .gn file that must point to the BUILDCONFIG.gn.
315 _WriteFile(os.path.join(work_dir, '.gn'),
316 'buildconfig = "//BUILDCONFIG.gn"\n')
317 # Create BUILDCONFIG.gn which must set a default toolchain. Also add
318 # all variables that may be used in locales.gni in a declare_args() block.
319 _WriteFile(os.path.join(work_dir, 'BUILDCONFIG.gn'),
320 r'''set_default_toolchain("toolchain")
321declare_args () {
322 is_ios = false
323}
324''')
325
326 # Create fake toolchain required by BUILDCONFIG.gn.
327 os.mkdir(os.path.join(work_dir, 'toolchain'))
328 _WriteFile(os.path.join(work_dir, 'toolchain', 'BUILD.gn'),
329 r'''toolchain("toolchain") {
330 tool("stamp") {
331 command = "touch {{output}}" # Required by action()
332 }
333}
334''')
335
336 # Create top-level BUILD.gn, GN requires at least one target to build so do
337 # that with a fake action which will never be invoked. Also write the locales
338 # to misc files in the output directory.
339 _WriteFile(os.path.join(work_dir, 'BUILD.gn'),
340 r'''import("//locales.gni")
341
342action("create_foo") { # fake action to avoid GN complaints.
343 script = "//build/create_foo.py"
344 inputs = []
345 outputs = [ "$target_out_dir/$target_name" ]
346}
347
348# Write the locales lists to files in the output directory.
349_filename = root_build_dir + "/foo"
350write_file(_filename + ".locales", locales, "json")
351write_file(_filename + ".android_omitted_locales",
352 android_chrome_omitted_locales,
353 "json")
354write_file(_filename + ".ios_unsupported_locales",
355 ios_unsupported_locales,
356 "json")
357''')
358
359 # Copy build/config/locales.gni to the workspace, as required by BUILD.gn.
360 shutil.copyfile(os.path.join(_TOP_SRC_DIR, 'build', 'config', 'locales.gni'),
361 os.path.join(work_dir, 'locales.gni'))
362
363 # Create output directory.
364 out_path = os.path.join(work_dir, out_subdir_name)
365 os.mkdir(out_path)
366
367 # And ... we're good.
368 return out_path
369
370
371# Set this global variable to the path of a given temporary directory
372# before calling _ExtractAllChromeLocalesLists() if you want to debug
373# the locales list extraction process.
374_DEBUG_LOCALES_WORK_DIR = None
375
376
377def _ReadJsonList(file_path):
378 """Read a JSON file that must contain a list, and return it."""
379 with open(file_path) as f:
380 data = json.load(f)
381 assert isinstance(data, list), "JSON file %s is not a list!" % file_path
David 'Digit' Turner5553d512019-04-15 08:54:05382 return [item.encode('utf8') for item in data]
David 'Digit' Turner00b5f272019-04-10 21:15:27383
384
385def _ExtractAllChromeLocalesLists():
386 with build_utils.TempDir() as tmp_path:
387 if _DEBUG_LOCALES_WORK_DIR:
388 tmp_path = _DEBUG_LOCALES_WORK_DIR
389 build_utils.DeleteDirectory(tmp_path)
390 build_utils.MakeDirectory(tmp_path)
391
392 out_path = _PrepareTinyGnWorkspace(tmp_path, 'out')
393
394 # NOTE: The file suffixes used here should be kept in sync with
395 # build/config/locales.gni
396 gn_executable = _FindGnExecutable()
397 subprocess.check_output(
398 [gn_executable, 'gen', out_path, '--root=' + tmp_path])
399
400 global _INTERNAL_CHROME_LOCALES
401 _INTERNAL_CHROME_LOCALES = _ReadJsonList(
402 os.path.join(out_path, 'foo.locales'))
403
404 global _INTERNAL_ANDROID_OMITTED_LOCALES
405 _INTERNAL_ANDROID_OMITTED_LOCALES = _ReadJsonList(
406 os.path.join(out_path, 'foo.android_omitted_locales'))
407
408 global _INTERNAL_IOS_UNSUPPORTED_LOCALES
409 _INTERNAL_IOS_UNSUPPORTED_LOCALES = _ReadJsonList(
410 os.path.join(out_path, 'foo.ios_unsupported_locales'))
411
David 'Digit' Turner5553d512019-04-15 08:54:05412
David 'Digit' Turner00b5f272019-04-10 21:15:27413##########################################################################
414##########################################################################
415#####
416##### G R D H E L P E R F U N C T I O N S
417#####
418##########################################################################
419##########################################################################
420
421# Technical note:
422#
423# Even though .grd files are XML, an xml parser library is not used in order
424# to preserve the original file's structure after modification. ElementTree
425# tends to re-order attributes in each element when re-writing an XML
426# document tree, which is undesirable here.
427#
428# Thus simple line-based regular expression matching is used instead.
429#
430
431# Misc regular expressions used to match elements and their attributes.
432_RE_OUTPUT_ELEMENT = re.compile(r'<output (.*)\s*/>')
433_RE_TRANSLATION_ELEMENT = re.compile(r'<file (.*\.xtb")\s*/>')
434_RE_FILENAME_ATTRIBUTE = re.compile(r'filename="([^"]*)"')
435_RE_LANG_ATTRIBUTE = re.compile(r'lang="([^"]*)"')
436_RE_PATH_ATTRIBUTE = re.compile(r'path="([^"]*)"')
437_RE_TYPE_ANDROID_ATTRIBUTE = re.compile(r'type="android"')
438
439assert _RE_TRANSLATION_ELEMENT.match('<file path="foo/bar.xtb" />')
440assert _RE_TRANSLATION_ELEMENT.match('<file path="foo/bar.xtb"/>')
441assert _RE_TRANSLATION_ELEMENT.match('<file path="foo/bar.xml" />') is None
442
443
444def _IsGritInputFile(input_file):
445 """Returns True iff this is a GRIT input file."""
446 return input_file.endswith('.grd')
447
448
449def _SortGrdElementsRanges(grd_lines, element_predicate):
450 """Sort all .grd elements of a given type by their lang attribute."""
451 return _SortElementsRanges(
452 grd_lines,
453 element_predicate,
454 lambda x: _RE_LANG_ATTRIBUTE.search(x).group(1))
455
456
457def _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales):
458 """Check the element 'lang' attributes in specific .grd lines range.
459
460 This really checks the following:
461 - Each item has a correct 'lang' attribute.
462 - There are no duplicated lines for the same 'lang' attribute.
463 - That there are no extra locales that Chromium doesn't want.
464 - That no wanted locale is missing.
465
466 Args:
467 grd_lines: Input .grd lines.
468 start: Sub-range start position in input line list.
469 end: Sub-range limit position in input line list.
470 wanted_locales: Set of wanted Chromium locale names.
471 Returns:
472 List of error message strings for this input. Empty on success.
473 """
474 errors = []
475 locales = set()
476 for pos in xrange(start, end):
477 line = grd_lines[pos]
478 m = _RE_LANG_ATTRIBUTE.search(line)
479 if not m:
480 errors.append('%d: Missing "lang" attribute in <output> element' % pos +
481 1)
482 continue
483 lang = m.group(1)
484 cr_locale = _FixChromiumLangAttribute(lang)
485 if cr_locale in locales:
486 errors.append(
487 '%d: Redefinition of <output> for "%s" locale' % (pos + 1, lang))
488 locales.add(cr_locale)
489
490 extra_locales = locales.difference(wanted_locales)
491 if extra_locales:
492 errors.append('%d-%d: Extra locales found: %s' % (start + 1, end + 1,
493 sorted(extra_locales)))
494
495 missing_locales = wanted_locales.difference(locales)
496 if missing_locales:
497 errors.append('%d-%d: Missing locales: %s' % (start + 1, end + 1,
498 sorted(missing_locales)))
499
500 return errors
501
502
503##########################################################################
504##########################################################################
505#####
506##### G R D A N D R O I D O U T P U T S
507#####
508##########################################################################
509##########################################################################
510
511def _IsGrdAndroidOutputLine(line):
512 """Returns True iff this is an Android-specific <output> line."""
513 m = _RE_OUTPUT_ELEMENT.search(line)
514 if m:
515 return 'type="android"' in m.group(1)
516 return False
517
518assert _IsGrdAndroidOutputLine(' <output type="android"/>')
519
520# Many of the functions below have unused arguments due to genericity.
521# pylint: disable=unused-argument
522
523def _CheckGrdElementRangeAndroidOutputFilename(grd_lines, start, end,
524 wanted_locales):
525 """Check all <output> elements in specific input .grd lines range.
526
527 This really checks the following:
528 - Filenames exist for each listed locale.
529 - Filenames are well-formed.
530
531 Args:
532 grd_lines: Input .grd lines.
533 start: Sub-range start position in input line list.
534 end: Sub-range limit position in input line list.
535 wanted_locales: Set of wanted Chromium locale names.
536 Returns:
537 List of error message strings for this input. Empty on success.
538 """
539 errors = []
540 for pos in xrange(start, end):
541 line = grd_lines[pos]
542 m = _RE_LANG_ATTRIBUTE.search(line)
543 if not m:
544 continue
545 lang = m.group(1)
546 cr_locale = _FixChromiumLangAttribute(lang)
547
548 m = _RE_FILENAME_ATTRIBUTE.search(line)
549 if not m:
550 errors.append('%d: Missing filename attribute in <output> element' % pos +
551 1)
552 else:
553 filename = m.group(1)
554 if not filename.endswith('.xml'):
555 errors.append(
556 '%d: Filename should end with ".xml": %s' % (pos + 1, filename))
557
558 dirname = os.path.basename(os.path.dirname(filename))
559 prefix = ('values-%s' % resource_utils.ToAndroidLocaleName(cr_locale)
560 if cr_locale != _DEFAULT_LOCALE else 'values')
561 if dirname != prefix:
562 errors.append(
563 '%s: Directory name should be %s: %s' % (pos + 1, prefix, filename))
564
565 return errors
566
567
568def _CheckGrdAndroidOutputElements(grd_file, grd_lines, wanted_locales):
569 """Check all <output> elements related to Android.
570
571 Args:
572 grd_file: Input .grd file path.
573 grd_lines: List of input .grd lines.
574 wanted_locales: set of wanted Chromium locale names.
575 Returns:
576 List of error message strings. Empty on success.
577 """
578 intervals = _BuildIntervalList(grd_lines, _IsGrdAndroidOutputLine)
579 errors = []
580 for start, end in intervals:
581 errors += _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales)
582 errors += _CheckGrdElementRangeAndroidOutputFilename(grd_lines, start, end,
583 wanted_locales)
584 return errors
585
586
587def _AddMissingLocalesInGrdAndroidOutputs(grd_file, grd_lines, wanted_locales):
588 """Fix an input .grd line by adding missing Android outputs.
589
590 Args:
591 grd_file: Input .grd file path.
592 grd_lines: Input .grd line list.
593 wanted_locales: set of Chromium locale names.
594 Returns:
595 A new list of .grd lines, containing new <output> elements when needed
596 for locales from |wanted_locales| that were not part of the input.
597 """
598 intervals = _BuildIntervalList(grd_lines, _IsGrdAndroidOutputLine)
599 for start, end in reversed(intervals):
600 locales = set()
601 for pos in xrange(start, end):
602 lang = _RE_LANG_ATTRIBUTE.search(grd_lines[pos]).group(1)
603 locale = _FixChromiumLangAttribute(lang)
604 locales.add(locale)
605
606 missing_locales = wanted_locales.difference(locales)
607 if not missing_locales:
608 continue
609
610 src_locale = 'bg'
611 src_lang_attribute = 'lang="%s"' % src_locale
612 src_line = None
613 for pos in xrange(start, end):
614 if src_lang_attribute in grd_lines[pos]:
615 src_line = grd_lines[pos]
616 break
617
618 if not src_line:
619 raise Exception(
620 'Cannot find <output> element with "%s" lang attribute' % src_locale)
621
622 line_count = end - 1
623 for locale in missing_locales:
624 android_locale = resource_utils.ToAndroidLocaleName(locale)
625 dst_line = src_line.replace(
626 'lang="%s"' % src_locale, 'lang="%s"' % locale).replace(
627 'values-%s/' % src_locale, 'values-%s/' % android_locale)
628 grd_lines.insert(line_count, dst_line)
629 line_count += 1
630
631 # Sort the new <output> elements.
632 return _SortGrdElementsRanges(grd_lines, _IsGrdAndroidOutputLine)
633
634
635##########################################################################
636##########################################################################
637#####
638##### G R D T R A N S L A T I O N S
639#####
640##########################################################################
641##########################################################################
642
643
644def _IsTranslationGrdOutputLine(line):
645 """Returns True iff this is an output .xtb <file> element."""
646 m = _RE_TRANSLATION_ELEMENT.search(line)
647 return m is not None
648
649
650def _CheckGrdTranslationElementRange(grd_lines, start, end,
651 wanted_locales):
652 """Check all <translations> sub-elements in specific input .grd lines range.
653
654 This really checks the following:
655 - Each item has a 'path' attribute.
656 - Each such path value ends up with '.xtb'.
657
658 Args:
659 grd_lines: Input .grd lines.
660 start: Sub-range start position in input line list.
661 end: Sub-range limit position in input line list.
662 wanted_locales: Set of wanted Chromium locale names.
663 Returns:
664 List of error message strings for this input. Empty on success.
665 """
666 errors = []
667 for pos in xrange(start, end):
668 line = grd_lines[pos]
669 m = _RE_LANG_ATTRIBUTE.search(line)
670 if not m:
671 continue
672 m = _RE_PATH_ATTRIBUTE.search(line)
673 if not m:
674 errors.append('%d: Missing path attribute in <file> element' % pos +
675 1)
676 else:
677 filename = m.group(1)
678 if not filename.endswith('.xtb'):
679 errors.append(
680 '%d: Path should end with ".xtb": %s' % (pos + 1, filename))
681
682 return errors
683
684
685def _CheckGrdTranslations(grd_file, grd_lines, wanted_locales):
686 """Check all <file> elements that correspond to an .xtb output file.
687
688 Args:
689 grd_file: Input .grd file path.
690 grd_lines: List of input .grd lines.
691 wanted_locales: set of wanted Chromium locale names.
692 Returns:
693 List of error message strings. Empty on success.
694 """
695 wanted_locales = wanted_locales - set([_DEFAULT_LOCALE])
696 intervals = _BuildIntervalList(grd_lines, _IsTranslationGrdOutputLine)
697 errors = []
698 for start, end in intervals:
699 errors += _CheckGrdElementRangeLang(grd_lines, start, end, wanted_locales)
700 errors += _CheckGrdTranslationElementRange(grd_lines, start, end,
701 wanted_locales)
702 return errors
703
704
David 'Digit' Turner5553d512019-04-15 08:54:05705# Regular expression used to replace the lang attribute inside .xtb files.
706_RE_TRANSLATIONBUNDLE = re.compile('<translationbundle lang="(.*)">')
707
708
709def _CreateFakeXtbFileFrom(src_xtb_path, dst_xtb_path, dst_locale):
710 """Create a fake .xtb file.
711
712 Args:
713 src_xtb_path: Path to source .xtb file to copy from.
714 dst_xtb_path: Path to destination .xtb file to write to.
715 dst_locale: Destination locale, the lang attribute in the source file
716 will be substituted with this value before its lines are written
717 to the destination file.
718 """
719 with open(src_xtb_path) as f:
720 src_xtb_lines = f.readlines()
721
722 def replace_xtb_lang_attribute(line):
723 m = _RE_TRANSLATIONBUNDLE.search(line)
724 if not m:
725 return line
726 return line[:m.start(1)] + dst_locale + line[m.end(1):]
727
728 dst_xtb_lines = [replace_xtb_lang_attribute(line) for line in src_xtb_lines]
729 with build_utils.AtomicOutput(dst_xtb_path) as tmp:
730 tmp.writelines(dst_xtb_lines)
731
732
David 'Digit' Turner00b5f272019-04-10 21:15:27733def _AddMissingLocalesInGrdTranslations(grd_file, grd_lines, wanted_locales):
734 """Fix an input .grd line by adding missing Android outputs.
735
David 'Digit' Turner5553d512019-04-15 08:54:05736 This also creates fake .xtb files from the one provided for 'en-GB'.
737
David 'Digit' Turner00b5f272019-04-10 21:15:27738 Args:
739 grd_file: Input .grd file path.
740 grd_lines: Input .grd line list.
741 wanted_locales: set of Chromium locale names.
742 Returns:
743 A new list of .grd lines, containing new <output> elements when needed
744 for locales from |wanted_locales| that were not part of the input.
745 """
746 wanted_locales = wanted_locales - set([_DEFAULT_LOCALE])
747 intervals = _BuildIntervalList(grd_lines, _IsTranslationGrdOutputLine)
748 for start, end in reversed(intervals):
749 locales = set()
750 for pos in xrange(start, end):
751 lang = _RE_LANG_ATTRIBUTE.search(grd_lines[pos]).group(1)
752 locale = _FixChromiumLangAttribute(lang)
753 locales.add(locale)
754
755 missing_locales = wanted_locales.difference(locales)
756 if not missing_locales:
757 continue
758
David 'Digit' Turner5553d512019-04-15 08:54:05759 src_locale = 'en-GB'
David 'Digit' Turner00b5f272019-04-10 21:15:27760 src_lang_attribute = 'lang="%s"' % src_locale
761 src_line = None
762 for pos in xrange(start, end):
763 if src_lang_attribute in grd_lines[pos]:
764 src_line = grd_lines[pos]
765 break
766
767 if not src_line:
768 raise Exception(
769 'Cannot find <file> element with "%s" lang attribute' % src_locale)
770
David 'Digit' Turner5553d512019-04-15 08:54:05771 src_path = os.path.join(
772 os.path.dirname(grd_file),
773 _RE_PATH_ATTRIBUTE.search(src_line).group(1))
774
David 'Digit' Turner00b5f272019-04-10 21:15:27775 line_count = end - 1
776 for locale in missing_locales:
777 dst_line = src_line.replace(
778 'lang="%s"' % src_locale, 'lang="%s"' % locale).replace(
779 '_%s.xtb' % src_locale, '_%s.xtb' % locale)
780 grd_lines.insert(line_count, dst_line)
781 line_count += 1
782
David 'Digit' Turner5553d512019-04-15 08:54:05783 dst_path = src_path.replace('_%s.xtb' % src_locale, '_%s.xtb' % locale)
784 _CreateFakeXtbFileFrom(src_path, dst_path, locale)
785
786
David 'Digit' Turner00b5f272019-04-10 21:15:27787 # Sort the new <output> elements.
788 return _SortGrdElementsRanges(grd_lines, _IsTranslationGrdOutputLine)
789
790
791##########################################################################
792##########################################################################
793#####
794##### G N A N D R O I D O U T P U T S
795#####
796##########################################################################
797##########################################################################
798
799_RE_GN_VALUES_LIST_LINE = re.compile(
800 r'^\s*".*values(\-([A-Za-z0-9-]+))?/.*\.xml",\s*$')
801
802def _IsBuildGnInputFile(input_file):
803 """Returns True iff this is a BUILD.gn file."""
804 return os.path.basename(input_file) == 'BUILD.gn'
805
806
807def _GetAndroidGnOutputLocale(line):
808 """Check a GN list, and return its Android locale if it is an output .xml"""
809 m = _RE_GN_VALUES_LIST_LINE.match(line)
810 if not m:
811 return None
812
813 if m.group(1): # First group is optional and contains group 2.
814 return m.group(2)
815
816 return resource_utils.ToAndroidLocaleName(_DEFAULT_LOCALE)
817
818
819def _IsAndroidGnOutputLine(line):
820 """Returns True iff this is an Android-specific localized .xml output."""
821 return _GetAndroidGnOutputLocale(line) != None
822
823
824def _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
825 """Check that a range of GN lines corresponds to localized strings.
826
827 Special case: Some BUILD.gn files list several non-localized .xml files
828 that should be ignored by this function, e.g. in
829 components/cronet/android/BUILD.gn, the following appears:
830
831 inputs = [
832 ...
833 "sample/res/layout/activity_main.xml",
834 "sample/res/layout/dialog_url.xml",
835 "sample/res/values/dimens.xml",
836 "sample/res/values/strings.xml",
837 ...
838 ]
839
840 These are non-localized strings, and should be ignored. This function is
841 used to detect them quickly.
842 """
843 for pos in xrange(start, end):
844 if not 'values/' in gn_lines[pos]:
845 return True
846 return False
847
848
849def _CheckGnOutputsRange(gn_lines, start, end, wanted_locales):
850 if not _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
851 return []
852
853 errors = []
854 locales = set()
855 for pos in xrange(start, end):
856 line = gn_lines[pos]
857 android_locale = _GetAndroidGnOutputLocale(line)
858 assert android_locale != None
859 cr_locale = resource_utils.ToChromiumLocaleName(android_locale)
860 if cr_locale in locales:
861 errors.append('%s: Redefinition of output for "%s" locale' %
862 (pos + 1, android_locale))
863 locales.add(cr_locale)
864
865 extra_locales = locales.difference(wanted_locales)
866 if extra_locales:
867 errors.append('%d-%d: Extra locales: %s' % (start + 1, end + 1,
868 sorted(extra_locales)))
869
870 missing_locales = wanted_locales.difference(locales)
871 if missing_locales:
872 errors.append('%d-%d: Missing locales: %s' % (start + 1, end + 1,
873 sorted(missing_locales)))
874
875 return errors
876
877
878def _CheckGnAndroidOutputs(gn_file, gn_lines, wanted_locales):
879 intervals = _BuildIntervalList(gn_lines, _IsAndroidGnOutputLine)
880 errors = []
881 for start, end in intervals:
882 errors += _CheckGnOutputsRange(gn_lines, start, end, wanted_locales)
883 return errors
884
885
886def _AddMissingLocalesInGnAndroidOutputs(gn_file, gn_lines, wanted_locales):
887 intervals = _BuildIntervalList(gn_lines, _IsAndroidGnOutputLine)
888 # NOTE: Since this may insert new lines to each interval, process the
889 # list in reverse order to maintain valid (start,end) positions during
890 # the iteration.
891 for start, end in reversed(intervals):
892 if not _CheckGnOutputsRangeForLocalizedStrings(gn_lines, start, end):
893 continue
894
895 locales = set()
896 for pos in xrange(start, end):
897 lang = _GetAndroidGnOutputLocale(gn_lines[pos])
898 locale = resource_utils.ToChromiumLocaleName(lang)
899 locales.add(locale)
900
901 missing_locales = wanted_locales.difference(locales)
902 if not missing_locales:
903 continue
904
905 src_locale = 'bg'
906 src_values = 'values-%s/' % resource_utils.ToAndroidLocaleName(src_locale)
907 src_line = None
908 for pos in xrange(start, end):
909 if src_values in gn_lines[pos]:
910 src_line = gn_lines[pos]
911 break
912
913 if not src_line:
914 raise Exception(
915 'Cannot find output list item with "%s" locale' % src_locale)
916
917 line_count = end - 1
918 for locale in missing_locales:
919 if locale == _DEFAULT_LOCALE:
920 dst_line = src_line.replace('values-%s/' % src_locale, 'values/')
921 else:
922 dst_line = src_line.replace(
923 'values-%s/' % src_locale,
924 'values-%s/' % resource_utils.ToAndroidLocaleName(locale))
925 gn_lines.insert(line_count, dst_line)
926 line_count += 1
927
928 gn_lines = _SortListSubRange(
929 gn_lines, start, line_count,
930 lambda line: _RE_GN_VALUES_LIST_LINE.match(line).group(1))
931
932 return gn_lines
933
934
935# pylint: enable=unused-argument
936
937##########################################################################
938##########################################################################
939#####
940##### C H E C K E V E R Y T H I N G
941#####
942##########################################################################
943##########################################################################
944
945def _IsAllInputFile(input_file):
946 return _IsGritInputFile(input_file) or _IsBuildGnInputFile(input_file)
947
948
949def _CheckAllFiles(input_file, input_lines, wanted_locales):
950 errors = []
951 if _IsGritInputFile(input_file):
952 errors += _CheckGrdTranslations(input_file, input_lines, wanted_locales)
953 errors += _CheckGrdAndroidOutputElements(
954 input_file, input_lines, wanted_locales)
955 elif _IsBuildGnInputFile(input_file):
956 errors += _CheckGnAndroidOutputs(input_file, input_lines, wanted_locales)
957 return errors
958
959
960def _AddMissingLocalesInAllFiles(input_file, input_lines, wanted_locales):
961 if _IsGritInputFile(input_file):
962 lines = _AddMissingLocalesInGrdTranslations(
963 input_file, input_lines, wanted_locales)
964 lines = _AddMissingLocalesInGrdAndroidOutputs(
965 input_file, lines, wanted_locales)
966 elif _IsBuildGnInputFile(input_file):
967 lines = _AddMissingLocalesInGnAndroidOutputs(
968 input_file, input_lines, wanted_locales)
969 return lines
970
David 'Digit' Turner5553d512019-04-15 08:54:05971
David 'Digit' Turner00b5f272019-04-10 21:15:27972##########################################################################
973##########################################################################
974#####
975##### C O M M A N D H A N D L I N G
976#####
977##########################################################################
978##########################################################################
979
980class _Command(object):
981 """A base class for all commands recognized by this script.
982
983 Usage is the following:
984 1) Derived classes must re-define the following class-based fields:
985 - name: Command name (e.g. 'list-locales')
986 - description: Command short description.
987 - long_description: Optional. Command long description.
988 NOTE: As a convenience, if the first character is a newline,
989 it will be omitted in the help output.
990
991 2) Derived classes for commands that take arguments should override
992 RegisterExtraArgs(), which receives a corresponding argparse
993 sub-parser as argument.
994
995 3) Derived classes should implement a Run() command, which can read
996 the current arguments from self.args.
997 """
998 name = None
999 description = None
1000 long_description = None
1001
1002 def __init__(self):
1003 self._parser = None
1004 self.args = None
1005
1006 def RegisterExtraArgs(self, subparser):
1007 pass
1008
1009 def RegisterArgs(self, parser):
1010 subp = parser.add_parser(
1011 self.name, help=self.description,
1012 description=self.long_description or self.description,
1013 formatter_class=argparse.RawDescriptionHelpFormatter)
1014 self._parser = subp
1015 subp.set_defaults(command=self)
1016 group = subp.add_argument_group('%s arguments' % self.name)
1017 self.RegisterExtraArgs(group)
1018
1019 def ProcessArgs(self, args):
1020 self.args = args
1021
1022
1023class _ListLocalesCommand(_Command):
1024 """Implement the 'list-locales' command to list locale lists of interest."""
1025 name = 'list-locales'
1026 description = 'List supported Chrome locales'
1027 long_description = r'''
1028List locales of interest, by default this prints all locales supported by
1029Chrome, but `--type=android_omitted` can be used to print the list of locales
1030omitted from Android APKs (but not app bundles), and `--type=ios_unsupported`
1031for the list of locales unsupported on iOS.
1032
1033These values are extracted directly from build/config/locales.gni.
1034
1035Additionally, use the --as-json argument to print the list as a JSON list,
1036instead of the default format (which is a space-separated list of locale names).
1037'''
1038
1039 # Maps type argument to a function returning the corresponding locales list.
1040 TYPE_MAP = {
1041 'all': ChromeLocales,
1042 'android_omitted': AndroidOmittedLocales,
1043 'ios_unsupported': IosUnsupportedLocales,
1044 }
1045
1046 def RegisterExtraArgs(self, group):
1047 group.add_argument(
1048 '--as-json',
1049 action='store_true',
1050 help='Output as JSON list.')
1051 group.add_argument(
1052 '--type',
1053 choices=tuple(self.TYPE_MAP.viewkeys()),
1054 default='all',
1055 help='Select type of locale list to print.')
1056
1057 def Run(self):
1058 locale_list = self.TYPE_MAP[self.args.type]()
1059 if self.args.as_json:
1060 print '[%s]' % ", ".join("'%s'" % loc for loc in locale_list)
1061 else:
1062 print ' '.join(locale_list)
1063
1064
1065class _CheckInputFileBaseCommand(_Command):
1066 """Used as a base for other _Command subclasses that check input files.
1067
1068 Subclasses should also define the following class-level variables:
1069
1070 - select_file_func:
1071 A predicate that receives a file name (not path) and return True if it
1072 should be selected for inspection. Used when scanning directories with
1073 '--scan-dir <dir>'.
1074
1075 - check_func:
1076 - fix_func:
1077 Two functions passed as parameters to _ProcessFile(), see relevant
1078 documentation in this function's definition.
1079 """
1080 select_file_func = None
1081 check_func = None
1082 fix_func = None
1083
1084 def RegisterExtraArgs(self, group):
1085 group.add_argument(
1086 '--scan-dir',
1087 action='append',
1088 help='Optional directory to scan for input files recursively.')
1089 group.add_argument(
1090 'input',
1091 nargs='*',
1092 help='Input file(s) to check.')
1093 group.add_argument(
1094 '--fix-inplace',
1095 action='store_true',
1096 help='Try to fix the files in-place too.')
1097 group.add_argument(
1098 '--add-locales',
1099 help='Space-separated list of additional locales to use')
1100
1101 def Run(self):
1102 args = self.args
1103 input_files = []
1104 if args.input:
1105 input_files = args.input
1106 if args.scan_dir:
1107 input_files.extend(_ScanDirectoriesForFiles(
1108 args.scan_dir, self.select_file_func.__func__))
1109 locales = ChromeLocales()
1110 if args.add_locales:
1111 locales.extend(args.add_locales.split(' '))
1112
1113 locales = set(locales)
1114
1115 for input_file in input_files:
1116 _ProcessFile(input_file,
1117 locales,
1118 self.check_func.__func__,
1119 self.fix_func.__func__ if args.fix_inplace else None)
1120 print '%sDone.' % (_CONSOLE_START_LINE)
1121
1122
1123class _CheckGrdAndroidOutputsCommand(_CheckInputFileBaseCommand):
1124 name = 'check-grd-android-outputs'
1125 description = (
1126 'Check the Android resource (.xml) files outputs in GRIT input files.')
1127 long_description = r'''
1128Check the Android .xml files outputs in one or more input GRIT (.grd) files
1129for the following conditions:
1130
1131 - Each item has a correct 'lang' attribute.
1132 - There are no duplicated lines for the same 'lang' attribute.
1133 - That there are no extra locales that Chromium doesn't want.
1134 - That no wanted locale is missing.
1135 - Filenames exist for each listed locale.
1136 - Filenames are well-formed.
1137'''
1138 select_file_func = _IsGritInputFile
1139 check_func = _CheckGrdAndroidOutputElements
1140 fix_func = _AddMissingLocalesInGrdAndroidOutputs
1141
1142
1143class _CheckGrdTranslationsCommand(_CheckInputFileBaseCommand):
1144 name = 'check-grd-translations'
1145 description = (
1146 'Check the translation (.xtb) files outputted by .grd input files.')
1147 long_description = r'''
1148Check the translation (.xtb) file outputs in one or more input GRIT (.grd) files
1149for the following conditions:
1150
1151 - Each item has a correct 'lang' attribute.
1152 - There are no duplicated lines for the same 'lang' attribute.
1153 - That there are no extra locales that Chromium doesn't want.
1154 - That no wanted locale is missing.
1155 - Each item has a 'path' attribute.
1156 - Each such path value ends up with '.xtb'.
1157'''
1158 select_file_func = _IsGritInputFile
1159 check_func = _CheckGrdTranslations
1160 fix_func = _AddMissingLocalesInGrdTranslations
1161
1162
1163class _CheckGnAndroidOutputsCommand(_CheckInputFileBaseCommand):
1164 name = 'check-gn-android-outputs'
1165 description = 'Check the Android .xml file lists in GN build files.'
1166 long_description = r'''
1167Check one or more BUILD.gn file, looking for lists of Android resource .xml
1168files, and checking that:
1169
1170 - There are no duplicated output files in the list.
1171 - Each output file belongs to a wanted Chromium locale.
1172 - There are no output files for unwanted Chromium locales.
1173'''
1174 select_file_func = _IsBuildGnInputFile
1175 check_func = _CheckGnAndroidOutputs
1176 fix_func = _AddMissingLocalesInGnAndroidOutputs
1177
1178
1179class _CheckAllCommand(_CheckInputFileBaseCommand):
1180 name = 'check-all'
1181 description = 'Check everything.'
1182 long_description = 'Equivalent to calling all other check-xxx commands.'
1183 select_file_func = _IsAllInputFile
1184 check_func = _CheckAllFiles
1185 fix_func = _AddMissingLocalesInAllFiles
1186
1187
1188# List of all commands supported by this script.
1189_COMMANDS = [
1190 _ListLocalesCommand,
1191 _CheckGrdAndroidOutputsCommand,
1192 _CheckGrdTranslationsCommand,
1193 _CheckGnAndroidOutputsCommand,
1194 _CheckAllCommand,
1195]
1196
1197
1198def main(argv):
1199 parser = argparse.ArgumentParser(
1200 description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
1201
1202 subparsers = parser.add_subparsers()
1203 commands = [clazz() for clazz in _COMMANDS]
1204 for command in commands:
1205 command.RegisterArgs(subparsers)
1206
1207 if not argv:
1208 argv = ['--help']
1209
1210 args = parser.parse_args(argv)
1211 args.command.ProcessArgs(args)
1212 args.command.Run()
1213
1214
1215if __name__ == "__main__":
1216 main(sys.argv[1:])