66
77import  re 
88from  typing  import  (
9-     Union , Dict , Iterable , List , Optional , Sequence , Tuple 
9+     Union , Dict , Iterable , List , Optional , Sequence , Collection ,  Tuple 
1010)
1111
1212from  ...entropies  import  (
2525from  shamir_mnemonic .constants  import  MAX_SHARE_COUNT 
2626from  shamir_mnemonic .recovery  import  RecoveryState , Share 
2727
28+ from  tabulate  import  tabulate 
29+ 
2830
2931class  SLIP39_MNEMONIC_WORDS :
3032
@@ -80,10 +82,8 @@ def group_parser( group_spec, size_default: Optional[int] = None) -> Tuple[str,
8082        raise  ValueError ( f"Impossible group specification from { group_spec !r} { size_default !r} { name ,(require ,size )!r}   )
8183
8284    return  (name , (require , size ))
83- 
84- 
85- group_parser .REQUIRED_RATIO 	=  1 / 2 
86- group_parser .RE 			=  re .compile ( # noqa E305 
85+ group_parser .REQUIRED_RATIO 	=  1 / 2   # noqa: E305 
86+ group_parser .RE 			=  re .compile (
8787    r""" 
8888        ^ 
8989        \s* 
@@ -162,9 +162,7 @@ def language_parser(language: str) -> Dict[Tuple[str, Tuple[int, int]], Dict[Uni
162162        g_sizes .append (g_dims )
163163
164164    return  { (s_name .strip (), (s_thresh , s_size )): dict (zip (g_names , g_sizes )) }
165- 
166- 
167- language_parser .REQUIRED_RATIO 	=  1 / 2 
165+ language_parser .REQUIRED_RATIO 	=  1 / 2   # noqa: E305 
168166language_parser .RE 		=  re .compile (
169167    r""" 
170168        ^ 
@@ -189,6 +187,96 @@ def language_parser(language: str) -> Dict[Tuple[str, Tuple[int, int]], Dict[Uni
189187    """ , re .VERBOSE )
190188
191189
190+ def  ordinal ( num  ):
191+     q , mod 			=  divmod ( num , 10  )
192+     suffix 			=  q  %  10  !=  1  and  ordinal .suffixes .get (mod ) or  "th" 
193+     return  f"{ num } { suffix }  
194+ ordinal .suffixes 		=  {1 : "st" , 2 : "nd" , 3 : "rd" }  # noqa: E305 
195+ 
196+ 
197+ def  tabulate_slip39 (
198+     groups : Dict [Union [str , int ], Tuple [int , int ]],
199+     group_mnemonics : Sequence [Collection [str ]],
200+     columns = None ,  # default: columnize, but no wrapping 
201+ ) ->  str :
202+     """Return SLIP-39 groups with group names/numbers, a separator, and tabulated mnemonics. 
203+ 
204+     Mnemonics exceeding 'columns' will be wrapped with no prefix except a continuation character. 
205+ 
206+     The default behavior (columns is falsey) is to NOT wrap the mnemonics (no columns limit).  If 
207+     columns is True or 1 (truthy, but not a specific sensible column size), we'll use the 
208+     tabulate_slip39.default of 20.  Otherwise, we'll use the specified specific columns. 
209+ 
210+     """ 
211+     if  not  columns :				# False, None, 0 
212+         limit 			=  0 
213+     elif  int (columns ) >  1 :			# 2, ... 
214+         limit 			=  int (columns )
215+     else :					# True, 1 
216+         limit 			=  tabulate_slip39 .default 
217+ 
218+     def  prefixed ( groups , group_mnemonics  ):
219+         for  g , ((name , (threshold , count )), mnemonics ) in  enumerate ( zip ( groups .items (), group_mnemonics  )):
220+             assert  count  ==  len ( mnemonics  )
221+             for  o , mnem  in  enumerate ( sorted ( map ( str .split , mnemonics  ))):
222+                 siz 		=  limit  or  len ( mnem  )
223+                 end 		=  len ( mnem  )
224+                 rows 		=  ( end  +  siz  -  1  ) //  siz 
225+                 for  r , col  in  enumerate ( range ( 0 , end , siz  )):
226+                     con  	=  '' 
227+                     if  count  ==  1 :			# A 1/1 
228+                         if  rows  ==  1 :
229+                             sep 	=  '━' 			# on 1 row 
230+                         elif  r  ==  0 :
231+                             sep  =  '┭' 			# on multiple rows 
232+                             con  =  '╎' 
233+                         elif  r + 1  <  rows :
234+                             sep  =  '├' 
235+                             con  =  '╎' 
236+                         else :
237+                             sep  =  '└' 
238+                     elif  rows  ==  1 :			# An N/M w/ full row mnemonics 
239+                         if  o  ==  0 :			# on 1 row, 1st mnemonic 
240+                             sep  =  '┳' 
241+                             con  =  '╏' 
242+                         elif  o + 1  <  count :
243+                             sep  =  '┣' 
244+                             con  =  '╏' 
245+                         else :
246+                             sep  =  '┗' 
247+                     else :				# An N/M, but multi-row mnemonics 
248+                         if  o  ==  0  and  r  ==  0 :		# on 1st row, 1st mnemonic 
249+                             sep  =  '┳' 
250+                             con  =  '╎' 
251+                         elif  r  ==  0 :			# on 1st row, any mnemonic 
252+                             sep  =  '┣' 
253+                             con  =  '╎' 
254+                         elif  r + 1  <  rows :		# on mid row, any mnemonic 
255+                             sep  =  '├' 
256+                             con  =  '╎' 
257+                         elif  o + 1  <  count :		# on last row, but not last mneonic 
258+                             sep  =  '└' 
259+                             con  =  '╏' 
260+                         else :
261+                             sep  =  '└' 			# on last row of last mnemonic 
262+ 
263+                     # Output the prefix and separator + mnemonics 
264+                     yield  [
265+                         f"{ name } { threshold } { count }   if  o  ==  0  and  col  ==  0  else  "" 
266+                     ] +  [
267+                         ordinal (o + 1 ) if  col  ==  0  else  "" 
268+                     ] +  [
269+                         sep 
270+                     ] +  mnem [col :col + siz ]
271+ 
272+                     # And if not the last group and mnemonic, but a last row; Add a blank or continuation row 
273+                     if  r + 1  ==  rows  and  not  (g + 1  ==  len (groups ) and  o + 1  ==  count ):
274+                         yield  ["" , "" , con ] if  con  else  [None ]
275+ 
276+     return  tabulate ( prefixed ( groups , group_mnemonics  ), tablefmt = 'plain'  )
277+ tabulate_slip39 .default 		=  20   # noqa: E305 
278+ 
279+ 
192280class  SLIP39Mnemonic (IMnemonic ):
193281    """ 
194282    Implements the SLIP39 standard, allowing the creation of mnemonic phrases for 
@@ -340,7 +428,7 @@ def encode(
340428        passphrase : str  =  "" ,
341429        extendable : bool  =  True ,
342430        iteration_exponent : int  =  1 ,
343-         tabulate : bool  =  False ,
431+         tabulate : bool  =  False ,   # False disables; any other value causes prefixing/columnization 
344432    ) ->  str :
345433        """ 
346434        Encodes entropy into a mnemonic phrase. 
@@ -373,15 +461,17 @@ def encode(
373461
374462        ((s_name , (s_thresh , s_size )), groups ), =  language_parser (language ).items ()
375463        assert  s_size  ==  len (groups )
376-         group_mnemonics : Sequence [Sequence [str ]] =  generate_mnemonics (
464+         group_mnemonics : Sequence [Collection [str ]] =  generate_mnemonics (
377465            group_threshold = s_thresh ,
378466            groups = groups .values (),
379467            master_secret = entropy ,
380468            passphrase = passphrase .encode ('UTF-8' ),
381469            extendable = extendable ,
382470            iteration_exponent = iteration_exponent ,
383471        )
384- 
472+         
473+         if  tabulate  is  not False :  # None/0 imply no column limits 
474+             return  tabulate_slip39 (groups , group_mnemonics , columns = tabulate )
385475        return  "\n " .join (sum (group_mnemonics , []))
386476
387477    @classmethod  
@@ -436,7 +526,8 @@ def decode(
436526            ^ 
437527            \s* 
438528            ( 
439-                 [\w\d\s]* [^\w\d\s]	# Group 1 { <-- a single non-word/space/digit separator allowed 
529+                 [ \w\d\s()/]*		# Group(1/1) 1st { <-- a single non-word/space/digit separator allowed 
530+                 [^\w\d\s()/]		#  Any symbol not comprising a valid group_parser language symbol 
440531            )? 
441532            \s* 
442533            ( 
@@ -458,8 +549,10 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]:
458549        symbol, before any number of Mnemonic word/space symbols: 
459550
460551                    Group  1 { word word ... 
552+ 
461553                    Group  2 ╭ word word ... 
462554                             ╰ word word ... 
555+ 
463556                    Group  3 ┌ word word ... 
464557                             ├ word word ... 
465558                             └ word word ... 
@@ -469,27 +562,41 @@ def normalize(cls, mnemonic: Union[str, List[str]]) -> List[str]:
469562                             | 
470563                  single non-word/digit/space 
471564
565+ 
566+         Since multi-row mnemonics are possible, we cannot always confirm that the accumulated 
567+         mnemonic size is valid after every mnemonic row.  We can certainly identify the end of a 
568+         mnemonic by a blank row (it doesn't make sense to allow a single Mnemonic to be split across 
569+         blank rows), or the end of input. 
570+ 
472571        """ 
473572        errors 			=  []
474573        if  isinstance ( mnemonic , str  ):
475574            mnemonic_list : List [str ] =  []
476575
477-             for  line_no , m  in  enumerate ( map ( cls .NORMALIZE .match , mnemonic .split ("\n " ))):
576+             for  line_no , line  in  enumerate ( mnemonic .split ("\n " )):
577+                 m  =  cls .NORMALIZE .match ( line  )
478578                if  not  m :
479-                     errors .append ( f"@L{ line_no + 1 } ;  unrecognized mnemonic ignored "  )
579+                     errors .append ( f"@L{ line_no + 1 } :  unrecognized mnemonic line "  )
480580                    continue 
481581
482582                pref , mnem 	=  m .groups ()
483-                 if  not  mnem :  # Blank lines or lines without Mnemonic skipped 
484-                     continue 
485-                 mnem 		=  super ().normalize (mnem )
486-                 if  len (mnem ) in  cls .words_list :
487-                     mnemonic_list .extend (mnem )
583+                 if  mnem :
584+                     mnemonic_list .extend ( super ().normalize ( mnem  ))
488585                else :
489-                     errors .append ( f"@L{ line_no + 1 } { len (mnem )}   )
586+                     # Blank lines or lines without Mnemonic skipped.  But they do indicate the end 
587+                     # of a mnemonic!  At this moment, the total accumulated Mnemonic(s) must be 
588+                     # valid -- or the last one must have been bad. 
589+                     word_lengths  =  list (filter (lambda  w : len (mnemonic_list ) %  w  ==  0 , cls .words_list ))
590+                     if  not  word_lengths :
591+                         errors .append ( f"@L{ line_no }   )
592+                         break 
490593        else :
491594            mnemonic_list : List [str ] =  mnemonic 
492595
596+         # Regardless of the Mnemonic source; the total number of words must be a valid multiple of 
597+         # the SLIP-39 mnemonic word lengths.  Fortunately, the LCM of (20, 33 and 59) is 38940, so 
598+         # we cannot encounter a sufficient body of mnemonics to ever run into an uncertain SLIP-39 
599+         # Mnemonic length in words. 
493600        word_lengths  =  list (filter (lambda  w : len (mnemonic_list ) %  w  ==  0 , cls .words_list ))
494601        if  not  word_lengths :
495602            errors .append ( "Mnemonics not a multiple of valid length, or a single hex entropy value"  )
0 commit comments