Skip to content

Commit c352070

Browse files
committed
Implement SLIP39Mnemonic.encode tabulate for human readable mnemonics
1 parent 1a2261d commit c352070

File tree

2 files changed

+424
-20
lines changed

2 files changed

+424
-20
lines changed

hdwallet/mnemonics/slip39/mnemonic.py

Lines changed: 127 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import re
88
from typing import (
9-
Union, Dict, Iterable, List, Optional, Sequence, Tuple
9+
Union, Dict, Iterable, List, Optional, Sequence, Collection, Tuple
1010
)
1111

1212
from ...entropies import (
@@ -25,6 +25,8 @@
2525
from shamir_mnemonic.constants import MAX_SHARE_COUNT
2626
from shamir_mnemonic.recovery import RecoveryState, Share
2727

28+
from tabulate import tabulate
29+
2830

2931
class 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
168166
language_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+
192280
class 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

Comments
 (0)