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} w/ default size { 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 } ; odd { len (mnem )} -word mnemonic ignored" )
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 } : odd length mnemonic encountered" )
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