1212import sys
1313import textwrap
1414from argparse import _SubParsersAction
15- from typing import Optional
16- from typing import Union
1715
1816from typing import Set
1917
3028
3129
3230class 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