1313import textwrap
1414from argparse import _SubParsersAction
1515from typing import Optional
16- from typing import Union
1716
1817from typing import Set
1918
3029
3130
3231class 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