diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 08d6425..049dc0d 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -87,7 +87,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 - - name: Update version in pyproject.toml + - name: Calculate prerelease version id: set-version run: | # Get base version from tag action (e.g., v1.2.0-rc.0) @@ -98,15 +98,15 @@ jobs: # Create unique prerelease version: {base}rc{pr_number}.dev{run_number} # e.g., 1.2.0rc42.dev7 for PR #42, run #7 PYPI_VERSION="${BASE_VERSION}rc${{ github.event.pull_request.number }}.dev${{ github.run_number }}" - sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml echo "Tag version: $NEW_TAG" echo "PyPI version: $PYPI_VERSION" echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT - grep "^version" pyproject.toml env: NEW_TAG: ${{ steps.version.outputs.new_tag }} - name: Build distributions + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.set-version.outputs.pypi_version }} run: | uv build --sdist uv build --wheel diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4b873e..4e8222c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,20 +40,17 @@ jobs: BRANCH_HISTORY: compare INITIAL_VERSION: 1.0.3 - - name: Update version in pyproject.toml + - name: Set version output id: set-version run: | # Strip 'v' prefix for PyPI-compatible version PYPI_VERSION="${NEW_TAG#v}" - sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml echo "Tag version: $NEW_TAG" echo "PyPI version: $PYPI_VERSION" echo "pypi_version=$PYPI_VERSION" >> $GITHUB_OUTPUT - grep "^version" pyproject.toml env: NEW_TAG: ${{ steps.version.outputs.new_tag }} - # Note: Version is injected locally for this job only. - # Not committed to repo - downstream jobs inject version from tag. + # Note: hatch-vcs reads version from git tag at build time publish: name: Publish PyPI @@ -67,10 +64,13 @@ jobs: - uses: actions/checkout@v5 with: ref: master - fetch-depth: 1 + fetch-depth: 0 # Full history needed for hatch-vcs - - name: Pull latest changes - run: git pull origin master + - name: Pull latest and fetch tags + run: | + git pull origin master + git fetch --tags + echo "Latest tag: $(git describe --tags --abbrev=0)" - name: Set up Python uses: actions/setup-python@v6 @@ -80,14 +80,6 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v7 - - name: Inject version from tag - run: | - PYPI_VERSION="${{ needs.version.outputs.new_version }}" - PYPI_VERSION="${PYPI_VERSION#v}" # Strip 'v' prefix - sed -i "s/^version = .*/version = \"$PYPI_VERSION\"/" pyproject.toml - echo "PyPI version: $PYPI_VERSION" - grep "^version" pyproject.toml - - name: Build distributions run: | uv build --sdist diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d73f81b..18c46e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,8 +28,11 @@ jobs: - name: Install dependencies run: make ci DEV=1 - - name: Lint with ruff + - name: Lint run: make lint - - name: Run tests with coverage + - name: Type check + run: make type + + - name: Test with coverage run: make cov diff --git a/.gitignore b/.gitignore index 035faad..b0c50bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ misc *.txt - +src/interstellar/_version.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4aedd27 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# AGENTS.md + +## Overview + +This repo is a Python CLI tool (`interstellar/`) for managing cryptocurrency mnemonics using BIP39 and SLIP39 standards — including generation, splitting, reconstruction, and Shamir secret sharing. + +## CI Checks + +All changes must pass these checks before merging (runs on ubuntu, macOS, and Windows): + +```bash +make lint # ruff check . && ruff format --check . +make type # ty check src tests +make cov # pytest with coverage (threshold enforced) +``` + +## Running Checks Locally + +```bash +make ci DEV=1 # install deps (frozen lockfile, requires uv) +make lint # lint + format check +make type # type check (ty) +make test # unit tests only +make cov # unit tests with coverage +make smoke # smoke tests (tests/smoke.py) +make format # auto-fix lint + format +``` + +## Project Structure + +- `src/interstellar/` — core CLI module (BIP39, SLIP39, CLI app) +- `tests/` — pytest unit tests and smoke tests +- `scripts/` — utility scripts (build, QR codes) +- `pyproject.toml` — project config, dependencies, and tool settings +- `Makefile` — build/test/lint commands + +## Key Conventions + +- **Package manager**: `uv` (all commands run via `uv run`) +- **Type checker**: `ty` (astral, not mypy) +- **Linter/Formatter**: `ruff` +- **Test runner**: `pytest` with `pytest-xdist` (`-n auto`) and `pytest-cov` +- **Python version**: 3.13 diff --git a/Makefile b/Makefile index 742dad6..9aa876a 100644 --- a/Makefile +++ b/Makefile @@ -4,13 +4,14 @@ NAME := $(shell basename $(CURDIR)) UV_SYNC := uv sync $(if $(DEV),--dev,--no-dev) UV_SYNC_FROZEN := uv sync --frozen $(if $(DEV),--dev,--no-dev) -.PHONY: install ci lint format smoke test cov build clean qr help all +.PHONY: install ci lint type format smoke test cov build clean qr help all help: @echo "Available targets:" @echo " install - Install dependencies (DEV=1 for dev deps)" @echo " ci - Install with frozen lock file (DEV=1 for dev deps)" @echo " lint - Run ruff linter and formatter check" + @echo " type - Run type checker (ty)" @echo " format - Run ruff formatter" @echo " smoke - Run smoke tests" @echo " test - Run tests with pytest" @@ -30,6 +31,9 @@ lint: uv run ruff check . uv run ruff format --check . +type: + uv run ty check src tests + format: uv run ruff check . --fix uv run ruff format . diff --git a/pyproject.toml b/pyproject.toml index f9992b9..9e8a6b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "interstellar" -version = "0.0.0" # Injected from git tag at build time +dynamic = ["version"] description = "A command-line tool for managing cryptocurrency mnemonics using BIP39 and SLIP39 standards" readme = "README.md" requires-python = ">=3.13" @@ -31,6 +31,12 @@ Homepage = "https://github.com/alkalescent/interstellar" Repository = "https://github.com/alkalescent/interstellar" Issues = "https://github.com/alkalescent/interstellar/issues" +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.hooks.vcs] +version-file = "src/interstellar/_version.py" + [tool.hatch.build.targets.wheel] packages = ["src/interstellar"] @@ -39,7 +45,7 @@ packages = ["src/interstellar"] interstellar = "interstellar.cli:app" [dependency-groups] -dev = ["pytest", "pytest-xdist", "pytest-cov", "nuitka", "ordered-set", "ruff", "packaging", "qrcode[pil]"] +dev = ["pytest", "pytest-xdist", "pytest-cov", "nuitka", "ordered-set", "ruff", "packaging", "qrcode[pil]", "ty"] [tool.pytest.ini_options] addopts = "-n auto" @@ -47,7 +53,8 @@ testpaths = ["tests"] [tool.coverage.run] source = ["src"] -omit = ["tests/*", ".venv/*", "build/*", "scripts/*", "*/__main__.py"] +omit = ["tests/*", ".venv/*", "build/*", "scripts/*", "*/__main__.py", "*/_version.py"] # Auto-generated by hatch-vcs + [tool.coverage.report] fail_under = 90 @@ -56,6 +63,7 @@ show_missing = true [tool.ruff] target-version = "py313" line-length = 88 +exclude = ["src/*/_version.py"] # Auto-generated by hatch-vcs [tool.ruff.lint] select = [ @@ -81,9 +89,10 @@ ignore = [ [tool.ruff.lint.pydocstyle] convention = "google" -[tool.ruff.lint.per-file-ignores] -"tests/*" = ["ANN", "D"] # Don't require annotations/docstrings in tests - [tool.ruff.format] quote-style = "double" indent-style = "space" + +[tool.ty] +[tool.ty.environment] +python-version = "3.13" diff --git a/src/interstellar/cli.py b/src/interstellar/cli.py index 7e737e7..b6fbe87 100644 --- a/src/interstellar/cli.py +++ b/src/interstellar/cli.py @@ -243,31 +243,34 @@ def reconstruct( ) -> None: """Reconstruct a BIP39 mnemonic from SLIP39 shares or BIP39 parts.""" cli.enforce_standard(standard) - if not shares and filename: + share_groups: list[list[str]] = shares or [] # type: ignore[assignment] + if not share_groups and filename: try: - shares = cli.get_mnemos(filename) + share_groups = cli.get_mnemos(filename) except FileNotFoundError: raise typer.BadParameter(f"File not found: {filename}") from None - if not shares: + if not share_groups: raise typer.BadParameter("Shares are required") required = 0 + reconstructed_parts: list[str] = [] if standard.upper() == "SLIP39": - groups = shares - shares = [] - for gidx, group in enumerate(groups): + for gidx, group in enumerate(share_groups): if digits: group = [_convert_digits_to_words(member, "SLIP39") for member in group] required = cli.slip39.get_required(group[gidx]) - shares.append(cli.slip39.reconstruct(group)) + reconstructed_parts.append(cli.slip39.reconstruct(group)) else: # BIP39 - shares = [part for group in shares for part in group] + reconstructed_parts = [part for group in share_groups for part in group] if digits: # Convert 1-indexed digits back to words - shares = [_convert_digits_to_words(share, "BIP39") for share in shares] - reconstructed = cli.bip39.reconstruct(shares) + reconstructed_parts = [ + _convert_digits_to_words(share, "BIP39") + for share in reconstructed_parts + ] + reconstructed = cli.bip39.reconstruct(reconstructed_parts) output = { "standard": "BIP39", "mnemonic": reconstructed, diff --git a/src/interstellar/tools.py b/src/interstellar/tools.py index e70fcde..ff30fd4 100644 --- a/src/interstellar/tools.py +++ b/src/interstellar/tools.py @@ -64,10 +64,10 @@ def deconstruct(self, mnemo: str, split: int = 2) -> list[str]: raise ValueError("Invalid BIP39 entropy split.") # Split the entropy into split parts size = len(entropy) // split - entropies = [entropy[i * size : (i + 1) * size] for i in range(split)] + entropies = [bytes(entropy[i * size : (i + 1) * size]) for i in range(split)] mnemos = [self.mnemo.to_mnemonic(ent) for ent in entropies] # Check if the mnemonics are valid - if not all(self.mnemo.check(mnemo) for mnemo in mnemos): + if not all(self.mnemo.check(m) for m in mnemos): raise ValueError("Invalid BIP39 mnemonics after deconstruction.") return mnemos @@ -80,9 +80,12 @@ def eth(self, mnemo: str) -> str: Returns: The derived Ethereum address. """ - mnemo = BIP39Mnemonic(mnemo) - wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(mnemo) + bip39_mnemo = BIP39Mnemonic(mnemo) + wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic( + bip39_mnemo + ) addr = wallet.address() + assert addr is not None, "Failed to derive Ethereum address" return addr def generate(self, num_words: int) -> str: @@ -94,8 +97,10 @@ def generate(self, num_words: int) -> str: Returns: A randomly generated BIP39 mnemonic phrase. """ - mnemo = BIP39Mnemonic.from_words(num_words, BIP39_MNEMONIC_LANGUAGES.ENGLISH) - return mnemo + generated = BIP39Mnemonic.from_words( + num_words, BIP39_MNEMONIC_LANGUAGES.ENGLISH + ) + return str(generated) class SLIP39: @@ -119,10 +124,11 @@ def deconstruct(self, mnemo: str, required: int = 2, total: int = 3) -> list[str Returns: List of SLIP39 share mnemonics. """ - _, shares = slip39.api.create( + result = slip39.api.create( "LEDGER", 1, {"KEYS": (required, total)}, mnemo, using_bip39=True - ).groups["KEYS"] - return shares + ) + _, shares = result.groups["KEYS"] # type: ignore[union-attr] + return list(shares) def reconstruct(self, shares: list[str]) -> str: """Reconstruct a BIP39 mnemonic from SLIP39 shares. @@ -133,9 +139,13 @@ def reconstruct(self, shares: list[str]) -> str: Returns: The reconstructed BIP39 mnemonic. """ - entropy = slip39.recovery.recover(shares, using_bip39=True, as_entropy=True) - mnemo = self.mnemo.to_mnemonic(entropy) - return mnemo + entropy = slip39.recovery.recover( + shares, # type: ignore[arg-type] + using_bip39=True, + as_entropy=True, + ) + reconstructed = self.mnemo.to_mnemonic(entropy) + return reconstructed def get_required(self, share: str) -> int: """Extract required threshold from a SLIP39 share. @@ -158,9 +168,12 @@ def eth(self, mnemo: str) -> str: Returns: The derived Ethereum address. """ - mnemo = SLIP39Mnemonic(mnemo) - wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic(mnemo) + slip39_mnemo = SLIP39Mnemonic(mnemo) + wallet = HDWallet(symbol=ETH, cryptocurrency=Ethereum).from_mnemonic( + slip39_mnemo + ) addr = wallet.address() + assert addr is not None, "Failed to derive Ethereum address" return addr def generate(self, num_words: int) -> str: @@ -172,5 +185,7 @@ def generate(self, num_words: int) -> str: Returns: A randomly generated SLIP39 mnemonic phrase. """ - mnemo = SLIP39Mnemonic.from_words(num_words, SLIP39_MNEMONIC_LANGUAGES.ENGLISH) - return mnemo + generated = SLIP39Mnemonic.from_words( + num_words, SLIP39_MNEMONIC_LANGUAGES.ENGLISH + ) + return str(generated) diff --git a/tests/conftest.py b/tests/conftest.py index 85c6461..c94f7b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,6 @@ SPLIT_PARTS = 2 -def assert_eth_addr(address): +def assert_eth_addr(address: str) -> None: """Assert that address is a valid Ethereum address.""" assert address.startswith("0x") and len(address) == 42 diff --git a/tests/test_cli.py b/tests/test_cli.py index fa42106..4003ebd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ import json import os import tempfile +from typing import Any from conftest import SPLIT_PARTS, WORDS_24, assert_eth_addr from packaging.version import parse as parse_version @@ -12,7 +13,7 @@ runner = CliRunner() -def assert_success_with_json(result) -> dict: +def assert_success_with_json(result: Any) -> dict[str, Any]: """Assert command succeeded and return parsed JSON output.""" assert result.exit_code == 0 return json.loads(result.stdout) @@ -21,7 +22,7 @@ def assert_success_with_json(result) -> dict: class TestVersion: """Test the version CLI command.""" - def test_version(self): + def test_version(self) -> None: """Test version command returns a valid version string.""" result = runner.invoke(app, ["version"]) assert result.exit_code == 0 @@ -36,12 +37,12 @@ def test_version(self): class TestDeconstruct: """Test the deconstruct CLI command.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() self.mnemo_24 = self.bip39.generate(WORDS_24) - def test_bip39_option(self): + def test_bip39_option(self) -> None: """Test BIP39 deconstruction from --mnemonic option.""" result = runner.invoke( app, ["deconstruct", "--mnemonic", self.mnemo_24, "--standard", "BIP39"] @@ -56,7 +57,7 @@ def test_bip39_option(self): for item in output: assert_eth_addr(item.get("eth_addr")) - def test_slip39_default(self): + def test_slip39_default(self) -> None: """Test SLIP39 deconstruction with default 2-of-3.""" result = runner.invoke(app, ["deconstruct", "--mnemonic", self.mnemo_24]) @@ -66,7 +67,7 @@ def test_slip39_default(self): assert len(output["shares"]) == SPLIT_PARTS assert all(len(group) == 3 for group in output["shares"]) - def test_slip39_3of5(self): + def test_slip39_3of5(self) -> None: """Test SLIP39 deconstruction with custom 3-of-5.""" result = runner.invoke( app, @@ -87,7 +88,7 @@ def test_slip39_3of5(self): assert len(output["shares"]) == SPLIT_PARTS assert all(len(group) == 5 for group in output["shares"]) - def test_slip39_5of7(self): + def test_slip39_5of7(self) -> None: """Test SLIP39 deconstruction with custom 5-of-7.""" result = runner.invoke( app, @@ -108,7 +109,7 @@ def test_slip39_5of7(self): assert len(output["shares"]) == SPLIT_PARTS assert all(len(group) == 7 for group in output["shares"]) - def test_from_file(self): + def test_from_file(self) -> None: """Test deconstruction from file.""" with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: f.write(self.mnemo_24) @@ -125,7 +126,7 @@ def test_from_file(self): finally: os.unlink(temp_file) - def test_with_digits(self): + def test_with_digits(self) -> None: """Test deconstruction with digits output.""" result = runner.invoke( app, ["deconstruct", "--mnemonic", self.mnemo_24, "--digits"] @@ -138,7 +139,7 @@ def test_with_digits(self): first_share = output["shares"][0][0] assert all(word.isdigit() or word == " " for word in first_share) - def test_invalid_standard(self): + def test_invalid_standard(self) -> None: """Test deconstruction with invalid standard.""" result = runner.invoke( app, ["deconstruct", "--mnemonic", self.mnemo_24, "--standard", "INVALID"] @@ -148,7 +149,7 @@ def test_invalid_standard(self): # Error is raised as exception, check the exception assert result.exception is not None - def test_missing_mnemonic(self): + def test_missing_mnemonic(self) -> None: """Test deconstruction without mnemonic or file.""" result = runner.invoke(app, ["deconstruct"]) @@ -158,13 +159,13 @@ def test_missing_mnemonic(self): class TestReconstruct: """Test the reconstruct CLI command.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() self.slip39 = SLIP39() self.mnemo_24 = self.bip39.generate(WORDS_24) - def test_slip39_file(self): + def test_slip39_file(self) -> None: """Test SLIP39 reconstruction from file.""" # Create shares bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) @@ -190,7 +191,7 @@ def test_slip39_file(self): finally: os.unlink(temp_file) - def test_slip39_option(self): + def test_slip39_option(self) -> None: """Test SLIP39 reconstruction from --shares option.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=2, total=3) @@ -206,7 +207,7 @@ def test_slip39_option(self): assert output["mnemonic"] == self.mnemo_24 assert_eth_addr(output.get("eth_addr")) - def test_bip39_file(self): + def test_bip39_file(self) -> None: """Test BIP39 reconstruction from file.""" # Use CLI to get properly formatted BIP39 output result = runner.invoke( @@ -232,7 +233,7 @@ def test_bip39_file(self): finally: os.unlink(temp_file) - def test_with_digits(self): + def test_with_digits(self) -> None: """Test reconstruction with digits input.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=2, total=3) @@ -266,7 +267,7 @@ def test_with_digits(self): finally: os.unlink(temp_file) - def test_invalid_standard(self): + def test_invalid_standard(self) -> None: """Test reconstruction with invalid standard.""" result = runner.invoke( app, ["reconstruct", "--shares", "dummy", "--standard", "INVALID"] @@ -276,13 +277,13 @@ def test_invalid_standard(self): # Error is raised as exception assert result.exception is not None - def test_missing_shares(self): + def test_missing_shares(self) -> None: """Test reconstruction without shares or file.""" result = runner.invoke(app, ["reconstruct"]) assert result.exit_code != 0 - def test_slip39_digits_zero_index(self): + def test_slip39_digits_zero_index(self) -> None: """Test reconstruction fails with zero index for SLIP39.""" # Create fake shares with index 0 (invalid for 1-indexed wordlist) invalid_shares = "0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20,1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20" @@ -296,7 +297,7 @@ def test_slip39_digits_zero_index(self): result.output ) or "Invalid SLIP39 word index: 0" in str(result.exception) - def test_slip39_digits_out_of_bounds(self): + def test_slip39_digits_out_of_bounds(self) -> None: """Test reconstruction fails with out-of-bounds index for SLIP39.""" # SLIP39 wordlist is 1-1024, so 1025 is out of bounds invalid_shares = "1025 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20,1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20" @@ -310,7 +311,7 @@ def test_slip39_digits_out_of_bounds(self): result.output ) or "Invalid SLIP39 word index: 1025" in str(result.exception) - def test_bip39_digits_zero_index(self): + def test_bip39_digits_zero_index(self) -> None: """Test reconstruction fails with zero index for BIP39.""" # Create fake BIP39 parts with index 0 (invalid) with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: @@ -338,7 +339,7 @@ def test_bip39_digits_zero_index(self): finally: os.unlink(temp_file) - def test_bip39_digits_out_of_bounds(self): + def test_bip39_digits_out_of_bounds(self) -> None: """Test reconstruction fails with out-of-bounds index for BIP39.""" # BIP39 wordlist is 1-2048, so 2049 is out of bounds with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: @@ -366,7 +367,7 @@ def test_bip39_digits_out_of_bounds(self): finally: os.unlink(temp_file) - def test_slip39_digits_invalid_non_integer(self): + def test_slip39_digits_invalid_non_integer(self) -> None: """Test reconstruction fails with non-integer string for SLIP39.""" invalid_shares = "1 2 3 abc 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20,1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20" @@ -379,7 +380,7 @@ def test_slip39_digits_invalid_non_integer(self): result.output ) or "Invalid SLIP39 digit: 'abc'" in str(result.exception) - def test_bip39_digits_invalid_non_integer(self): + def test_bip39_digits_invalid_non_integer(self) -> None: """Test reconstruction fails with non-integer string for BIP39.""" with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: f.write("1 2 3 xyz 5 6 7 8 9 10 11 12\n") @@ -410,12 +411,12 @@ def test_bip39_digits_invalid_non_integer(self): class TestRoundtrip: """Test full roundtrip: deconstruct -> reconstruct.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() self.mnemo_24 = self.bip39.generate(WORDS_24) - def test_default_2of3(self): + def test_default_2of3(self) -> None: """Test full roundtrip with default 2-of-3 threshold.""" # Deconstruct result = runner.invoke(app, ["deconstruct", "--mnemonic", self.mnemo_24]) @@ -439,7 +440,7 @@ def test_default_2of3(self): assert_eth_addr(recon_output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_3of5(self): + def test_3of5(self) -> None: """Test full roundtrip with 3-of-5 threshold.""" # Deconstruct result = runner.invoke( @@ -474,7 +475,7 @@ def test_3of5(self): assert_eth_addr(recon_output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_5of7(self): + def test_5of7(self) -> None: """Test full roundtrip with 5-of-7 threshold.""" # Deconstruct result = runner.invoke( @@ -512,7 +513,7 @@ def test_5of7(self): assert_eth_addr(recon_output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_bip39_only(self): + def test_bip39_only(self) -> None: """Test BIP39-only roundtrip (no SLIP39).""" # Deconstruct to BIP39 result = runner.invoke( @@ -544,7 +545,7 @@ def test_bip39_only(self): finally: os.unlink(temp_file) - def test_bip39_with_digits(self): + def test_bip39_with_digits(self) -> None: """Test BIP39-only roundtrip with digits mode.""" # Deconstruct to BIP39 with digits result = runner.invoke( @@ -598,7 +599,7 @@ def test_bip39_with_digits(self): finally: os.unlink(temp_file) - def test_with_digits(self): + def test_with_digits(self) -> None: """Test full roundtrip with digits mode.""" # Deconstruct with digits result = runner.invoke( @@ -631,7 +632,7 @@ def test_with_digits(self): finally: os.unlink(temp_file) - def test_file_based(self): + def test_file_based(self) -> None: """Test full roundtrip using files for both operations.""" # Write mnemonic to file with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: @@ -676,13 +677,13 @@ def test_file_based(self): class TestAutoDetect: """Test automatic required/total detection from shares.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() self.slip39 = SLIP39() self.mnemo_24 = self.bip39.generate(WORDS_24) - def test_2of3(self): + def test_2of3(self) -> None: """Test auto-detection of 2-of-3 threshold.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=2, total=3) @@ -698,7 +699,7 @@ def test_2of3(self): assert_eth_addr(output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_3of5(self): + def test_3of5(self) -> None: """Test auto-detection of 3-of-5 threshold.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=3, total=5) @@ -715,7 +716,7 @@ def test_3of5(self): assert_eth_addr(output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_5of7(self): + def test_5of7(self) -> None: """Test auto-detection of 5-of-7 threshold.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=5, total=7) @@ -731,7 +732,7 @@ def test_5of7(self): assert_eth_addr(output.get("eth_addr")) # Note: total cannot be reliably inferred from shares - def test_with_digits(self): + def test_with_digits(self) -> None: """Test auto-detection works with digits mode.""" bip_parts = self.bip39.deconstruct(self.mnemo_24, SPLIT_PARTS) shares_group1 = self.slip39.deconstruct(bip_parts[0], required=3, total=5) diff --git a/tests/test_tools.py b/tests/test_tools.py index e4f30bb..bfba87e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -17,25 +17,25 @@ class TestBIP39: """Test BIP39 mnemonic generation and validation.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() - def test_gen_12_words(self): + def test_gen_12_words(self) -> None: """Test generating a 12-word mnemonic.""" mnemo = self.bip39.generate(WORDS_12) words = mnemo.split() assert len(words) == WORDS_12 assert self.bip39.mnemo.check(mnemo) - def test_gen_24_words(self): + def test_gen_24_words(self) -> None: """Test generating a 24-word mnemonic.""" mnemo = self.bip39.generate(WORDS_24) words = mnemo.split() assert len(words) == WORDS_24 assert self.bip39.mnemo.check(mnemo) - def test_24_word_roundtrip(self): + def test_24_word_roundtrip(self) -> None: """Test deconstructing and reconstructing a 24-word mnemonic.""" mnemo = self.bip39.generate(WORDS_24) parts = self.bip39.deconstruct(mnemo, split=SPLIT_PARTS) @@ -46,7 +46,7 @@ def test_24_word_roundtrip(self): reconstructed = self.bip39.reconstruct(parts) assert reconstructed == mnemo - def test_entropy_validation(self): + def test_entropy_validation(self) -> None: """Test that 12-word mnemonic cannot be split into 2 parts (8-byte entropy is invalid).""" mnemo = self.bip39.generate(WORDS_12) # 12-word mnemonic has 16 bytes of entropy, splitting gives 8 bytes each @@ -54,26 +54,26 @@ def test_entropy_validation(self): with pytest.raises(ValueError): self.bip39.deconstruct(mnemo, split=SPLIT_PARTS) - def test_invalid_mnemo(self): + def test_invalid_mnemo(self) -> None: """Test that invalid mnemonic raises error on deconstruct.""" invalid_mnemo = "invalid mnemonic phrase here test fail bad" with pytest.raises(ValueError, match="Invalid BIP39 mnemo"): self.bip39.deconstruct(invalid_mnemo) - def test_wordlist(self): + def test_wordlist(self) -> None: """Test BIP39 wordlist has correct properties.""" assert len(self.bip39.words) == BIP39_WORDLIST_SIZE assert self.bip39.words == sorted(self.bip39.words) assert len(self.bip39.map) == BIP39_WORDLIST_SIZE - def test_consistency(self): + def test_consistency(self) -> None: """Test that generate produces valid mnemonics consistently.""" for _ in range(TEST_ITERATIONS): mnemo = self.bip39.generate(WORDS_24) assert self.bip39.mnemo.check(mnemo) assert len(mnemo.split()) == WORDS_24 - def test_eth(self): + def test_eth(self) -> None: """Test Ethereum address derivation from mnemonic.""" mnemo = self.bip39.generate(WORDS_12) address = self.bip39.eth(mnemo) @@ -83,12 +83,12 @@ def test_eth(self): class TestSLIP39: """Test SLIP39 mnemonic generation and validation.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.slip39 = SLIP39() self.bip39 = BIP39() - def test_gen_20_words(self): + def test_gen_20_words(self) -> None: """Test generating a 20-word mnemonic.""" mnemo = self.slip39.generate(WORDS_20) # Decode the mnemonic to check if it is valid. @@ -97,7 +97,7 @@ def test_gen_20_words(self): words = mnemo.split() assert len(words) == WORDS_20 - def test_24_word_roundtrip(self): + def test_24_word_roundtrip(self) -> None: """Test deconstructing and reconstructing a 24-word mnemonic.""" mnemo = self.bip39.generate(WORDS_24) shares = self.slip39.deconstruct( @@ -110,7 +110,7 @@ def test_24_word_roundtrip(self): reconstructed = self.slip39.reconstruct(shares[:SHARES_REQUIRED]) assert reconstructed == mnemo - def test_share_combos(self): + def test_share_combos(self) -> None: """Test reconstruction with different share combinations.""" mnemo = self.bip39.generate(WORDS_24) shares = self.slip39.deconstruct( @@ -122,7 +122,7 @@ def test_share_combos(self): assert self.slip39.reconstruct(shares[1:]) == mnemo assert self.slip39.reconstruct([shares[0], shares[SHARES_REQUIRED]]) == mnemo - def test_wordlist(self): + def test_wordlist(self) -> None: """Test SLIP39 wordlist has correct properties.""" assert len(self.slip39.words) == SLIP39_WORDLIST_SIZE assert self.slip39.words == sorted(self.slip39.words) @@ -132,12 +132,12 @@ def test_wordlist(self): class TestIntegration: """Integration tests for the complete workflow.""" - def setup_method(self): + def setup_method(self) -> None: """Setup test fixtures.""" self.bip39 = BIP39() self.slip39 = SLIP39() - def test_full_24_word_workflow(self): + def test_full_24_word_workflow(self) -> None: """Test the complete workflow from main function with 24-word mnemonic.""" # Generate initial mnemonic mnemo = self.bip39.generate(WORDS_24) @@ -173,7 +173,7 @@ def test_full_24_word_workflow(self): ) assert mnemo_reconstructed == mnemo - def test_12_word_direct(self): + def test_12_word_direct(self) -> None: """Test SLIP39 workflow with 12-word mnemonic (no BIP39 splitting).""" # Generate initial mnemonic mnemo = self.bip39.generate(WORDS_12) @@ -188,7 +188,7 @@ def test_12_word_direct(self): mnemo_reconstructed = self.slip39.reconstruct(shares[:SHARES_REQUIRED]) assert mnemo_reconstructed == mnemo - def test_bip39_iterations(self): + def test_bip39_iterations(self) -> None: """Test multiple iterations to ensure consistency.""" for _ in range(TEST_ITERATIONS): mnemo = self.bip39.generate(WORDS_24) @@ -196,7 +196,7 @@ def test_bip39_iterations(self): reconstructed = self.bip39.reconstruct(parts) assert reconstructed == mnemo - def test_slip39_iterations(self): + def test_slip39_iterations(self) -> None: """Test SLIP39 multiple iterations to ensure consistency.""" for _ in range(TEST_ITERATIONS): mnemo = self.bip39.generate(WORDS_24) diff --git a/uv.lock b/uv.lock index e9d5134..e3a6ab9 100644 --- a/uv.lock +++ b/uv.lock @@ -403,7 +403,6 @@ wheels = [ [[package]] name = "interstellar" -version = "0.0.0" source = { editable = "." } dependencies = [ { name = "mnemonic" }, @@ -421,6 +420,7 @@ dev = [ { name = "pytest-xdist" }, { name = "qrcode", extra = ["pil"] }, { name = "ruff" }, + { name = "ty" }, ] [package.metadata] @@ -440,6 +440,7 @@ dev = [ { name = "pytest-xdist" }, { name = "qrcode", extras = ["pil"] }, { name = "ruff" }, + { name = "ty" }, ] [[package]] @@ -842,6 +843,30 @@ version = "0.10.6" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/9b/89/740f079b9bbd7f8be417173127b43644c65c0c154f2c364586e0fb0e0afb/tabulate_slip39-0.10.6.tar.gz", hash = "sha256:9447076b7f18a8b2353b6c6789925837015558a5615c76ea3cfd73a7ed6d4347", size = 106120, upload-time = "2025-10-27T09:03:18.178Z" } +[[package]] +name = "ty" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/c3/41ae6346443eedb65b96761abfab890a48ce2aa5a8a27af69c5c5d99064d/ty-0.0.17.tar.gz", hash = "sha256:847ed6c120913e280bf9b54d8eaa7a1049708acb8824ad234e71498e8ad09f97", size = 5167209, upload-time = "2026-02-13T13:26:36.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/01/0ef15c22a1c54b0f728ceff3f62d478dbf8b0dcf8ff7b80b954f79584f3e/ty-0.0.17-py3-none-linux_armv6l.whl", hash = "sha256:64a9a16555cc8867d35c2647c2f1afbd3cae55f68fd95283a574d1bb04fe93e0", size = 10192793, upload-time = "2026-02-13T13:27:13.943Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2c/f4c322d9cded56edc016b1092c14b95cf58c8a33b4787316ea752bb9418e/ty-0.0.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eb2dbd8acd5c5a55f4af0d479523e7c7265a88542efe73ed3d696eb1ba7b6454", size = 10051977, upload-time = "2026-02-13T13:26:57.741Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a5/43746c1ff81e784f5fc303afc61fe5bcd85d0fcf3ef65cb2cef78c7486c7/ty-0.0.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f18f5fd927bc628deb9ea2df40f06b5f79c5ccf355db732025a3e8e7152801f6", size = 9564639, upload-time = "2026-02-13T13:26:42.781Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b8/280b04e14a9c0474af574f929fba2398b5e1c123c1e7735893b4cd73d13c/ty-0.0.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5383814d1d7a5cc53b3b07661856bab04bb2aac7a677c8d33c55169acdaa83df", size = 10061204, upload-time = "2026-02-13T13:27:00.152Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d7/493e1607d8dfe48288d8a768a2adc38ee27ef50e57f0af41ff273987cda0/ty-0.0.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c20423b8744b484f93e7bf2ef8a9724bca2657873593f9f41d08bd9f83444c9", size = 10013116, upload-time = "2026-02-13T13:26:34.543Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/22f3ed401520afac90dbdf1f9b8b7755d85b0d5c35c1cb35cf5bd11b59c2/ty-0.0.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e6f5b1aba97db9af86517b911674b02f5bc310750485dc47603a105bd0e83ddd", size = 10533623, upload-time = "2026-02-13T13:26:31.449Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/744b15279a11ac7138832e3a55595706b4a8a209c9f878e3ab8e571d9032/ty-0.0.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:488bce1a9bea80b851a97cd34c4d2ffcd69593d6c3f54a72ae02e5c6e47f3d0c", size = 11069750, upload-time = "2026-02-13T13:26:48.638Z" }, + { url = "https://files.pythonhosted.org/packages/f2/be/1133c91f15a0e00d466c24f80df486d630d95d1b2af63296941f7473812f/ty-0.0.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8df66b91ec84239420985ec215e7f7549bfda2ac036a3b3c065f119d1c06825a", size = 10870862, upload-time = "2026-02-13T13:26:54.715Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/a2ed209ef215b62b2d3246e07e833081e07d913adf7e0448fc204be443d6/ty-0.0.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:002139e807c53002790dfefe6e2f45ab0e04012e76db3d7c8286f96ec121af8f", size = 10628118, upload-time = "2026-02-13T13:26:45.439Z" }, + { url = "https://files.pythonhosted.org/packages/b3/0c/87476004cb5228e9719b98afffad82c3ef1f84334bde8527bcacba7b18cb/ty-0.0.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6c4e01f05ce82e5d489ab3900ca0899a56c4ccb52659453780c83e5b19e2b64c", size = 10038185, upload-time = "2026-02-13T13:27:02.693Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/98f0b3ba9aef53c1f0305519536967a4aa793a69ed72677b0a625c5313ac/ty-0.0.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2b226dd1e99c0d2152d218c7e440150d1a47ce3c431871f0efa073bbf899e881", size = 10047644, upload-time = "2026-02-13T13:27:05.474Z" }, + { url = "https://files.pythonhosted.org/packages/93/e0/06737bb80aa1a9103b8651d2eb691a7e53f1ed54111152be25f4a02745db/ty-0.0.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8b11f1da7859e0ad69e84b3c5ef9a7b055ceed376a432fad44231bdfc48061c2", size = 10231140, upload-time = "2026-02-13T13:27:10.844Z" }, + { url = "https://files.pythonhosted.org/packages/7c/79/e2a606bd8852383ba9abfdd578f4a227bd18504145381a10a5f886b4e751/ty-0.0.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c04e196809ff570559054d3e011425fd7c04161529eb551b3625654e5f2434cb", size = 10718344, upload-time = "2026-02-13T13:26:51.66Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2d/2663984ac11de6d78f74432b8b14ba64d170b45194312852b7543cf7fd56/ty-0.0.17-py3-none-win32.whl", hash = "sha256:305b6ed150b2740d00a817b193373d21f0767e10f94ac47abfc3b2e5a5aec809", size = 9672932, upload-time = "2026-02-13T13:27:08.522Z" }, + { url = "https://files.pythonhosted.org/packages/de/b5/39be78f30b31ee9f5a585969930c7248354db90494ff5e3d0756560fb731/ty-0.0.17-py3-none-win_amd64.whl", hash = "sha256:531828267527aee7a63e972f54e5eee21d9281b72baf18e5c2850c6b862add83", size = 10542138, upload-time = "2026-02-13T13:27:17.084Z" }, + { url = "https://files.pythonhosted.org/packages/40/b7/f875c729c5d0079640c75bad2c7e5d43edc90f16ba242f28a11966df8f65/ty-0.0.17-py3-none-win_arm64.whl", hash = "sha256:de9810234c0c8d75073457e10a84825b9cd72e6629826b7f01c7a0b266ae25b1", size = 10023068, upload-time = "2026-02-13T13:26:39.637Z" }, +] + [[package]] name = "typer" version = "0.21.0"