From 5361df5297c1a4bdb159540063f020a6b04d60e5 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 23 Sep 2026 16:34:04 -0500 Subject: [PATCH] Support Python 3.10 through 3.15 Use Python 3.10-compatible enum, typing, and integer conversion forms while preserving existing behavior. Advertise and test Python 3.10 through 3.15, and make create tests robust to interpreter-level terminal probes. Security: no intended change to codex32 encoding, recovery, creation preflight, or wallet behavior. Validation: - python -m pytest -q tests/test_cli.py (223 passed) - python -m pytest -q (864 passed) - python -m mypy src/codex32 - python -m ruff check --target-version py310 . - python -m ruff format --check . - python -m build --no-isolation - python -m twine check built artifacts Refs #18. --- .github/workflows/python-package.yml | 18 ++++++++++++++++++ AGENTS.md | 5 +++-- README.md | 2 +- pyproject.toml | 8 ++++++-- src/codex32/generation.py | 4 ++-- src/codex32/profiles/__init__.py | 10 ++++++---- src/codex32/profiles/bip39.py | 2 +- tests/test_bip93.py | 4 ++++ tests/test_cli.py | 18 ++++++++++-------- 9 files changed, 51 insertions(+), 20 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 165dd45..76addce 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -31,3 +31,21 @@ jobs: - run: python tools/differential_correction.py --verify - run: python -m build --no-isolation - run: python tools/verify_wheel_environment.py + + compatibility: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.14", "3.15"] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: ${{ matrix.python-version }} + allow-prereleases: ${{ matrix.python-version == '3.15' }} + - run: python -m pip install --upgrade pip + - run: python -m pip install --require-hashes -r requirements/cli-build-dependencies.txt + - run: python -m pip install --no-build-isolation -e '.[dev]' + - run: python -m pip check + - run: python -m pytest -q diff --git a/AGENTS.md b/AGENTS.md index cc23028..43f1f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,8 +28,9 @@ contains offline verification utilities. ## Development and verification -Use the existing virtual environment when available. Python 3.12 is the minimum; -CI also covers 3.13. Install development dependencies only when needed: +Use the existing virtual environment when available. Python 3.10 is the minimum; +the supported range is Python 3.10 through 3.15. Install development +dependencies only when needed: `python -m pip install -e '.[dev]'`. Run the CLI with `codex32 --help`. Choose checks according to the changed behavior: diff --git a/README.md b/README.md index e652578..604201f 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ obtain an independent review before relying on it with funds. See ## Install -Python 3.12 or 3.13 is required. The installed package has no third-party +Python 3.10 through 3.15 is supported. The installed package has no third-party runtime dependencies. To install it with the pinned build backend, run these commands from the project folder: diff --git a/pyproject.toml b/pyproject.toml index 870cf18..963eb29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ ] description = "Python reference implementation for codex32 (BIP93) and codex32-encoded master seeds." readme = "README.md" -requires-python = ">=3.12,<3.14" +requires-python = ">=3.10,<3.16" license = "MIT" license-files = ["LICENSE*"] maintainers = [ @@ -22,8 +22,12 @@ classifiers = [ "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Operating System :: OS Independent", "Topic :: Security :: Cryptography", "Topic :: Software Development :: Libraries", @@ -52,7 +56,7 @@ where = ["src"] testpaths = ["tests"] [tool.mypy] -python_version = "3.12" +python_version = "3.10" strict = true [tool.ruff] diff --git a/src/codex32/generation.py b/src/codex32/generation.py index 551ef61..fcb180b 100644 --- a/src/codex32/generation.py +++ b/src/codex32/generation.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass -from typing import Never, SupportsIndex, cast +from typing import NoReturn, SupportsIndex, cast from codex32._bip32 import _valid_root from codex32.bech32 import CHARSET, _u5_to_chars, convertbits @@ -198,7 +198,7 @@ class CreationCeremony: def __init__(self) -> None: raise TypeError("use a CreationCeremony class constructor") - def __reduce_ex__(self, _protocol: SupportsIndex) -> Never: + def __reduce_ex__(self, _protocol: SupportsIndex) -> NoReturn: raise TypeError("creation ceremonies cannot be copied or serialized") @classmethod diff --git a/src/codex32/profiles/__init__.py b/src/codex32/profiles/__init__.py index 89e053d..3293130 100644 --- a/src/codex32/profiles/__init__.py +++ b/src/codex32/profiles/__init__.py @@ -2,8 +2,8 @@ from __future__ import annotations -from enum import StrEnum -from typing import TYPE_CHECKING +from enum import Enum +from typing import TYPE_CHECKING, TypeAlias, Union from codex32.errors import UnknownProfile @@ -11,10 +11,12 @@ from codex32.profiles.bip39 import _Bip39Rules from codex32.profiles.cl32 import _Cl32Rules from codex32.profiles.ms32 import _Ms32Rules -type _ProfileRules = _Ms32Rules | _Cl32Rules | _Bip39Rules +_ProfileRules: TypeAlias = Union["_Ms32Rules", "_Cl32Rules", "_Bip39Rules"] -class Profile(StrEnum): +class Profile(str, Enum): + __str__ = str.__str__ + MS = "ms" CL = "cl" BIP39_12W = "bip39_12w" diff --git a/src/codex32/profiles/bip39.py b/src/codex32/profiles/bip39.py index c4994f5..5ebc5ed 100644 --- a/src/codex32/profiles/bip39.py +++ b/src/codex32/profiles/bip39.py @@ -57,7 +57,7 @@ def validate_payload(self, symbols: tuple[int, ...], index: str) -> None: semantic = value >> self.outer_padding embedded = semantic & ((1 << self.checksum_bits) - 1) entropy = semantic >> self.checksum_bits - entropy_bytes = entropy.to_bytes(self.entropy_bits // 8) + entropy_bytes = entropy.to_bytes(self.entropy_bits // 8, "big") expected = hashlib.sha256(entropy_bytes).digest()[0] >> (8 - self.checksum_bits) if embedded != expected: raise InvalidBip39Checksum("embedded BIP39 entropy checksum is invalid") diff --git a/tests/test_bip93.py b/tests/test_bip93.py index c8b3999..a57b1f0 100644 --- a/tests/test_bip93.py +++ b/tests/test_bip93.py @@ -32,6 +32,10 @@ from codex32.errors import CodexError, InvalidChecksum +def test_profile_string_matches_its_value() -> None: + assert str(Profile.MS) == "ms" + + def test_vector_1_parts_and_seed() -> None: secret = parse_codex32(VECTOR_1["secret_s"]) assert isinstance(secret, MasterSeed) diff --git a/tests/test_cli.py b/tests/test_cli.py index 42304ae..931d070 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -78,14 +78,12 @@ def isatty(self) -> bool: class _CreationOutput(io.StringIO): - def __init__(self, *, pretty: bool = False) -> None: + def __init__(self) -> None: super().__init__() - self.pretty = pretty - self.checks = 0 + self.interactive = True def isatty(self) -> bool: - self.checks += 1 - return self.pretty or self.checks == 1 + return self.interactive @dataclass @@ -151,12 +149,16 @@ def _invoke_terminal(args: list[str], *lines: str) -> _Result: def _invoke_confirmed_create( args: list[str], *lines: str, - terminal_output: bool = False, core: _FakeBitcoinCore | None = None, ) -> _Result: stdin = _TTYInput("\n".join(lines) + "\n") - stdout = _CreationOutput(pretty=terminal_output) + stdout = _CreationOutput() stderr = io.StringIO() + selected_core = core or _FakeBitcoinCore() + + def connect(*_args: object, **_kwargs: object) -> _FakeBitcoinCore: + stdout.interactive = False + return selected_core def confirm_card( artifact: Share | Secret, @@ -169,7 +171,7 @@ def confirm_card( with ( patch.object(sys, "stdin", stdin), patch("codex32.cli._confirm_card", confirm_card), - patch("codex32.cli.BitcoinCore.connect", return_value=core or _FakeBitcoinCore()), + patch("codex32.cli.BitcoinCore.connect", side_effect=connect), contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr), ):