Skip to content

Commit cc06bf7

Browse files
committed
Improve i18n_tool.py list-translators, update-from-weblate
Allow the specification of a commit whose timestamp will be used as the starting point for gathering translator credits, instead of starting from the last commit that says "l10n: sync". That breaks badly if there are any source string changes during the release cycle that require another sync, so instead, just examine all the commits since the time of the specified revision, defaulting to the last release tag. Also, instead of always gathering translator contributions up to the tip of i18n/i18n for list-translators and update-from-weblate, allow the specification of a commit. This is intended to allow verification of these functions' results; release management tasks should use the default target of the i18n/i18n branch tip.
1 parent a0b53d3 commit cc06bf7

1 file changed

Lines changed: 124 additions & 61 deletions

File tree

securedrop/i18n_tool.py

Lines changed: 124 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import textwrap
1414
from argparse import _SubParsersAction
1515
from typing import Optional
16-
from typing import Union
1716

1817
from typing import Set
1918

@@ -30,7 +29,6 @@
3029

3130

3231
class I18NTool:
33-
3432
#
3533
# The database of support language, indexed by the language code
3634
# used by weblate (i.e. whatever shows as CODE in
@@ -41,7 +39,7 @@ class I18NTool:
4139
# display in the interface.
4240
# desktop: The language code used for dekstop icons.
4341
#
44-
SUPPORTED_LANGUAGES = {
42+
supported_languages = {
4543
'ar': {'name': 'Arabic', 'desktop': 'ar', },
4644
'ca': {'name': 'Catalan', 'desktop': 'ca', },
4745
'cs': {'name': 'Czech', 'desktop': 'cs', },
@@ -62,12 +60,18 @@ class I18NTool:
6260
'tr': {'name': 'Turkish', 'desktop': 'tr', },
6361
'zh_Hant': {'name': 'Chinese, Traditional', 'desktop': 'zh_Hant', },
6462
}
63+
release_tag_re = re.compile(r"^\d+\.\d+\.\d+$")
64+
translated_commit_re = re.compile('Translated using Weblate')
65+
updated_commit_re = re.compile(r'(?:updated from| (?:revision|commit):) (\w+)')
6566

66-
def file_is_modified(self, path: str) -> int:
67-
dir = dirname(path)
68-
return subprocess.call(['git', '-C', dir, 'diff', '--quiet', path])
67+
def file_is_modified(self, path: str) -> bool:
68+
return bool(subprocess.call(['git', '-C', dirname(path), 'diff', '--quiet', path]))
6969

7070
def ensure_i18n_remote(self, args: argparse.Namespace) -> None:
71+
"""
72+
Make sure we have a git remote for the i18n repo.
73+
"""
74+
7175
k = {'_cwd': args.root}
7276
if b'i18n' not in git.remote(**k).stdout:
7377
git.remote.add('i18n', args.url, **k)
@@ -219,7 +223,7 @@ def require_git_email_name(git_dir: str) -> bool:
219223

220224
def update_docs(self, args: argparse.Namespace) -> None:
221225
l10n_content = u'.. GENERATED BY i18n_tool.py DO NOT EDIT:\n\n'
222-
for (code, info) in sorted(I18NTool.SUPPORTED_LANGUAGES.items()):
226+
for (code, info) in sorted(self.supported_languages.items()):
223227
l10n_content += '* ' + info['name'] + ' (``' + code + '``)\n'
224228
includes = abspath(join(args.docs_repo_dir, 'docs/includes'))
225229
l10n_txt = join(includes, 'l10n.txt')
@@ -246,73 +250,90 @@ def set_update_docs_parser(self, subps: _SubParsersAction) -> None:
246250
parser.set_defaults(func=self.update_docs)
247251

248252
def update_from_weblate(self, args: argparse.Namespace) -> None:
253+
"""
254+
Pull in updated translations from the i18n repo.
255+
"""
249256
self.ensure_i18n_remote(args)
250-
codes = list(I18NTool.SUPPORTED_LANGUAGES.keys())
257+
codes = list(self.supported_languages.keys())
251258
if args.supported_languages:
252259
codes = args.supported_languages.split(',')
253260
for code in sorted(codes):
254-
info = I18NTool.SUPPORTED_LANGUAGES[code]
261+
info = self.supported_languages[code]
255262

256-
def need_update(p: str) -> Union[bool, int]:
257-
exists = os.path.exists(join(args.root, p))
263+
def need_update(path: str) -> bool:
264+
"""
265+
Check if the file is different in the i18n repo.
266+
"""
267+
268+
exists = os.path.exists(join(args.root, path))
258269
k = {'_cwd': args.root}
259-
git.checkout('i18n/i18n', '--', p, **k)
260-
git.reset('HEAD', '--', p, **k)
270+
git.checkout(args.target, '--', path, **k)
271+
git.reset('HEAD', '--', path, **k)
261272
if not exists:
262273
return True
263-
else:
264-
return self.file_is_modified(join(args.root, p))
265274

266-
def add(p: str) -> None:
267-
git('-C', args.root, 'add', p)
275+
return self.file_is_modified(join(args.root, path))
276+
277+
def add(path: str) -> None:
278+
"""
279+
Add the file to the git index.
280+
"""
281+
git('-C', args.root, 'add', path)
268282

269283
updated = False
270284
#
271-
# Update messages
285+
# Add changes to web .po files
272286
#
273-
p = "securedrop/translations/{l}/LC_MESSAGES/messages.po".format(
287+
path = "securedrop/translations/{l}/LC_MESSAGES/messages.po".format(
274288
l=code) # noqa: E741
275-
if need_update(p):
276-
add(p)
289+
if need_update(path):
290+
add(path)
277291
updated = True
278292
#
279-
# Update desktop
293+
# Add changes to desktop .po files
280294
#
281295
desktop_code = info['desktop']
282-
p = join("install_files/ansible-base/roles",
296+
path = join("install_files/ansible-base/roles",
283297
"tails-config/templates/{l}.po".format(
284298
l=desktop_code)) # noqa: E741
285-
if need_update(p):
286-
add(p)
299+
if need_update(path):
300+
add(path)
287301
updated = True
288302

289303
if updated:
290-
self.upstream_commit(args, code)
304+
self.commit_changes(args, code)
291305

292-
def translators(self, args: argparse.Namespace, path: str, commit_range: str) -> Set[str]:
306+
def translators(self, args: argparse.Namespace, path: str, since: str) -> Set[str]:
293307
"""
294308
Return the set of people who've modified a file in Weblate.
295309
296310
Extracts all the authors of translation changes to the given
297-
path in the given commit range. Translation changes are
311+
path since the given timestamp. Translation changes are
298312
identified by the presence of "Translated using Weblate" in
299313
the commit message.
300314
"""
301-
translation_re = re.compile('Translated using Weblate')
302315

303-
path_changes = git(
304-
'--no-pager', '-C', args.root,
305-
'log', '--format=%aN\x1e%s', commit_range, '--', path,
306-
_encoding='utf-8'
307-
)
316+
if since:
317+
path_changes = git(
318+
'--no-pager', '-C', args.root,
319+
'log', '--format=%aN\x1e%s', '--since', since, args.target, '--', path,
320+
_encoding='utf-8'
321+
)
322+
else:
323+
path_changes = git(
324+
'--no-pager', '-C', args.root,
325+
'log', '--format=%aN\x1e%s', args.target, '--', path,
326+
_encoding='utf-8'
327+
)
308328
path_changes = u"{}".format(path_changes)
309329
path_changes = [c.split('\x1e') for c in path_changes.strip().split('\n')]
310-
path_changes = [c for c in path_changes if len(c) > 1 and translation_re.match(c[1])]
311-
330+
path_changes = [
331+
c for c in path_changes if len(c) > 1 and self.translated_commit_re.match(c[1])
332+
]
312333
path_authors = [c[0] for c in path_changes]
313334
return set(path_authors)
314335

315-
def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
336+
def commit_changes(self, args: argparse.Namespace, code: str) -> None:
316337
self.require_git_email_name(args.root)
317338
authors = set() # type: Set[str]
318339
diffs = u"{}".format(git('--no-pager', '-C', args.root, 'diff', '--name-only', '--cached'))
@@ -321,18 +342,18 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
321342
previous_message = u"{}".format(git(
322343
'--no-pager', '-C', args.root, 'log', '-n', '1', path,
323344
_encoding='utf-8'))
324-
update_re = re.compile(r'(?:updated from| revision:) (\w+)')
325-
m = update_re.search(previous_message)
345+
m = self.updated_commit_re.search(previous_message)
326346
if m:
327347
origin = m.group(1)
328348
else:
329349
origin = ''
330-
authors |= self.translators(args, path, '{}..i18n/i18n'.format(origin))
350+
since = self.get_commit_timestamp(origin)
351+
authors |= self.translators(args, path, since)
331352

332353
authors_as_str = u"\n ".join(sorted(authors))
333354

334-
current = git('-C', args.root, 'rev-parse', 'i18n/i18n')
335-
info = I18NTool.SUPPORTED_LANGUAGES[code]
355+
current = git('-C', args.root, 'rev-parse', args.target)
356+
info = self.supported_languages[code]
336357
message = textwrap.dedent(u"""
337358
l10n: updated {name} ({code})
338359
@@ -341,7 +362,7 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
341362
342363
updated from:
343364
repo: {remote}
344-
revision: {current}
365+
commit: {current}
345366
""").format(
346367
remote=args.url,
347368
name=info['name'],
@@ -366,6 +387,14 @@ def set_update_from_weblate_parser(self, subps: _SubParsersAction) -> None:
366387
default=url,
367388
help=('URL of the weblate repository'
368389
' (default {})'.format(url)))
390+
parser.add_argument(
391+
'--target',
392+
default="i18n/i18n",
393+
help=(
394+
'Commit on i18n branch at which to stop gathering translator contributions '
395+
'(default: i18n/i18n)'
396+
)
397+
)
369398
parser.add_argument(
370399
'--supported-languages',
371400
help='comma separated list of supported languages')
@@ -387,12 +416,12 @@ def set_list_locales_parser(self, subps: _SubParsersAction) -> None:
387416

388417
def list_locales(self, args: argparse.Namespace) -> None:
389418
if args.lines:
390-
for l in sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']):
419+
for l in sorted(list(self.supported_languages.keys()) + ['en_US']):
391420
print(l)
392421
elif args.python:
393-
print(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']))
422+
print(sorted(list(self.supported_languages.keys()) + ['en_US']))
394423
else:
395-
print(" ".join(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US'])))
424+
print(" ".join(sorted(list(self.supported_languages.keys()) + ['en_US'])))
396425

397426
def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
398427
parser = subps.add_parser('list-translators',
@@ -409,50 +438,84 @@ def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
409438
default=url,
410439
help=('URL of the weblate repository'
411440
' (default {})'.format(url)))
441+
parser.add_argument(
442+
'--target',
443+
default="i18n/i18n",
444+
help=(
445+
'Commit on i18n branch at which to stop gathering translator contributions '
446+
'(default: i18n/i18n)'
447+
)
448+
)
449+
last_release = self.get_last_release()
450+
parser.add_argument(
451+
'--since',
452+
default=last_release,
453+
help=(
454+
'Gather translator contributions from the time of this commit '
455+
'(default: {})'.format(last_release)
456+
)
457+
)
412458
parser.add_argument(
413459
'--all',
414460
action="store_true",
415461
help=(
416462
"List everyone who's ever contributed, instead of just since the last "
417-
"sync from Weblate."
463+
"release or specified commit."
418464
)
419465
)
420466
parser.set_defaults(func=self.list_translators)
421467

422-
def get_last_sync(self) -> Optional[str]:
423-
commits = git('--no-pager', 'log', '--format=%h:%s', 'i18n/i18n', _encoding='utf-8')
424-
for commit in commits:
425-
commit_hash, msg = commit.split(':', 1)
426-
if msg.startswith("l10n: sync "):
427-
return commit_hash
428-
return None
468+
def get_last_release(self) -> Optional[str]:
469+
"""
470+
Returns the last release tag, e.g. 1.5.0.
471+
"""
472+
tags = subprocess.check_output(["git", "tag"]).decode("utf-8").splitlines()
473+
release_tags = sorted([t.strip() for t in tags if self.release_tag_re.match(t)])
474+
if not release_tags:
475+
raise ValueError("Could not find a release tag!")
476+
return release_tags[-1]
477+
478+
def get_commit_timestamp(self, commit):
479+
timestamp = subprocess.check_output(
480+
["git", "log", "-n", "1", '--pretty=format:%ct', commit]
481+
)
482+
return timestamp.decode("utf-8").strip()
429483

430484
def list_translators(self, args: argparse.Namespace) -> None:
431485
self.ensure_i18n_remote(args)
432486
app_template = "securedrop/translations/{}/LC_MESSAGES/messages.po"
433487
desktop_template = "install_files/ansible-base/roles/tails-config/templates/{}.po"
434-
last_sync = self.get_last_sync()
435-
for code, info in sorted(I18NTool.SUPPORTED_LANGUAGES.items()):
488+
since = self.get_commit_timestamp(args.since) if not args.all else None
489+
if args.all:
490+
print("Listing all translators who have ever helped")
491+
else:
492+
print("Listing translators who have helped since {}".format(args.since))
493+
for code, info in sorted(self.supported_languages.items()):
436494
translators = set([])
437495
paths = [
438496
app_template.format(code),
439497
desktop_template.format(info["desktop"]),
440498
]
441499
for path in paths:
442500
try:
443-
commit_range = "i18n/i18n"
444-
if last_sync and not args.all:
445-
commit_range = '{}..{}'.format(last_sync, commit_range)
446-
t = self.translators(args, path, commit_range)
501+
t = self.translators(args, path, since)
447502
translators.update(t)
448503
except Exception as e:
449504
print("Could not check git history of {}: {}".format(path, e), file=sys.stderr)
450-
print(u"{} ({}):\n {}".format(code, info["name"], "\n ".join(sorted(translators))))
505+
print(
506+
"{} ({}):{}".format(
507+
code, info["name"],
508+
"\n {}\n".format(
509+
"\n ".join(sorted(translators))) if translators else "\n"
510+
)
511+
)
451512

452513
def get_args(self) -> argparse.ArgumentParser:
453514
parser = argparse.ArgumentParser(
454515
prog=__file__,
455516
description='i18n tool for SecureDrop.')
517+
parser.set_defaults(func=lambda args: parser.print_help())
518+
456519
parser.add_argument('-v', '--verbose', action='store_true')
457520
subps = parser.add_subparsers()
458521

0 commit comments

Comments
 (0)