Skip to content

Commit dd48337

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 dd48337

1 file changed

Lines changed: 125 additions & 61 deletions

File tree

securedrop/i18n_tool.py

Lines changed: 125 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,91 @@ 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+
337+
def commit_changes(self, args: argparse.Namespace, code: str) -> None:
316338
self.require_git_email_name(args.root)
317339
authors = set() # type: Set[str]
318340
diffs = u"{}".format(git('--no-pager', '-C', args.root, 'diff', '--name-only', '--cached'))
@@ -321,18 +343,18 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
321343
previous_message = u"{}".format(git(
322344
'--no-pager', '-C', args.root, 'log', '-n', '1', path,
323345
_encoding='utf-8'))
324-
update_re = re.compile(r'(?:updated from| revision:) (\w+)')
325-
m = update_re.search(previous_message)
346+
m = self.updated_commit_re.search(previous_message)
326347
if m:
327348
origin = m.group(1)
328349
else:
329350
origin = ''
330-
authors |= self.translators(args, path, '{}..i18n/i18n'.format(origin))
351+
since = self.get_commit_timestamp(origin)
352+
authors |= self.translators(args, path, since)
331353

332354
authors_as_str = u"\n ".join(sorted(authors))
333355

334-
current = git('-C', args.root, 'rev-parse', 'i18n/i18n')
335-
info = I18NTool.SUPPORTED_LANGUAGES[code]
356+
current = git('-C', args.root, 'rev-parse', args.target)
357+
info = self.supported_languages[code]
336358
message = textwrap.dedent(u"""
337359
l10n: updated {name} ({code})
338360
@@ -341,7 +363,7 @@ def upstream_commit(self, args: argparse.Namespace, code: str) -> None:
341363
342364
updated from:
343365
repo: {remote}
344-
revision: {current}
366+
commit: {current}
345367
""").format(
346368
remote=args.url,
347369
name=info['name'],
@@ -366,6 +388,14 @@ def set_update_from_weblate_parser(self, subps: _SubParsersAction) -> None:
366388
default=url,
367389
help=('URL of the weblate repository'
368390
' (default {})'.format(url)))
391+
parser.add_argument(
392+
'--target',
393+
default="i18n/i18n",
394+
help=(
395+
'Commit on i18n branch at which to stop gathering translator contributions '
396+
'(default: i18n/i18n)'
397+
)
398+
)
369399
parser.add_argument(
370400
'--supported-languages',
371401
help='comma separated list of supported languages')
@@ -387,12 +417,12 @@ def set_list_locales_parser(self, subps: _SubParsersAction) -> None:
387417

388418
def list_locales(self, args: argparse.Namespace) -> None:
389419
if args.lines:
390-
for l in sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']):
420+
for l in sorted(list(self.supported_languages.keys()) + ['en_US']):
391421
print(l)
392422
elif args.python:
393-
print(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US']))
423+
print(sorted(list(self.supported_languages.keys()) + ['en_US']))
394424
else:
395-
print(" ".join(sorted(list(self.SUPPORTED_LANGUAGES.keys()) + ['en_US'])))
425+
print(" ".join(sorted(list(self.supported_languages.keys()) + ['en_US'])))
396426

397427
def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
398428
parser = subps.add_parser('list-translators',
@@ -409,50 +439,84 @@ def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
409439
default=url,
410440
help=('URL of the weblate repository'
411441
' (default {})'.format(url)))
442+
parser.add_argument(
443+
'--target',
444+
default="i18n/i18n",
445+
help=(
446+
'Commit on i18n branch at which to stop gathering translator contributions '
447+
'(default: i18n/i18n)'
448+
)
449+
)
450+
last_release = self.get_last_release()
451+
parser.add_argument(
452+
'--since',
453+
default=last_release,
454+
help=(
455+
'Gather translator contributions from the time of this commit '
456+
'(default: {})'.format(last_release)
457+
)
458+
)
412459
parser.add_argument(
413460
'--all',
414461
action="store_true",
415462
help=(
416463
"List everyone who's ever contributed, instead of just since the last "
417-
"sync from Weblate."
464+
"release or specified commit."
418465
)
419466
)
420467
parser.set_defaults(func=self.list_translators)
421468

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
469+
def get_last_release(self) -> Optional[str]:
470+
"""
471+
Returns the last release tag, e.g. 1.5.0.
472+
"""
473+
tags = subprocess.check_output(["git", "tag"]).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, commit):
480+
timestamp = subprocess.check_output(
481+
["git", "log", "-n", "1", '--pretty=format:%ct', commit]
482+
)
483+
return timestamp.decode("utf-8").strip()
429484

430485
def list_translators(self, args: argparse.Namespace) -> None:
431486
self.ensure_i18n_remote(args)
432487
app_template = "securedrop/translations/{}/LC_MESSAGES/messages.po"
433488
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()):
489+
since = self.get_commit_timestamp(args.since) if not args.all else None
490+
if args.all:
491+
print("Listing all translators who have ever helped")
492+
else:
493+
print("Listing translators who have helped since {}".format(args.since))
494+
for code, info in sorted(self.supported_languages.items()):
436495
translators = set([])
437496
paths = [
438497
app_template.format(code),
439498
desktop_template.format(info["desktop"]),
440499
]
441500
for path in paths:
442501
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)
502+
t = self.translators(args, path, since)
447503
translators.update(t)
448504
except Exception as e:
449505
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))))
506+
print(
507+
"{} ({}):{}".format(
508+
code, info["name"],
509+
"\n {}\n".format(
510+
"\n ".join(sorted(translators))) if translators else "\n"
511+
)
512+
)
451513

452514
def get_args(self) -> argparse.ArgumentParser:
453515
parser = argparse.ArgumentParser(
454516
prog=__file__,
455517
description='i18n tool for SecureDrop.')
518+
parser.set_defaults(func=lambda args: parser.print_help())
519+
456520
parser.add_argument('-v', '--verbose', action='store_true')
457521
subps = parser.add_subparsers()
458522

0 commit comments

Comments
 (0)