From b81ade26ea0b44a535b5431591369fa7275d13b9 Mon Sep 17 00:00:00 2001 From: Willian Paixao Date: Wed, 24 Nov 2021 00:23:38 +0100 Subject: [PATCH 1/3] CLI first version --- .github/workflows/python-package.yml | 9 ++++-- README.rst | 11 +++++--- src/mnemonic/cli.py | 42 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) create mode 100644 src/mnemonic/cli.py diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 1466e37..a333d29 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,9 +22,14 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install package run: | - python -m pip install --upgrade pip - python -m pip install --upgrade setuptools + python -m pip install --upgrade build pip setuptools python -m pip install . - name: Run test run: | python tests/test_mnemonic.py + - name: Build wheel and sdist + run: | + python -m build + - name: Check long description + run: | + twine check dist/* diff --git a/README.rst b/README.rst index b2f652e..900a49c 100644 --- a/README.rst +++ b/README.rst @@ -13,22 +13,23 @@ Abstract This BIP describes the implementation of a mnemonic code or mnemonic sentence -- a group of easy to remember words -- for the generation of deterministic wallets. -It consists of two parts: generating the mnenomic, and converting it into a +It consists of two parts: generating the mnemonic, and converting it into a binary seed. This seed can be later used to generate deterministic wallets using BIP-0032 or similar methods. BIP Paper --------- -See https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki -for full specification +See `BIP-0039`_ for the full specification. Installation ------------ To install this library and its dependencies use: - ``pip install mnemonic`` +.. code-block:: sh + + $ pip install mnemonic Usage examples -------------- @@ -75,3 +76,5 @@ Given the word list, calculate original entropy: .. code-block:: python entropy = mnemo.to_entropy(words) + +.. _BIP-0039: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki diff --git a/src/mnemonic/cli.py b/src/mnemonic/cli.py new file mode 100644 index 0000000..9f767e3 --- /dev/null +++ b/src/mnemonic/cli.py @@ -0,0 +1,42 @@ +import click + +from mnemonic import Mnemonic + + +@click.group() +def cli() -> None: + pass + + +@cli.command() +@click.option( + "-l", + "--language", + default="english", + type=str, + help="", +) +@click.option( + "-s", + "--strength", + default=128, + type=int, + help="", +) +@click.option("-p", "--passphrase", default="", type=str, help="") +def create( + language: str, + passphrase: str, + strength: int, +) -> None: + """ """ + mnemo = Mnemonic(language) + words = mnemo.generate(strength) + seed = mnemo.to_seed(words, passphrase) + click.secho("SUCCESS!", fg="green", bold=True) + click.echo(f"Mnemonic: {words}") + click.echo(f"Seed: {seed.hex()}") + + +if __name__ == "__main__": + cli() From 7c37cf2fbc320a35c614d1488ea0bbf546eb0ec2 Mon Sep 17 00:00:00 2001 From: Willian Paixao Date: Fri, 2 Jan 2026 13:17:04 +0100 Subject: [PATCH 2/3] feat: add command-line interface with create, check, and to-seed commands - Add CLI entry point using Click - Add Click as runtime dependency Signed-off-by: Willian Paixao --- .github/workflows/python-package.yml | 2 +- CHANGELOG.rst | 11 ++++ README.rst | 34 +++++++++++ pyproject.toml | 4 ++ src/mnemonic/cli.py | 84 ++++++++++++++++++++++++++-- 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index a333d29..713bcf2 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -22,7 +22,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install package run: | - python -m pip install --upgrade build pip setuptools + python -m pip install --upgrade build pip setuptools twine python -m pip install . - name: Run test run: | diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db59742..5bad96f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -8,6 +8,17 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog`_, and this project adheres to `Semantic Versioning`_. +`0.22`_ - Unreleased +-------------------- + +.. _0.22: https://github.com/trezor/python-mnemonic/compare/v0.21...HEAD + +Added +~~~~~ + +- Command-line interface with ``create``, ``check``, and ``to-seed`` commands +- Click as a runtime dependency + `0.21`_ - 2024-01-05 -------------------- diff --git a/README.rst b/README.rst index 900a49c..6c1ea62 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,8 @@ python-mnemonic Reference implementation of BIP-0039: Mnemonic code for generating deterministic keys +Maintained by `Trezor `_. See the `GitHub repository `_ for source code and issue tracking. + Abstract -------- @@ -77,4 +79,36 @@ Given the word list, calculate original entropy: entropy = mnemo.to_entropy(words) +Command-line interface +---------------------- + +The ``mnemonic`` command provides CLI access to the library: + +.. code-block:: sh + + $ mnemonic create --help + $ mnemonic check --help + $ mnemonic to-seed --help + +Generate a new mnemonic phrase: + +.. code-block:: sh + + $ mnemonic create + $ mnemonic create -s 256 -l english -p "my passphrase" + +Validate a mnemonic phrase: + +.. code-block:: sh + + $ mnemonic check abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + $ echo "abandon abandon ..." | mnemonic check + +Derive seed from a mnemonic phrase: + +.. code-block:: sh + + $ mnemonic to-seed abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about + $ mnemonic to-seed -p "my passphrase" word1 word2 ... + .. _BIP-0039: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki diff --git a/pyproject.toml b/pyproject.toml index d99cb0f..b85db29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,10 @@ include = [ [tool.poetry.dependencies] python = ">=3.8.1" +click = "^8.0" + +[tool.poetry.scripts] +mnemonic = "mnemonic.cli:cli" [tool.poetry.group.dev.dependencies] isort = "^5.13.2" diff --git a/src/mnemonic/cli.py b/src/mnemonic/cli.py index 9f767e3..00cbbea 100644 --- a/src/mnemonic/cli.py +++ b/src/mnemonic/cli.py @@ -1,3 +1,5 @@ +import sys + import click from mnemonic import Mnemonic @@ -5,6 +7,7 @@ @click.group() def cli() -> None: + """BIP-39 mnemonic phrase generator and validator.""" pass @@ -14,29 +17,100 @@ def cli() -> None: "--language", default="english", type=str, - help="", + help="Language for the mnemonic wordlist.", ) @click.option( "-s", "--strength", default=128, type=int, - help="", + help="Entropy strength in bits (128, 160, 192, 224, or 256).", +) +@click.option( + "-p", + "--passphrase", + default="", + type=str, + help="Optional passphrase for seed derivation.", ) -@click.option("-p", "--passphrase", default="", type=str, help="") def create( language: str, passphrase: str, strength: int, ) -> None: - """ """ + """Generate a new mnemonic phrase and its derived seed.""" mnemo = Mnemonic(language) words = mnemo.generate(strength) seed = mnemo.to_seed(words, passphrase) - click.secho("SUCCESS!", fg="green", bold=True) click.echo(f"Mnemonic: {words}") click.echo(f"Seed: {seed.hex()}") +@cli.command() +@click.option( + "-l", + "--language", + default=None, + type=str, + help="Language for the mnemonic wordlist. Auto-detected if not specified.", +) +@click.argument("words", nargs=-1) +def check(language: str | None, words: tuple[str, ...]) -> None: + """Validate a mnemonic phrase's checksum. + + WORDS can be provided as arguments or piped via stdin. + """ + if words: + mnemonic = " ".join(words) + else: + mnemonic = sys.stdin.read().strip() + + if not mnemonic: + click.secho("Error: No mnemonic provided.", fg="red", err=True) + sys.exit(1) + + try: + if language is None: + language = Mnemonic.detect_language(mnemonic) + mnemo = Mnemonic(language) + if mnemo.check(mnemonic): + click.secho("Valid mnemonic.", fg="green") + sys.exit(0) + else: + click.secho("Invalid mnemonic checksum.", fg="red", err=True) + sys.exit(1) + except Exception as e: + click.secho(f"Error: {e}", fg="red", err=True) + sys.exit(1) + + +@cli.command("to-seed") +@click.option( + "-p", + "--passphrase", + default="", + type=str, + help="Optional passphrase for seed derivation.", +) +@click.argument("words", nargs=-1) +def to_seed(passphrase: str, words: tuple[str, ...]) -> None: + """Derive a seed from a mnemonic phrase. + + WORDS can be provided as arguments or piped via stdin. + Outputs the 64-byte seed in hexadecimal format. + """ + if words: + mnemonic = " ".join(words) + else: + mnemonic = sys.stdin.read().strip() + + if not mnemonic: + click.secho("Error: No mnemonic provided.", fg="red", err=True) + sys.exit(1) + + seed = Mnemonic.to_seed(mnemonic, passphrase) + click.echo(seed.hex()) + + if __name__ == "__main__": cli() From 86e09ff709b609845ea7b38e155b32b0854b882e Mon Sep 17 00:00:00 2001 From: Willian Paixao Date: Tue, 6 Jan 2026 18:41:24 +0100 Subject: [PATCH 3/3] fix: address all comments from Copilot Signed-off-by: Willian Paixao --- README.rst | 6 ++ src/mnemonic/cli.py | 100 ++++++++++++++++++++++-------- tests/test_mnemonic.py | 136 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 24 deletions(-) diff --git a/README.rst b/README.rst index 6c1ea62..2e301fd 100644 --- a/README.rst +++ b/README.rst @@ -96,6 +96,10 @@ Generate a new mnemonic phrase: $ mnemonic create $ mnemonic create -s 256 -l english -p "my passphrase" + $ mnemonic create -s 256 -l english + $ mnemonic create -P # prompt for passphrase (hidden input) + $ mnemonic create --hide-seed # only output mnemonic, not the seed + $ MNEMONIC_PASSPHRASE="secret" mnemonic create Validate a mnemonic phrase: @@ -110,5 +114,7 @@ Derive seed from a mnemonic phrase: $ mnemonic to-seed abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about $ mnemonic to-seed -p "my passphrase" word1 word2 ... + $ mnemonic to-seed -P word1 word2 ... # prompt for passphrase (hidden input) + $ MNEMONIC_PASSPHRASE="secret" mnemonic to-seed word1 word2 ... .. _BIP-0039: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki diff --git a/src/mnemonic/cli.py b/src/mnemonic/cli.py index 00cbbea..997bdb6 100644 --- a/src/mnemonic/cli.py +++ b/src/mnemonic/cli.py @@ -3,6 +3,7 @@ import click from mnemonic import Mnemonic +from mnemonic.mnemonic import ConfigurationError @click.group() @@ -22,28 +23,56 @@ def cli() -> None: @click.option( "-s", "--strength", - default=128, - type=int, - help="Entropy strength in bits (128, 160, 192, 224, or 256).", + default="128", + type=click.Choice(["128", "160", "192", "224", "256"]), + help="Entropy strength in bits.", ) @click.option( "-p", "--passphrase", default="", + envvar="MNEMONIC_PASSPHRASE", type=str, - help="Optional passphrase for seed derivation.", + help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.", +) +@click.option( + "-P", + "--prompt-passphrase", + is_flag=True, + default=False, + help="Prompt for passphrase with hidden input (secure).", +) +@click.option( + "--hide-seed", + is_flag=True, + default=False, + help="Do not display the derived seed.", ) def create( language: str, passphrase: str, - strength: int, + prompt_passphrase: bool, + strength: str, + hide_seed: bool, ) -> None: """Generate a new mnemonic phrase and its derived seed.""" - mnemo = Mnemonic(language) - words = mnemo.generate(strength) - seed = mnemo.to_seed(words, passphrase) - click.echo(f"Mnemonic: {words}") - click.echo(f"Seed: {seed.hex()}") + if prompt_passphrase: + if passphrase: + click.secho( + "Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.", + fg="yellow", + err=True, + ) + passphrase = click.prompt("Passphrase", default="", hide_input=True) + try: + mnemo = Mnemonic(language) + words = mnemo.generate(int(strength)) + click.echo(f"Mnemonic: {words}") + if not hide_seed: + seed = mnemo.to_seed(words, passphrase) + click.echo(f"Seed: {seed.hex()}") + except ConfigurationError as e: + raise click.ClickException(str(e)) @cli.command() @@ -66,8 +95,7 @@ def check(language: str | None, words: tuple[str, ...]) -> None: mnemonic = sys.stdin.read().strip() if not mnemonic: - click.secho("Error: No mnemonic provided.", fg="red", err=True) - sys.exit(1) + raise click.ClickException("No mnemonic provided.") try: if language is None: @@ -75,13 +103,12 @@ def check(language: str | None, words: tuple[str, ...]) -> None: mnemo = Mnemonic(language) if mnemo.check(mnemonic): click.secho("Valid mnemonic.", fg="green") - sys.exit(0) else: - click.secho("Invalid mnemonic checksum.", fg="red", err=True) - sys.exit(1) - except Exception as e: - click.secho(f"Error: {e}", fg="red", err=True) - sys.exit(1) + raise click.ClickException("Invalid mnemonic checksum.") + except ConfigurationError as e: + raise click.ClickException(str(e)) + except (ValueError, LookupError) as e: + raise click.ClickException(str(e)) @cli.command("to-seed") @@ -89,11 +116,19 @@ def check(language: str | None, words: tuple[str, ...]) -> None: "-p", "--passphrase", default="", + envvar="MNEMONIC_PASSPHRASE", type=str, - help="Optional passphrase for seed derivation.", + help="Passphrase for seed derivation. Can also be set via MNEMONIC_PASSPHRASE env var.", +) +@click.option( + "-P", + "--prompt-passphrase", + is_flag=True, + default=False, + help="Prompt for passphrase with hidden input (secure).", ) @click.argument("words", nargs=-1) -def to_seed(passphrase: str, words: tuple[str, ...]) -> None: +def to_seed(passphrase: str, prompt_passphrase: bool, words: tuple[str, ...]) -> None: """Derive a seed from a mnemonic phrase. WORDS can be provided as arguments or piped via stdin. @@ -105,11 +140,28 @@ def to_seed(passphrase: str, words: tuple[str, ...]) -> None: mnemonic = sys.stdin.read().strip() if not mnemonic: - click.secho("Error: No mnemonic provided.", fg="red", err=True) - sys.exit(1) + raise click.ClickException("No mnemonic provided.") + + if prompt_passphrase: + if passphrase: + click.secho( + "Warning: --prompt-passphrase overrides -p/MNEMONIC_PASSPHRASE.", + fg="yellow", + err=True, + ) + passphrase = click.prompt("Passphrase", default="", hide_input=True) - seed = Mnemonic.to_seed(mnemonic, passphrase) - click.echo(seed.hex()) + try: + language = Mnemonic.detect_language(mnemonic) + mnemo = Mnemonic(language) + if not mnemo.check(mnemonic): + raise click.ClickException("Invalid mnemonic checksum.") + seed = mnemo.to_seed(mnemonic, passphrase) + click.echo(seed.hex()) + except ConfigurationError as e: + raise click.ClickException(str(e)) + except (ValueError, LookupError) as e: + raise click.ClickException(str(e)) if __name__ == "__main__": diff --git a/tests/test_mnemonic.py b/tests/test_mnemonic.py index 641cf16..48dd815 100755 --- a/tests/test_mnemonic.py +++ b/tests/test_mnemonic.py @@ -26,7 +26,10 @@ import unittest from typing import List +from click.testing import CliRunner + from mnemonic import Mnemonic +from mnemonic.cli import cli class MnemonicTest(unittest.TestCase): @@ -149,6 +152,139 @@ def test_expand(self) -> None: ) +class CLITest(unittest.TestCase): + def setUp(self) -> None: + self.runner = CliRunner() + + def test_create_generates_valid_mnemonic(self) -> None: + result = self.runner.invoke(cli, ["create"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertIn("Seed:", result.output) + # Extract mnemonic and verify it's valid + mnemonic_line = result.output.split("\n")[0] + mnemonic = mnemonic_line.replace("Mnemonic: ", "") + mnemo = Mnemonic("english") + self.assertTrue(mnemo.check(mnemonic)) + + def test_create_with_strength(self) -> None: + result = self.runner.invoke(cli, ["create", "-s", "256"]) + self.assertEqual(result.exit_code, 0) + mnemonic_line = result.output.split("\n")[0] + mnemonic = mnemonic_line.replace("Mnemonic: ", "") + # 256 bits = 24 words + self.assertEqual(len(mnemonic.split()), 24) + + def test_create_invalid_strength(self) -> None: + result = self.runner.invoke(cli, ["create", "-s", "100"]) + self.assertNotEqual(result.exit_code, 0) + + def test_create_invalid_language(self) -> None: + result = self.runner.invoke(cli, ["create", "-l", "klingon"]) + self.assertEqual(result.exit_code, 1) + self.assertIn("Error", result.output) + + def test_check_valid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_invalid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong" + result = self.runner.invoke(cli, ["check"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + self.assertIn("Invalid mnemonic checksum", result.output) + + def test_check_stdin(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check"], input=mnemonic) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_empty_input(self) -> None: + result = self.runner.invoke(cli, ["check"], input="") + self.assertEqual(result.exit_code, 1) + self.assertIn("No mnemonic provided", result.output) + + def test_to_seed_valid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_with_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed", "-p", "TREZOR"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_with_env_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke( + cli, ["to-seed"] + mnemonic.split(), env={"MNEMONIC_PASSPHRASE": "TREZOR"} + ) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertEqual(result.output.strip(), expected_seed) + + def test_to_seed_invalid_mnemonic(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon wrong" + result = self.runner.invoke(cli, ["to-seed"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + self.assertIn("Invalid mnemonic checksum", result.output) + + def test_to_seed_stdin(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["to-seed"], input=mnemonic) + self.assertEqual(result.exit_code, 0) + expected_seed = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4" + self.assertEqual(result.output.strip(), expected_seed) + + def test_check_with_language_option(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check", "-l", "english"] + mnemonic.split()) + self.assertEqual(result.exit_code, 0) + self.assertIn("Valid mnemonic", result.output) + + def test_check_with_invalid_language(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke(cli, ["check", "-l", "klingon"] + mnemonic.split()) + self.assertEqual(result.exit_code, 1) + + def test_create_with_prompt_passphrase(self) -> None: + result = self.runner.invoke(cli, ["create", "-P"], input="test_passphrase\n") + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertIn("Seed:", result.output) + + def test_to_seed_with_prompt_passphrase(self) -> None: + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + result = self.runner.invoke( + cli, ["to-seed", "-P"] + mnemonic.split(), input="TREZOR\n" + ) + self.assertEqual(result.exit_code, 0) + expected_seed = "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04" + self.assertIn(expected_seed, result.output) + + def test_prompt_passphrase_warning_when_both_set(self) -> None: + result = self.runner.invoke( + cli, + ["create", "-p", "existing", "-P"], + input="new_passphrase\n", + ) + self.assertEqual(result.exit_code, 0) + self.assertIn("Warning", result.output) + + def test_create_hide_seed(self) -> None: + result = self.runner.invoke(cli, ["create", "--hide-seed"]) + self.assertEqual(result.exit_code, 0) + self.assertIn("Mnemonic:", result.output) + self.assertNotIn("Seed:", result.output) + + def __main__() -> None: unittest.main()