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,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