Skip to content

Commit 86e09ff

Browse files
committed
fix: address all comments from Copilot
Signed-off-by: Willian Paixao <willian@ufpa.br>
1 parent 7c37cf2 commit 86e09ff

3 files changed

Lines changed: 218 additions & 24 deletions

File tree

README.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ Generate a new mnemonic phrase:
9696
9797
$ mnemonic create
9898
$ mnemonic create -s 256 -l english -p "my passphrase"
99+
$ mnemonic create -s 256 -l english
100+
$ mnemonic create -P # prompt for passphrase (hidden input)
101+
$ mnemonic create --hide-seed # only output mnemonic, not the seed
102+
$ MNEMONIC_PASSPHRASE="secret" mnemonic create
99103
100104
Validate a mnemonic phrase:
101105

@@ -110,5 +114,7 @@ Derive seed from a mnemonic phrase:
110114
111115
$ mnemonic to-seed abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about
112116
$ mnemonic to-seed -p "my passphrase" word1 word2 ...
117+
$ mnemonic to-seed -P word1 word2 ... # prompt for passphrase (hidden input)
118+
$ MNEMONIC_PASSPHRASE="secret" mnemonic to-seed word1 word2 ...
113119
114120
.. _BIP-0039: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki

src/mnemonic/cli.py

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44

55
from mnemonic import Mnemonic
6+
from mnemonic.mnemonic import ConfigurationError
67

78

89
@click.group()
@@ -22,28 +23,56 @@ def cli() -> None:
2223
@click.option(
2324
"-s",
2425
"--strength",
25-
default=128,
26-
type=int,
27-
help="Entropy strength in bits (128, 160, 192, 224, or 256).",
26+
default="128",
27+
type=click.Choice(["128", "160", "192", "224", "256"]),
28+
help="Entropy strength in bits.",
2829
)
2930
@click.option(
3031
"-p",
3132
"--passphrase",
3233
default="",
34+
envvar="MNEMONIC_PASSPHRASE",
3335
type=str,
34-
help="Optional passphrase for seed derivation.",
36+
help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.",
37+
)
38+
@click.option(
39+
"-P",
40+
"--prompt-passphrase",
41+
is_flag=True,
42+
default=False,
43+
help="Prompt for passphrase with hidden input (secure).",
44+
)
45+
@click.option(
46+
"--hide-seed",
47+
is_flag=True,
48+
default=False,
49+
help="Do not display the derived seed.",
3550
)
3651
def create(
3752
language: str,
3853
passphrase: str,
39-
strength: int,
54+
prompt_passphrase: bool,
55+
strength: str,
56+
hide_seed: bool,
4057
) -> None:
4158
"""Generate a new mnemonic phrase and its derived seed."""
42-
mnemo = Mnemonic(language)
43-
words = mnemo.generate(strength)
44-
seed = mnemo.to_seed(words, passphrase)
45-
click.echo(f"Mnemonic: {words}")
46-
click.echo(f"Seed: {seed.hex()}")
59+
if prompt_passphrase:
60+
if passphrase:
61+
click.secho(
62+
"Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.",
63+
fg="yellow",
64+
err=True,
65+
)
66+
passphrase = click.prompt("Passphrase", default="", hide_input=True)
67+
try:
68+
mnemo = Mnemonic(language)
69+
words = mnemo.generate(int(strength))
70+
click.echo(f"Mnemonic: {words}")
71+
if not hide_seed:
72+
seed = mnemo.to_seed(words, passphrase)
73+
click.echo(f"Seed: {seed.hex()}")
74+
except ConfigurationError as e:
75+
raise click.ClickException(str(e))
4776

4877

4978
@cli.command()
@@ -66,34 +95,40 @@ def check(language: str | None, words: tuple[str, ...]) -> None:
6695
mnemonic = sys.stdin.read().strip()
6796

6897
if not mnemonic:
69-
click.secho("Error: No mnemonic provided.", fg="red", err=True)
70-
sys.exit(1)
98+
raise click.ClickException("No mnemonic provided.")
7199

72100
try:
73101
if language is None:
74102
language = Mnemonic.detect_language(mnemonic)
75103
mnemo = Mnemonic(language)
76104
if mnemo.check(mnemonic):
77105
click.secho("Valid mnemonic.", fg="green")
78-
sys.exit(0)
79106
else:
80-
click.secho("Invalid mnemonic checksum.", fg="red", err=True)
81-
sys.exit(1)
82-
except Exception as e:
83-
click.secho(f"Error: {e}", fg="red", err=True)
84-
sys.exit(1)
107+
raise click.ClickException("Invalid mnemonic checksum.")
108+
except ConfigurationError as e:
109+
raise click.ClickException(str(e))
110+
except (ValueError, LookupError) as e:
111+
raise click.ClickException(str(e))
85112

86113

87114
@cli.command("to-seed")
88115
@click.option(
89116
"-p",
90117
"--passphrase",
91118
default="",
119+
envvar="MNEMONIC_PASSPHRASE",
92120
type=str,
93-
help="Optional passphrase for seed derivation.",
121+
help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.",
122+
)
123+
@click.option(
124+
"-P",
125+
"--prompt-passphrase",
126+
is_flag=True,
127+
default=False,
128+
help="Prompt for passphrase with hidden input (secure).",
94129
)
95130
@click.argument("words", nargs=-1)
96-
def to_seed(passphrase: str, words: tuple[str, ...]) -> None:
131+
def to_seed(passphrase: str, prompt_passphrase: bool, words: tuple[str, ...]) -> None:
97132
"""Derive a seed from a mnemonic phrase.
98133
99134
WORDS can be provided as arguments or piped via stdin.
@@ -105,11 +140,28 @@ def to_seed(passphrase: str, words: tuple[str, ...]) -> None:
105140
mnemonic = sys.stdin.read().strip()
106141

107142
if not mnemonic:
108-
click.secho("Error: No mnemonic provided.", fg="red", err=True)
109-
sys.exit(1)
143+
raise click.ClickException("No mnemonic provided.")
144+
145+
if prompt_passphrase:
146+
if passphrase:
147+
click.secho(
148+
"Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.",
149+
fg="yellow",
150+
err=True,
151+
)
152+
passphrase = click.prompt("Passphrase", default="", hide_input=True)
110153

111-
seed = Mnemonic.to_seed(mnemonic, passphrase)
112-
click.echo(seed.hex())
154+
try:
155+
language = Mnemonic.detect_language(mnemonic)
156+
mnemo = Mnemonic(language)
157+
if not mnemo.check(mnemonic):
158+
raise click.ClickException("Invalid mnemonic checksum.")
159+
seed = mnemo.to_seed(mnemonic, passphrase)
160+
click.echo(seed.hex())
161+
except ConfigurationError as e:
162+
raise click.ClickException(str(e))
163+
except (ValueError, LookupError) as e:
164+
raise click.ClickException(str(e))
113165

114166

115167
if __name__ == "__main__":

tests/test_mnemonic.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@
2626
import unittest
2727
from typing import List
2828

29+
from click.testing import CliRunner
30+
2931
from mnemonic import Mnemonic
32+
from mnemonic.cli import cli
3033

3134

3235
class MnemonicTest(unittest.TestCase):
@@ -149,6 +152,139 @@ def test_expand(self) -> None:
149152
)
150153

151154

155+
class CLITest(unittest.TestCase):
156+
def setUp(self) -> None:
157+
self.runner = CliRunner()
158+
159+
def test_create_generates_valid_mnemonic(self) -> None:
160+
result = self.runner.invoke(cli, ["create"])
161+
self.assertEqual(result.exit_code, 0)
162+
self.assertIn("Mnemonic:", result.output)
163+
self.assertIn("Seed:", result.output)
164+
# Extract mnemonic and verify it's valid
165+
mnemonic_line = result.output.split("\n")[0]
166+
mnemonic = mnemonic_line.replace("Mnemonic: ", "")
167+
mnemo = Mnemonic("english")
168+
self.assertTrue(mnemo.check(mnemonic))
169+
170+
def test_create_with_strength(self) -> None:
171+
result = self.runner.invoke(cli, ["create", "-s", "256"])
172+
self.assertEqual(result.exit_code, 0)
173+
mnemonic_line = result.output.split("\n")[0]
174+
mnemonic = mnemonic_line.replace("Mnemonic: ", "")
175+
# 256 bits = 24 words
176+
self.assertEqual(len(mnemonic.split()), 24)
177+
178+
def test_create_invalid_strength(self) -> None:
179+
result = self.runner.invoke(cli, ["create", "-s", "100"])
180+
self.assertNotEqual(result.exit_code, 0)
181+
182+
def test_create_invalid_language(self) -> None:
183+
result = self.runner.invoke(cli, ["create", "-l", "klingon"])
184+
self.assertEqual(result.exit_code, 1)
185+
self.assertIn("Error", result.output)
186+
187+
def test_check_valid_mnemonic(self) -> None:
188+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
189+
result = self.runner.invoke(cli, ["check"] + mnemonic.split())
190+
self.assertEqual(result.exit_code, 0)
191+
self.assertIn("Valid mnemonic", result.output)
192+
193+
def test_check_invalid_mnemonic(self) -> None:
194+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong"
195+
result = self.runner.invoke(cli, ["check"] + mnemonic.split())
196+
self.assertEqual(result.exit_code, 1)
197+
self.assertIn("Invalid mnemonic checksum", result.output)
198+
199+
def test_check_stdin(self) -> None:
200+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
201+
result = self.runner.invoke(cli, ["check"], input=mnemonic)
202+
self.assertEqual(result.exit_code, 0)
203+
self.assertIn("Valid mnemonic", result.output)
204+
205+
def test_check_empty_input(self) -> None:
206+
result = self.runner.invoke(cli, ["check"], input="")
207+
self.assertEqual(result.exit_code, 1)
208+
self.assertIn("No mnemonic provided", result.output)
209+
210+
def test_to_seed_valid_mnemonic(self) -> None:
211+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
212+
result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split())
213+
self.assertEqual(result.exit_code, 0)
214+
expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
215+
self.assertEqual(result.output.strip(), expected_seed)
216+
217+
def test_to_seed_with_passphrase(self) -> None:
218+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
219+
result = self.runner.invoke(cli, ["to-seed", "-p", "TREZOR"] + mnemonic.split())
220+
self.assertEqual(result.exit_code, 0)
221+
expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"
222+
self.assertEqual(result.output.strip(), expected_seed)
223+
224+
def test_to_seed_with_env_passphrase(self) -> None:
225+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
226+
result = self.runner.invoke(
227+
cli, ["to-seed"] + mnemonic.split(), env={"MNEMONIC_PASSPHRASE": "TREZOR"}
228+
)
229+
self.assertEqual(result.exit_code, 0)
230+
expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"
231+
self.assertEqual(result.output.strip(), expected_seed)
232+
233+
def test_to_seed_invalid_mnemonic(self) -> None:
234+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong"
235+
result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split())
236+
self.assertEqual(result.exit_code, 1)
237+
self.assertIn("Invalid mnemonic checksum", result.output)
238+
239+
def test_to_seed_stdin(self) -> None:
240+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
241+
result = self.runner.invoke(cli, ["to-seed"], input=mnemonic)
242+
self.assertEqual(result.exit_code, 0)
243+
expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"
244+
self.assertEqual(result.output.strip(), expected_seed)
245+
246+
def test_check_with_language_option(self) -> None:
247+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
248+
result = self.runner.invoke(cli, ["check", "-l", "english"] + mnemonic.split())
249+
self.assertEqual(result.exit_code, 0)
250+
self.assertIn("Valid mnemonic", result.output)
251+
252+
def test_check_with_invalid_language(self) -> None:
253+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
254+
result = self.runner.invoke(cli, ["check", "-l", "klingon"] + mnemonic.split())
255+
self.assertEqual(result.exit_code, 1)
256+
257+
def test_create_with_prompt_passphrase(self) -> None:
258+
result = self.runner.invoke(cli, ["create", "-P"], input="test_passphrase\n")
259+
self.assertEqual(result.exit_code, 0)
260+
self.assertIn("Mnemonic:", result.output)
261+
self.assertIn("Seed:", result.output)
262+
263+
def test_to_seed_with_prompt_passphrase(self) -> None:
264+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
265+
result = self.runner.invoke(
266+
cli, ["to-seed", "-P"] + mnemonic.split(), input="TREZOR\n"
267+
)
268+
self.assertEqual(result.exit_code, 0)
269+
expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04"
270+
self.assertIn(expected_seed, result.output)
271+
272+
def test_prompt_passphrase_warning_when_both_set(self) -> None:
273+
result = self.runner.invoke(
274+
cli,
275+
["create", "-p", "existing", "-P"],
276+
input="new_passphrase\n",
277+
)
278+
self.assertEqual(result.exit_code, 0)
279+
self.assertIn("Warning", result.output)
280+
281+
def test_create_hide_seed(self) -> None:
282+
result = self.runner.invoke(cli, ["create", "--hide-seed"])
283+
self.assertEqual(result.exit_code, 0)
284+
self.assertIn("Mnemonic:", result.output)
285+
self.assertNotIn("Seed:", result.output)
286+
287+
152288
def __main__() -> None:
153289
unittest.main()
154290

0 commit comments

Comments
 (0)