Repository: ethereum/eth-account

Issue:
Change the `language` param in mnemonics to an enum
### What feature should we add?

It's a better idea all around.

Discussion:
pacrob: Hey @DavidRomanovizc , we discussed this today and decided we'd like this not to be breaking. Sorry for lack of clarity in the initial issue. Keeping everything you've done, just allow `Union[Language, str]` in where `language` is an arg, and keep return types too. If you get to it, awesome, otherwise I'll put it together later this week. Thank you!

Patch:
diff --git a/.bumpversion.cfg b/.bumpversion.cfg
index 5ccf046..eed6152 100644
--- a/.bumpversion.cfg
+++ b/.bumpversion.cfg
@@ -1,5 +1,5 @@
 [bumpversion]
-current_version = 0.13.1
+current_version = 0.13.3
 commit = True
 tag = True
 parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(-(?P<stage>[^.]*)\.(?P<devnum>\d+))?
diff --git a/docs/eth_account.hdaccount.rst b/docs/eth_account.hdaccount.rst
index 3cf765c..7d23a5a 100644
--- a/docs/eth_account.hdaccount.rst
+++ b/docs/eth_account.hdaccount.rst
@@ -1,12 +1,9 @@
-:orphan:
-
-eth\_account.hdaccount package
+HD Account
 ==============================
 
-Submodules
-----------
+.. CAUTION:: These features are experimental and unaudited
 
-eth\_account.hdaccount.deterministic module
+HD Deterministic module
 -------------------------------------------
 
 .. automodule:: eth_account.hdaccount.deterministic
@@ -14,7 +11,7 @@ eth\_account.hdaccount.deterministic module
    :undoc-members:
    :show-inheritance:
 
-eth\_account.hdaccount.mnemonic module
+HD Mnemonic module
 --------------------------------------
 
 .. automodule:: eth_account.hdaccount.mnemonic
diff --git a/docs/index.rst b/docs/index.rst
index 2ff040f..df2d060 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -12,6 +12,7 @@ Contents
     eth_account
     eth_account.signers
     eth_account.typed_transactions
+    eth_account.hdaccount
     release_notes
 
 
diff --git a/docs/release_notes.rst b/docs/release_notes.rst
index 1b56133..689d89d 100644
--- a/docs/release_notes.rst
+++ b/docs/release_notes.rst
@@ -3,6 +3,24 @@ Release Notes
 
 .. towncrier release notes start
 
+eth-account v0.13.3 (2024-08-28)
+--------------------------------
+
+Bugfixes
+~~~~~~~~
+
+- Raise lower pin on ``eth-keyfile`` to ``>=0.7.0`` where a bug was fixed in the default values for ``scrypt`` generated keyfiles. (`#297 <https://github.com/ethereum/eth-account/issues/297>`__)
+
+
+eth-account v0.13.2 (2024-08-21)
+--------------------------------
+
+Bugfixes
+~~~~~~~~
+
+- Pin ckzg to ``>=2.0``; update the the trusted setup and how it is loaded. (`#293 <https://github.com/ethereum/eth-account/issues/293>`__)
+
+
 eth-account v0.13.1 (2024-07-24)
 --------------------------------
 
diff --git a/eth_account/account.py b/eth_account/account.py
index 37cc436..62a3165 100644
--- a/eth_account/account.py
+++ b/eth_account/account.py
@@ -12,6 +12,7 @@ from typing import (
     Union,
     cast,
 )
+import warnings
 
 from eth_keyfile import (
     create_keyfile_json,
@@ -95,6 +96,7 @@ from eth_account.typed_transactions import (
 )
 from eth_account.types import (
     Blobs,
+    Language,
     PrivateKeyType,
     TransactionDictType,
 )
@@ -376,7 +378,7 @@ class Account(AccountLocalActions):
         self,
         passphrase: str = "",
         num_words: int = 12,
-        language: str = "english",
+        language: Union[Language, str] = Language.ENGLISH,
         account_path: str = ETHEREUM_DEFAULT_PATH,
     ) -> Tuple[LocalAccount, str]:
         r"""
@@ -392,7 +394,9 @@ class Account(AccountLocalActions):
         :param int num_words: Number of words to use with seed phrase.
                               Default is 12 words.
                               Must be one of [12, 15, 18, 21, 24].
-        :param str language: Language to use for BIP39 mnemonic seed phrase.
+        :param (Language, str) language: Language to use for BIP39 mnemonic seed phrase.
+                                         The use of a string is deprecated and will be
+                                         removed in a future version.
         :param str account_path: Specify an alternate HD path for deriving the
             seed using BIP32 HD wallet key derivation.
         :returns: A tuple consisting of an object with private key and
@@ -422,6 +426,13 @@ class Account(AccountLocalActions):
                 "enable them by running `Account.enable_unaudited_hdwallet_features()` "
                 "and try again."
             )
+        if isinstance(language, str):
+            warnings.warn(
+                "The language parameter should be a Language enum, not a string. "
+                "This will be enforced in a future version.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
         mnemonic = generate_mnemonic(num_words, language)
         return self.from_mnemonic(mnemonic, passphrase, account_path), mnemonic
 
diff --git a/eth_account/hdaccount/__init__.py b/eth_account/hdaccount/__init__.py
index 98b9d35..e5e37ea 100644
--- a/eth_account/hdaccount/__init__.py
+++ b/eth_account/hdaccount/__init__.py
@@ -1,7 +1,16 @@
+from typing import (
+    Union,
+)
+import warnings
+
 from eth_utils import (
     ValidationError,
 )
 
+from eth_account.types import (
+    Language,
+)
+
 from .deterministic import (
     HDPath,
 )
@@ -12,7 +21,14 @@ from .mnemonic import (
 ETHEREUM_DEFAULT_PATH = "m/44'/60'/0'/0/0"
 
 
-def generate_mnemonic(num_words: int, lang: str) -> str:
+def generate_mnemonic(num_words: int, lang: Union[Language, str]) -> str:
+    if isinstance(lang, str):
+        warnings.warn(
+            "The language parameter should be a Language enum, not a string. "
+            "This will be enforced in a future version.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
     return Mnemonic(lang).generate(num_words)
 
 
diff --git a/eth_account/hdaccount/deterministic.py b/eth_account/hdaccount/deterministic.py
index 1f228d5..bdd7077 100644
--- a/eth_account/hdaccount/deterministic.py
+++ b/eth_account/hdaccount/deterministic.py
@@ -2,9 +2,10 @@
 Generate Heirarchical Deterministic Wallets (HDWallet).
 
 Partially implements the BIP-0032, BIP-0043, and BIP-0044 specifications:
-BIP-0032: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
-BIP-0043: https://github.com/bitcoin/bips/blob/master/bip-0043.mediawiki
-BIP-0044: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki
+
+* BIP-0032: https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki
+* BIP-0043: https://github.com/bitcoin/bips/blob/master/bip-0043.mediawiki
+* BIP-0044: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki
 
 Skips serialization and public key derivation as unnecssary for this library's purposes.
 
diff --git a/eth_account/hdaccount/mnemonic.py b/eth_account/hdaccount/mnemonic.py
index 7380658..7f73173 100644
--- a/eth_account/hdaccount/mnemonic.py
+++ b/eth_account/hdaccount/mnemonic.py
@@ -20,7 +20,6 @@
 # WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 #
-
 import os
 from pathlib import (
     Path,
@@ -29,7 +28,9 @@ import secrets
 from typing import (
     Dict,
     List,
+    Union,
 )
+import warnings
 
 from bitarray import (
     bitarray,
@@ -42,6 +43,10 @@ from eth_utils import (
     ValidationError,
 )
 
+from eth_account.types import (
+    Language,
+)
+
 from ._utils import (
     normalize_string,
     pbkdf2_hmac_sha512,
@@ -71,15 +76,59 @@ def get_wordlist(language: str) -> List[str]:
 
 
 class Mnemonic:
-    def __init__(self, raw_language: str = "english"):
-        language = raw_language.lower().replace(" ", "_")
-        languages = Mnemonic.list_languages()
-        if language not in languages:
-            raise ValidationError(
-                f'Invalid language choice "{language}", must be one of {languages}'
+    r"""
+    Creates and validates BIP39 mnemonics.
+
+    .. doctest:: python
+
+        >>> from eth_account.hdaccount import Language, Mnemonic
+
+        >>> # Create a new Mnemonic instance with Czech language
+        >>> cz_mnemonic = Mnemonic(Language.CZECH)
+
+        >>> # English is the default language
+        >>> en_mnemonic = Mnemonic()
+
+        >>> # List available languages
+        >>> available_languages = Mnemonic.list_languages()
+        >>> print(available_languages)
+        ['chinese_simplified', 'chinese_traditional', 'czech', 'english', 'french', 'italian', 'japanese', 'korean', 'spanish']
+
+        >>> # Generate a new mnemonic phrase
+        >>> mnemonic_phrase = en_mnemonic.generate()
+        >>> print(mnemonic_phrase) # doctest: +SKIP
+        'cabin raise oven oven knock fantasy flock letter click empty skate volcano'
+
+        >>> # Validate a mnemonic phrase
+        >>> is_valid = en_mnemonic.is_mnemonic_valid(mnemonic_phrase)
+        >>> print(is_valid)
+        True
+
+        >>> # Convert mnemonic phrase to seed
+        >>> seed = en_mnemonic.to_seed(mnemonic_phrase, passphrase="optional passphrase")
+        >>> print(seed) # doctest: +SKIP
+        b'\x97ii\x07\x12\xf0$\x81\x98\xb6?\x07\x08t7\x18d\x87\xe1\x7f\xbe\xbaL\xb4i%\xeb\x12\xce\xe2h\x1c\xb2\x19\x13\xfb9wtoV\x9c\xb8\xdf;5\xba4X\xa3\xd6b`|\xdc\xb1\x10\xb0\xeeS\x86\x95\xd75'
+    """  # noqa: E501
+
+    def __init__(self, raw_language: Union[Language, str] = Language.ENGLISH):
+        if isinstance(raw_language, str):
+            warnings.warn(
+                "The language parameter should be a Language enum, not a string. "
+                "This will be enforced in a future version.",
+                DeprecationWarning,
+                stacklevel=2,
             )
+
+            language = raw_language.lower().replace(" ", "_")
+            languages = Mnemonic.list_languages()
+            if language not in languages:
+                raise ValidationError(
+                    f"Invalid language choice '{language}', must be one of {languages}"
+                )
+        else:
+            language = raw_language.value
         self.language = language
-        self.wordlist = get_wordlist(language)
+        self.wordlist = get_wordlist(self.language)
 
     @staticmethod
     def list_languages() -> List[str]:
@@ -87,13 +136,14 @@ class Mnemonic:
 
     @classmethod
     def detect_language(cls, raw_mnemonic: str) -> str:
+        # return type will be a Language enum value in a future version
         mnemonic = normalize_string(raw_mnemonic)
 
         words = set(mnemonic.split(" "))
         matching_languages = {
             lang
             for lang in Mnemonic.list_languages()
-            if len(words.intersection(cls(lang).wordlist)) == len(words)
+            if len(words.intersection(cls(Language(lang)).wordlist)) == len(words)
         }
 
         # No language had all words match it, so the language can't be fully determined
@@ -106,7 +156,7 @@ class Mnemonic:
         if len(matching_languages) == 2 and all(
             "chinese" in lang for lang in matching_languages
         ):
-            return "chinese_simplified"
+            return Language.CHINESE_SIMPLIFIED.value
 
         # Because certain wordlists share some similar words, if we detect multiple
         # languages that the provided mnemonic word(s) could be valid in, we have
@@ -117,7 +167,7 @@ class Mnemonic:
             )
 
         (language,) = matching_languages
-        return language
+        return Language(language).value
 
     def generate(self, num_words: int = 12) -> str:
         if num_words not in VALID_WORD_COUNTS:
diff --git a/eth_account/types.py b/eth_account/types.py
index 7bac5bd..e5652f3 100644
--- a/eth_account/types.py
+++ b/eth_account/types.py
@@ -1,3 +1,4 @@
+import enum
 from typing import (
     Dict,
     Sequence,
@@ -22,3 +23,15 @@ AccessList = Sequence[Dict[str, Union[HexStr, Sequence[HexStr]]]]
 RLPStructuredAccessList = Sequence[Sequence[Union[HexStr, Sequence[HexStr]]]]
 
 TransactionDictType = Dict[str, Union[AccessList, bytes, HexStr, int]]
+
+
+class Language(enum.Enum):
+    CHINESE_SIMPLIFIED = "chinese_simplified"
+    CHINESE_TRADITIONAL = "chinese_traditional"
+    CZECH = "czech"
+    ENGLISH = "english"
+    FRENCH = "french"
+    ITALIAN = "italian"
+    JAPANESE = "japanese"
+    KOREAN = "korean"
+    SPANISH = "spanish"
diff --git a/newsfragments/291.deprecation.rst b/newsfragments/291.deprecation.rst
new file mode 100644
index 0000000..ce14b00
--- /dev/null
+++ b/newsfragments/291.deprecation.rst
@@ -0,0 +1,1 @@
+Change ``language`` arg to enum in ``Mnemonic``
diff --git a/newsfragments/293.bugfix.rst b/newsfragments/293.bugfix.rst
deleted file mode 100644
index 169bd7c..0000000
--- a/newsfragments/293.bugfix.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-Pin ckzg to ``>=2.0``; update the the trusted setup and how it is loaded.
diff --git a/setup.py b/setup.py
index dd5de3b..df599de 100644
--- a/setup.py
+++ b/setup.py
@@ -41,7 +41,7 @@ with open("./README.md") as readme:
 setup(
     name="eth-account",
     # *IMPORTANT*: Don't manually change the version here. Use `make bump`, as described in readme
-    version="0.13.1",
+    version="0.13.3",
     description="""eth-account: Sign Ethereum transactions and messages with local private keys""",
     long_description=long_description,
     long_description_content_type="text/markdown",
@@ -52,7 +52,7 @@ setup(
     install_requires=[
         "bitarray>=2.4.0",
         "eth-abi>=4.0.0-b.2",
-        "eth-keyfile>=0.6.0",
+        "eth-keyfile>=0.7.0",
         "eth-keys>=0.4.0",
         "eth-rlp>=2.1.0",
         "eth-utils>=2.0.0",


Test patch:
diff --git a/tests/core/test_hdaccount.py b/tests/core/test_hdaccount.py
index 19c90ef..2563426 100644
--- a/tests/core/test_hdaccount.py
+++ b/tests/core/test_hdaccount.py
@@ -95,5 +95,15 @@ def test_bad_account_path2():
 
 
 def test_unknown_language():
-    with pytest.raises(ValidationError, match="Invalid language choice.*"):
+    with pytest.raises(
+        ValidationError, match="Invalid language choice 'pig_latin', must be one of.*"
+    ):
         Account.create_with_mnemonic(language="pig latin")
+
+
+def test_known_language_as_string_warns():
+    with pytest.warns(
+        DeprecationWarning,
+        match="The language parameter should be a Language enum, not a string*.",
+    ):
+        Account.create_with_mnemonic(language="spanish")
diff --git a/tests/core/test_mnemonic.py b/tests/core/test_mnemonic.py
index 190d43d..8fbf2f9 100644
--- a/tests/core/test_mnemonic.py
+++ b/tests/core/test_mnemonic.py
@@ -23,6 +23,7 @@
 import pytest
 
 from eth_account.hdaccount.mnemonic import (
+    Language,
     Mnemonic,
     ValidationError,
     normalize_string,
@@ -30,7 +31,7 @@ from eth_account.hdaccount.mnemonic import (
 
 
 def test_failed_checksum():
-    mnemo = Mnemonic("english")
+    mnemo = Mnemonic(Language.ENGLISH)
     assert not mnemo.is_mnemonic_valid(
         "bless cloud wheel regular tiny venue bird web grief security dignity zoo"
     )
@@ -39,25 +40,25 @@ def test_failed_checksum():
 @pytest.mark.parametrize(
     "language,word",
     [
-        ("english", "security"),
-        ("french", "baguette"),
-        ("spanish", "leche"),
-        ("italian", "cordata"),
-        ("czech", "nitro"),
-        ("japanese", "あらためる"),
-        ("korean", "강북"),
-        (
-            "chinese_simplified",
+        (Language.ENGLISH, "security"),
+        (Language.FRENCH, "baguette"),
+        (Language.SPANISH, "leche"),
+        (Language.ITALIAN, "cordata"),
+        (Language.CZECH, "nitro"),
+        (Language.JAPANESE, "あらためる"),
+        (Language.KOREAN, "강북"),
+        (
+            Language.CHINESE_SIMPLIFIED,
             "也",  # NOTE: This succeeds even though this character is in both
         ),
         (
-            "chinese_traditional",
+            Language.CHINESE_TRADITIONAL,
             "滅",  # traditional and simplified Chinese, assumes simplified
         ),
     ],
 )
 def test_detection(language, word):
-    assert language == Mnemonic.detect_language(word)
+    assert language.value == Mnemonic.detect_language(word)
 
 
 def test_undetected_language():
@@ -68,7 +69,7 @@ def test_undetected_language():
 
 
 def test_expand_word():
-    m = Mnemonic("english")
+    m = Mnemonic(Language.ENGLISH)
     assert "" == m.expand_word("")
     assert " " == m.expand_word(" ")
     assert "access" == m.expand_word("access")  # word in list
@@ -82,7 +83,7 @@ def test_expand_word():
 @pytest.mark.parametrize(
     "lang",
     {
-        lang
+        Language(lang)
         for lang in Mnemonic.list_languages()
         if lang
         not in (
@@ -108,6 +109,7 @@ def test_expand(lang):
 @pytest.mark.parametrize("lang", Mnemonic.list_languages())
 @pytest.mark.parametrize("num_words", [12, 15, 18, 21, 24])
 def test_generation(lang, num_words):
+    lang = Language(lang)
     m = Mnemonic(lang)
     mnemonic = m.generate(num_words)
     assert m.is_mnemonic_valid(mnemonic)
@@ -117,7 +119,7 @@ def test_generation(lang, num_words):
     if lang == "chinese_traditional":
         assert "chinese" in Mnemonic.detect_language(mnemonic)
     else:
-        assert Mnemonic.detect_language(mnemonic) == lang
+        assert Mnemonic.detect_language(mnemonic) == lang.value
     assert len(Mnemonic.to_seed(mnemonic)) == 64
 
 
@@ -318,7 +320,7 @@ def test_generation(lang, num_words):
     ],
 )
 def test_english_mnemonics(entropy, expected_mnemonic, expected_seed):
-    m = Mnemonic("english")
+    m = Mnemonic(Language.ENGLISH)
     mnemonic = m.to_mnemonic(bytes.fromhex(entropy))
     assert m.is_mnemonic_valid(mnemonic)
     assert mnemonic == expected_mnemonic
@@ -542,7 +544,7 @@ def test_english_mnemonics(entropy, expected_mnemonic, expected_seed):
     ],
 )
 def test_japanese_mnemonics(entropy, expected_mnemonic, passphrase, expected_seed):
-    m = Mnemonic("japanese")
+    m = Mnemonic(Language.JAPANESE)
     mnemonic = m.to_mnemonic(bytes.fromhex(entropy))
     assert m.is_mnemonic_valid(mnemonic)
     # NOTE For some reason, the strings weren't appearing in normalized form as
diff --git a/tests/integration/test_ethers_fuzzing.py b/tests/integration/test_ethers_fuzzing.py
index e437e92..57e48d1 100644
--- a/tests/integration/test_ethers_fuzzing.py
+++ b/tests/integration/test_ethers_fuzzing.py
@@ -14,6 +14,9 @@ from eth_account.hdaccount.mnemonic import (
     VALID_ENTROPY_SIZES,
     Mnemonic,
 )
+from eth_account.types import (
+    Language,
+)
 
 Account.enable_unaudited_hdwallet_features()
 
@@ -37,6 +40,7 @@ path_st = (
 @settings(deadline=1000)
 @pytest.mark.compatibility
 def test_compatibility(seed, language, account_path):
+    language = Language(language)
     mnemonic = Mnemonic(language).to_mnemonic(seed)
     acct = Account.from_mnemonic(mnemonic, account_path=account_path)
     # NOTE Must do `cd tests/integration/js-scripts && npm install -g .
@@ -47,7 +51,7 @@ def test_compatibility(seed, language, account_path):
             "-m",
             mnemonic,
             "-l",
-            language,
+            language.value,
             "-p",
             account_path,
         ],