Skip to content

Commit 73a5a22

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 ce22827 commit 73a5a22

1 file changed

Lines changed: 132 additions & 63 deletions

File tree

securedrop/i18n_tool.py

Lines changed: 132 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212
import sys
1313
import textwrap
1414
from argparse import _SubParsersAction
15-
from typing import Optional
16-
from typing import Union
1715

1816
from typing import Set
1917

@@ -30,7 +28,6 @@
3028

3129

3230
class I18NTool:
33-
3431
#
3532
# The database of support language, indexed by the language code
3633
# used by weblate (i.e. whatever shows as CODE in
@@ -41,7 +38,7 @@ class I18NTool:
4138
# display in the interface.
4239
# desktop: The language code used for dekstop icons.
4340
#
44-
SUPPORTED_LANGUAGES = {
41+
supported_languages = {
4542
'ar': {'name': 'Arabic', 'desktop': 'ar', },
4643
'ca': {'name': 'Catalan', 'desktop': 'ca', },
4744
'cs': {'name': 'Czech', 'desktop': 'cs', },
@@ -62,12 +59,18 @@ class I18NTool:
6259
'tr': {'name': 'Turkish', 'desktop': 'tr', },
6360
'zh_Hant': {'name': 'Chinese, Traditional', 'desktop': 'zh_Hant', },
6461
}
62+
release_tag_re = re.compile(r"^\d+\.\d+\.\d+$")
63+
translated_commit_re = re.compile('Translated using Weblate')
64+
updated_commit_re = re.compile(r'(?:updated from| (?:revision|commit):) (\w+)')
6565

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

7069
def ensure_i18n_remote(self, args: argparse.Namespace) -> None:
70+
"""
71+
Make sure we have a git remote for the i18n repo.
72+
"""
73+
7174
k = {'_cwd': args.root}
7275
if b'i18n' not in git.remote(**k).stdout:
7376
git.remote.add('i18n', args.url, **k)
@@ -219,7 +222,7 @@ def require_git_email_name(git_dir: str) -> bool:
219222

220223
def update_docs(self, args: argparse.Namespace) -> None:
221224
l10n_content = u'.. GENERATED BY i18n_tool.py DO NOT EDIT:\n\n'
222-
for (code, info) in sorted(I18NTool.SUPPORTED_LANGUAGES.items()):
225+
for (code, info) in sorted(self.supported_languages.items()):
223226
l10n_content += '* ' + info['name'] + ' (``' + code + '``)\n'
224227
includes = abspath(join(args.docs_repo_dir, 'docs/includes'))
225228
l10n_txt = join(includes, 'l10n.txt')
@@ -246,73 +249,90 @@ def set_update_docs_parser(self, subps: _SubParsersAction) -> None:
246249
parser.set_defaults(func=self.update_docs)
247250

248251
def update_from_weblate(self, args: argparse.Namespace) -> None:
252+
"""
253+
Pull in updated translations from the i18n repo.
254+
"""
249255
self.ensure_i18n_remote(args)
250-
codes = list(I18NTool.SUPPORTED_LANGUAGES.keys())
256+
codes = list(self.supported_languages.keys())
251257
if args.supported_languages:
252258
codes = args.supported_languages.split(',')
253259
for code in sorted(codes):
254-
info = I18NTool.SUPPORTED_LANGUAGES[code]
260+
info = self.supported_languages[code]
261+
262+
def need_update(path: str) -> bool:
263+
"""
264+
Check if the file is different in the i18n repo.
265+
"""
255266

256-
def need_update(p: str) -> Union[bool, int]:
257-
exists = os.path.exists(join(args.root, p))
267+
exists = os.path.exists(join(args.root, path))
258268
k = {'_cwd': args.root}
259-
git.checkout('i18n/i18n', '--', p, **k)
260-
git.reset('HEAD', '--', p, **k)
269+
git.checkout(args.target, '--', path, **k)
270+
git.reset('HEAD', '--', path, **k)
261271
if not exists:
262272
return True
263-
else:
264-
return self.file_is_modified(join(args.root, p))
265273

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

269282
updated = False
270283
#
271-
# Update messages
284+
# Add changes to web .po files
272285
#
273-
p = "securedrop/translations/{l}/LC_MESSAGES/messages.po".format(
286+
path = "securedrop/translations/{l}/LC_MESSAGES/messages.po".format(
274287
l=code) # noqa: E741
275-
if need_update(p):
276-
add(p)
288+
if need_update(path):
289+
add(path)
277290
updated = True
278291
#
279-
# Update desktop
292+
# Add changes to desktop .po files
280293
#
281294
desktop_code = info['desktop']
282-
p = join("install_files/ansible-base/roles",
295+
path = join("install_files/ansible-base/roles",
283296
"tails-config/templates/{l}.po".format(
284297
l=desktop_code)) # noqa: E741
285-
if need_update(p):
286-
add(p)
298+
if need_update(path):
299+
add(path)
287300
updated = True
288301

289302
if updated:
290-
self.upstream_commit(args, code)
303+
self.commit_changes(args, code)
291304

292-
def translators(self, args: argparse.Namespace, path: str, commit_range: str) -> Set[str]:
305+
def translators(self, args: argparse.Namespace, path: str, since: str) -> Set[str]:
293306
"""
294307
Return the set of people who've modified a file in Weblate.
295308
296309
Extracts all the authors of translation changes to the given
297-
path in the given commit range. Translation changes are
310+
path since the given timestamp. Translation changes are
298311
identified by the presence of "Translated using Weblate" in
299312
the commit message.
300313
"""
301-
translation_re = re.compile('Translated using Weblate')
302314

303-
path_changes = git(
304-
'--no-pager', '-C', args.root,
305-
'log', '--format=%aN\x1e%s', commit_range, '--', path,
306-
_encoding='utf-8'
307-
)
315+
if since:
316+
path_changes = git(
317+
'--no-pager', '-C', args.root,
318+
'log', '--format=%aN\x1e%s', '--since', since, args.target, '--', path,
319+
_encoding='utf-8'
320+
)
321+
else:
322+
path_changes = git(
323+
'--no-pager', '-C', args.root,
324+
'log', '--format=%aN\x1e%s', args.target, '--', path,
325+
_encoding='utf-8'
326+
)
308327
path_changes = u"{}".format(path_changes)
309328
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-
329+
path_changes = [
330+
c for c in path_changes if len(c) > 1 and self.translated_commit_re.match(c[1])
331+
]
312332
path_authors = [c[0] for c in path_changes]
313333
return set(path_authors)
314334

315-
def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
335+
def commit_changes(self, args: argparse.Namespace, code: str) -> None:
316336
self.require_git_email_name(args.root)
317337
authors = set() # type: Set[str]
318338
diffs = u"{}".format(git('--no-pager', '-C', args.root, 'diff', '--name-only', '--cached'))
@@ -321,18 +341,18 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
321341
previous_message = u"{}".format(git(
322342
'--no-pager', '-C', args.root, 'log', '-n', '1', path,
323343
_encoding='utf-8'))
324-
update_re = re.compile(r'(?:updated from| revision:) (\w+)')
325-
m = update_re.search(previous_message)
344+
m = self.updated_commit_re.search(previous_message)
326345
if m:
327346
origin = m.group(1)
328347
else:
329-
origin = ''
330-
authors |= self.translators(args, path, '{}..i18n/i18n'.format(origin))
348+
origin = None
349+
since = self.get_commit_timestamp(args.root, origin)
350+
authors |= self.translators(args, path, since)
331351

332352
authors_as_str = u"\n ".join(sorted(authors))
333353

334-
current = git('-C', args.root, 'rev-parse', 'i18n/i18n')
335-
info = I18NTool.SUPPORTED_LANGUAGES[code]
354+
current = git('-C', args.root, 'rev-parse', args.target)
355+
info = self.supported_languages[code]
336356
message = textwrap.dedent(u"""
337357
l10n: updated {name} ({code})
338358
@@ -341,7 +361,7 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
341361
342362
updated from:
343363
repo: {remote}
344-
revision: {current}
364+
commit: {current}
345365
""").format(
346366
remote=args.url,
347367
name=info['name'],
@@ -366,6 +386,14 @@ def set_update_from_weblate_parser(self, subps: _SubParsersAction) -> None:
366386
default=url,
367387
help=('URL of the weblate repository'
368388
' (default {})'.format(url)))
389+
parser.add_argument(
390+
'--target',
391+
default="i18n/i18n",
392+
help=(
393+
'Commit on i18n branch at which to stop gathering translator contributions '
394+
'(default: i18n/i18n)'
395+
)
396+
)
369397
parser.add_argument(
370398
'--supported-languages',
371399
help='comma separated list of supported languages')
@@ -387,12 +415,12 @@ def set_list_locales_parser(self, subps: _SubParsersAction) -> None:
387415

388416
def list_locales(self, args: argparse.Namespace) -> None:
389417
if args.lines:
390-
for l in sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']):
418+
for l in sorted(list(self.supported_languages.keys()) + ['en_US']):
391419
print(l)
392420
elif args.python:
393-
print(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']))
421+
print(sorted(list(self.supported_languages.keys()) + ['en_US']))
394422
else:
395-
print(" ".join(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US'])))
423+
print(" ".join(sorted(list(self.supported_languages.keys()) + ['en_US'])))
396424

397425
def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
398426
parser = subps.add_parser('list-translators',
@@ -409,50 +437,91 @@ def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
409437
default=url,
410438
help=('URL of the weblate repository'
411439
' (default {})'.format(url)))
440+
parser.add_argument(
441+
'--target',
442+
default="i18n/i18n",
443+
help=(
444+
'Commit on i18n branch at which to stop gathering translator contributions '
445+
'(default: i18n/i18n)'
446+
)
447+
)
448+
last_release = self.get_last_release(root)
449+
parser.add_argument(
450+
'--since',
451+
default=last_release,
452+
help=(
453+
'Gather translator contributions from the time of this commit '
454+
'(default: {})'.format(last_release)
455+
)
456+
)
412457
parser.add_argument(
413458
'--all',
414459
action="store_true",
415460
help=(
416461
"List everyone who's ever contributed, instead of just since the last "
417-
"sync from Weblate."
462+
"release or specified commit."
418463
)
419464
)
420465
parser.set_defaults(func=self.list_translators)
421466

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
467+
def get_last_release(self, root: str) -> str:
468+
"""
469+
Returns the last release tag, e.g. 1.5.0.
470+
"""
471+
tags = subprocess.check_output(
472+
["git", "-C", root, "tag", "--list"]
473+
).decode("utf-8").splitlines()
474+
release_tags = sorted([t.strip() for t in tags if self.release_tag_re.match(t)])
475+
if not release_tags:
476+
raise ValueError("Could not find a release tag!")
477+
return release_tags[-1]
478+
479+
def get_commit_timestamp(self, root: str, commit: str) -> str:
480+
"""
481+
Returns the UNIX timestamp of the given commit.
482+
"""
483+
cmd = ["git", "-C", root, "log", "-n", "1", '--pretty=format:%ct']
484+
if commit:
485+
cmd.append(commit)
486+
487+
timestamp = subprocess.check_output(cmd)
488+
return timestamp.decode("utf-8").strip()
429489

430490
def list_translators(self, args: argparse.Namespace) -> None:
431491
self.ensure_i18n_remote(args)
432492
app_template = "securedrop/translations/{}/LC_MESSAGES/messages.po"
433493
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()):
494+
since = self.get_commit_timestamp(args.root, args.since) if not args.all else None
495+
if args.all:
496+
print("Listing all translators who have ever helped")
497+
else:
498+
print("Listing translators who have helped since {}".format(args.since))
499+
for code, info in sorted(self.supported_languages.items()):
436500
translators = set([])
437501
paths = [
438502
app_template.format(code),
439503
desktop_template.format(info["desktop"]),
440504
]
441505
for path in paths:
442506
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)
507+
t = self.translators(args, path, since)
447508
translators.update(t)
448509
except Exception as e:
449510
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))))
511+
print(
512+
"{} ({}):{}".format(
513+
code, info["name"],
514+
"\n {}\n".format(
515+
"\n ".join(sorted(translators))) if translators else "\n"
516+
)
517+
)
451518

452519
def get_args(self) -> argparse.ArgumentParser:
453520
parser = argparse.ArgumentParser(
454521
prog=__file__,
455522
description='i18n tool for SecureDrop.')
523+
parser.set_defaults(func=lambda args: parser.print_help())
524+
456525
parser.add_argument('-v', '--verbose', action='store_true')
457526
subps = parser.add_subparsers()
458527

0 commit comments

Comments
 (0)