mm-eth 0.7.4__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. mm_eth-0.8.0/CLAUDE.md +24 -0
  2. mm_eth-0.8.0/PKG-INFO +7 -0
  3. {mm_eth-0.7.4 → mm_eth-0.8.0}/pyproject.toml +9 -8
  4. mm_eth-0.8.0/src/mm_eth/__init__.py +1 -0
  5. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/abi.py +17 -2
  6. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/account.py +6 -43
  7. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/anvil.py +11 -0
  8. mm_eth-0.8.0/src/mm_eth/cli/__init__.py +1 -0
  9. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/calcs.py +4 -0
  10. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cli.py +14 -3
  11. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cli_utils.py +21 -4
  12. mm_eth-0.8.0/src/mm_eth/cli/cmd/__init__.py +1 -0
  13. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/balance_cmd.py +10 -7
  14. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/balances_cmd.py +13 -4
  15. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/deploy_cmd.py +9 -2
  16. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/node_cmd.py +14 -2
  17. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/solc_cmd.py +11 -7
  18. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/transfer_cmd.py +25 -1
  19. mm_eth-0.8.0/src/mm_eth/cli/cmd/wallet/__init__.py +1 -0
  20. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/cmd/wallet/mnemonic_cmd.py +5 -2
  21. mm_eth-0.8.0/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +15 -0
  22. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/rpc_helpers.py +6 -0
  23. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/cli/validators.py +12 -0
  24. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/converters.py +4 -0
  25. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/deploy.py +4 -0
  26. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/erc20.py +4 -0
  27. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/retry.py +17 -0
  28. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/rpc.py +32 -9
  29. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/solc.py +5 -0
  30. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/tx.py +13 -0
  31. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/utils.py +4 -0
  32. mm_eth-0.8.0/tests/__init__.py +1 -0
  33. mm_eth-0.8.0/tests/cli/__init__.py +1 -0
  34. mm_eth-0.8.0/tests/cli/cmd/__init__.py +1 -0
  35. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/cli/cmd/test_balance_cmd.py +3 -0
  36. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/cli/cmd/test_node_cmd.py +3 -0
  37. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/cli/cmd/test_solc_cmd.py +3 -0
  38. mm_eth-0.8.0/tests/cli/cmd/wallet/__init__.py +1 -0
  39. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/cli/cmd/wallet/test_mnemonic_cmd.py +3 -0
  40. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/cli/cmd/wallet/test_private_key_cmd.py +4 -0
  41. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/conftest.py +21 -0
  42. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/test_abi.py +6 -0
  43. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/test_account.py +6 -0
  44. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/test_converters.py +4 -0
  45. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/test_rpc.py +15 -1
  46. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/test_tx.py +5 -0
  47. {mm_eth-0.7.4 → mm_eth-0.8.0}/uv.lock +54 -399
  48. mm_eth-0.7.4/CLAUDE.md +0 -13
  49. mm_eth-0.7.4/PKG-INFO +0 -7
  50. mm_eth-0.7.4/src/mm_eth/__init__.py +0 -0
  51. mm_eth-0.7.4/src/mm_eth/cli/__init__.py +0 -0
  52. mm_eth-0.7.4/src/mm_eth/cli/cmd/__init__.py +0 -0
  53. mm_eth-0.7.4/src/mm_eth/cli/cmd/wallet/__init__.py +0 -0
  54. mm_eth-0.7.4/src/mm_eth/cli/cmd/wallet/private_key_cmd.py +0 -11
  55. mm_eth-0.7.4/tests/__init__.py +0 -0
  56. mm_eth-0.7.4/tests/cli/__init__.py +0 -0
  57. mm_eth-0.7.4/tests/cli/cmd/__init__.py +0 -0
  58. mm_eth-0.7.4/tests/cli/cmd/wallet/__init__.py +0 -0
  59. {mm_eth-0.7.4 → mm_eth-0.8.0}/.claude/settings.local.json +0 -0
  60. {mm_eth-0.7.4 → mm_eth-0.8.0}/.gitignore +0 -0
  61. {mm_eth-0.7.4 → mm_eth-0.8.0}/.pre-commit-config.yaml +0 -0
  62. {mm_eth-0.7.4 → mm_eth-0.8.0}/README.md +0 -0
  63. {mm_eth-0.7.4 → mm_eth-0.8.0}/dict.dic +0 -0
  64. {mm_eth-0.7.4 → mm_eth-0.8.0}/justfile +0 -0
  65. {mm_eth-0.7.4 → mm_eth-0.8.0}/src/mm_eth/py.typed +0 -0
  66. {mm_eth-0.7.4 → mm_eth-0.8.0}/tests/contracts/ERC20.sol +0 -0
mm_eth-0.8.0/CLAUDE.md ADDED
@@ -0,0 +1,24 @@
1
+ # AI Agent Start Guide
2
+
3
+ ## Critical: Language
4
+ RESPOND IN ENGLISH. Always. No exceptions.
5
+ User's language does NOT determine your response language.
6
+ Only switch if user EXPLICITLY requests it (e.g., "respond in {language}").
7
+ Language switching applies ONLY to chat. All code, comments, commit messages, and files must ALWAYS be in English — no exceptions.
8
+
9
+ ## Mandatory Rules (external)
10
+ These files are REQUIRED. Read them fully and follow all rules.
11
+ - `~/.claude/shared-rules/general.md`
12
+ - `~/.claude/shared-rules/python.md`
13
+
14
+ ## Project Reading (context)
15
+ These files are REQUIRED for project understanding.
16
+ - `README.md`
17
+
18
+ ## Preflight (mandatory)
19
+ Before your first response:
20
+ 1. Read all files listed above.
21
+ 2. Do not answer until all are read.
22
+ 3. In your first reply, list every file you have read from this document.
23
+
24
+ Failure to follow this protocol is considered an error.
mm_eth-0.8.0/PKG-INFO ADDED
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: mm-eth
3
+ Version: 0.8.0
4
+ Requires-Python: >=3.14
5
+ Requires-Dist: mm-web3~=0.6.2
6
+ Requires-Dist: typer~=0.21.1
7
+ Requires-Dist: web3~=7.14.1
@@ -1,9 +1,9 @@
1
1
  [project]
2
2
  name = "mm-eth"
3
- version = "0.7.4"
3
+ version = "0.8.0"
4
4
  description = ""
5
- requires-python = ">=3.13"
6
- dependencies = ["mm-web3~=0.5.6", "web3~=7.14.0", "typer~=0.21.1"]
5
+ requires-python = ">=3.14"
6
+ dependencies = ["mm-web3~=0.6.2", "web3~=7.14.1", "typer~=0.21.1"]
7
7
  [project.scripts]
8
8
  mm-eth = "mm_eth.cli.cli:app"
9
9
 
@@ -13,19 +13,19 @@ build-backend = "hatchling.build"
13
13
 
14
14
  [dependency-groups]
15
15
  dev = [
16
- "bandit~=1.9.2",
16
+ "bandit~=1.9.3",
17
17
  "mypy~=1.19.1",
18
18
  "pip-audit~=2.10.0",
19
19
  "pre-commit~=4.5.1",
20
20
  "pytest~=9.0.2",
21
21
  "pytest-asyncio~=1.3.0",
22
22
  "pytest-xdist~=3.8.0",
23
- "ruff~=0.14.11",
23
+ "ruff~=0.15.0",
24
24
  "python-dotenv~=1.2.0",
25
25
  ]
26
26
 
27
27
  [tool.mypy]
28
- python_version = "3.13"
28
+ python_version = "3.14"
29
29
  mypy_path = "stubs"
30
30
  warn_no_return = false
31
31
  implicit_reexport = true
@@ -38,7 +38,7 @@ ignore_missing_imports = true
38
38
 
39
39
  [tool.ruff]
40
40
  line-length = 130
41
- target-version = "py313"
41
+ target-version = "py314"
42
42
  [tool.ruff.lint]
43
43
  select = ["ALL"]
44
44
  # fixable = ["F401"]
@@ -47,7 +47,6 @@ ignore = [
47
47
  "A005", # flake8-builtins: stdlib-module-shadowing
48
48
  "ERA001", # eradicate: commented-out-code
49
49
  "PT", # flake8-pytest-style
50
- "D", # pydocstyle
51
50
  "FIX", # flake8-fixme
52
51
  "PLR0911", # pylint: too-many-return-statements
53
52
  "PLR0912", # pylint: too-many-branches
@@ -66,6 +65,8 @@ ignore = [
66
65
  "COM812", # it's used in ruff formatter
67
66
  "ASYNC109", # flake8-async: async-function-with-timeout
68
67
  "G004",
68
+ "D203", # pydocstyle: one-blank-line-before-class (conflicts with D211)
69
+ "D213", # pydocstyle: multi-line-summary-second-line (conflicts with D212)
69
70
  ]
70
71
  [tool.ruff.lint.pep8-naming]
71
72
  classmethod-decorators = ["field_validator"]
@@ -0,0 +1 @@
1
+ """Python library for interacting with EVM blockchains."""
@@ -1,3 +1,5 @@
1
+ """ABI encoding, decoding, and function signature utilities."""
2
+
1
3
  import string
2
4
  from collections.abc import Sequence
3
5
  from dataclasses import dataclass
@@ -13,16 +15,21 @@ from web3.auto import w3
13
15
 
14
16
  @dataclass
15
17
  class NameTypeValue:
18
+ """A named, typed ABI parameter value."""
19
+
16
20
  name: str
17
21
  type: str
18
22
  value: Any
19
23
 
20
24
 
21
25
  class FunctionInput(BaseModel):
26
+ """Decoded function input with ABI metadata and parameter values."""
27
+
22
28
  function_abi: ABIFunction
23
29
  params: dict[str, Any]
24
30
 
25
31
  def decode_params_bytes(self) -> dict[str, Any]:
32
+ """Convert bytes parameters to human-readable hex or text strings."""
26
33
  result: dict[str, Any] = {}
27
34
  for k, v in self.params.items():
28
35
  if isinstance(v, bytes):
@@ -36,10 +43,12 @@ class FunctionInput(BaseModel):
36
43
  return result
37
44
 
38
45
  def function_signature(self) -> str:
46
+ """Return the function signature string, e.g. 'transfer(to,value)'."""
39
47
  inputs = [i["name"] for i in self.function_abi["inputs"]]
40
48
  return self.function_abi["name"] + f"({','.join(inputs)})"
41
49
 
42
50
  def to_list(self, decode_bytes: bool = False) -> list[NameTypeValue]:
51
+ """Convert parameters to a list of NameTypeValue, optionally decoding bytes."""
43
52
  result = []
44
53
  for param in self.function_abi["inputs"]:
45
54
  name = param["name"]
@@ -55,12 +64,14 @@ class FunctionInput(BaseModel):
55
64
 
56
65
 
57
66
  def decode_function_input(contract_abi: ABI, tx_input: str) -> FunctionInput:
67
+ """Decode a transaction input hex string using the contract ABI."""
58
68
  contract = w3.eth.contract(abi=contract_abi)
59
69
  func, params = contract.decode_function_input(HexStr(tx_input))
60
70
  return FunctionInput(function_abi=func.abi, params=params)
61
71
 
62
72
 
63
73
  def get_function_abi(contr_abi: ABI, fn_name: str) -> ABIFunction:
74
+ """Find and return the ABI entry for a function by name."""
64
75
  abi = next((x for x in contr_abi if x.get("name", None) == fn_name and x.get("type", None) == "function"), None)
65
76
  if not abi:
66
77
  raise ValueError("can't find abi for function: " + fn_name)
@@ -68,6 +79,7 @@ def get_function_abi(contr_abi: ABI, fn_name: str) -> ABIFunction:
68
79
 
69
80
 
70
81
  def encode_function_input_by_abi(abi: ABI | ABIFunction, fn_name: str, args: list[Any]) -> HexStr:
82
+ """Encode function call data using a contract ABI or function ABI."""
71
83
  # if abi is contract_abi, get function_abi
72
84
  if isinstance(abi, Sequence):
73
85
  abi = get_function_abi(abi, fn_name)
@@ -85,6 +97,7 @@ def encode_function_input_by_abi(abi: ABI | ABIFunction, fn_name: str, args: lis
85
97
 
86
98
 
87
99
  def encode_function_input_by_signature(func_signature: str, args: list[Any]) -> HexStr:
100
+ """Encode function call data from a signature string like 'func1(uint256,address)'."""
88
101
  if not func_signature.endswith(")"):
89
102
  raise ValueError(f"wrong func_signature={func_signature}. example: func1(uint256,address)")
90
103
  func_signature = func_signature.removesuffix(")")
@@ -102,20 +115,22 @@ def encode_function_input_by_signature(func_signature: str, args: list[Any]) ->
102
115
 
103
116
 
104
117
  def encode_function_signature(func_name_with_types: str) -> HexStr:
105
- """input example 'transfer(address,uint256)'"""
118
+ """Encode a 4-byte function selector from a function signature like 'transfer(address,uint256)'."""
106
119
  return HexStr(eth_utils.to_hex(Web3.keccak(text=func_name_with_types))[0:10])
107
120
 
108
121
 
109
122
  def decode_data(types: list[str], data: str) -> tuple[Any, ...]:
123
+ """Decode ABI-encoded hex data into a tuple of values."""
110
124
  return eth_abi.decode(types, eth_utils.to_bytes(hexstr=HexStr(data)))
111
125
 
112
126
 
113
127
  def encode_data(types: list[str], args: list[Any]) -> str:
128
+ """ABI-encodes values into a hex string."""
114
129
  return eth_utils.to_hex(eth_abi.encode(types, args))
115
130
 
116
131
 
117
132
  def parse_function_signatures(contract_abi: ABI) -> dict[str, str]:
118
- """returns dict, key: function_name_and_types, value: 4bytes signature"""
133
+ """Return dict, key: function_name_and_types, value: 4bytes signature."""
119
134
  result: dict[str, str] = {}
120
135
  for item in contract_abi:
121
136
  if item.get("type", None) == "function":
@@ -1,3 +1,5 @@
1
+ """Ethereum account generation, derivation, and key utilities."""
2
+
1
3
  from dataclasses import dataclass
2
4
 
3
5
  import eth_utils
@@ -27,35 +29,13 @@ class DerivedAccount:
27
29
 
28
30
 
29
31
  def generate_mnemonic(num_words: int = 24) -> str:
30
- """
31
- Generates a BIP39 mnemonic phrase in English.
32
-
33
- Args:
34
- num_words (int): Number of words in the mnemonic (12, 15, 18, 21, or 24).
35
-
36
- Returns:
37
- str: Generated mnemonic phrase.
38
- """
32
+ """Generate a BIP39 mnemonic phrase in English."""
39
33
  mnemonic = Mnemonic(Language.ENGLISH)
40
34
  return mnemonic.generate(num_words=num_words)
41
35
 
42
36
 
43
37
  def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit: int) -> list[DerivedAccount]:
44
- """
45
- Derives multiple Ethereum accounts from a given mnemonic phrase.
46
-
47
- Args:
48
- mnemonic (str): BIP39 mnemonic phrase.
49
- passphrase (str): Optional BIP39 passphrase.
50
- derivation_path (str): Path template with '{i}' as index placeholder.
51
- limit (int): Number of accounts to derive.
52
-
53
- Raises:
54
- ValueError: If derivation_path does not contain '{i}'.
55
-
56
- Returns:
57
- list[DerivedAccount]: List of derived Ethereum accounts.
58
- """
38
+ """Derive multiple Ethereum accounts from a mnemonic phrase."""
59
39
  if "{i}" not in derivation_path:
60
40
  raise ValueError("derivation_path must contain {i}, for example: " + DEFAULT_DERIVATION_PATH)
61
41
 
@@ -69,16 +49,7 @@ def derive_accounts(mnemonic: str, passphrase: str, derivation_path: str, limit:
69
49
 
70
50
 
71
51
  def private_to_address(private_key: str, lower: bool = False) -> Result[str]:
72
- """
73
- Converts a private key to its corresponding Ethereum address.
74
-
75
- Args:
76
- private_key (str): Hex-encoded private key.
77
- lower (bool): Whether to return address in lowercase.
78
-
79
- Returns:
80
- Result[str]: Ok(address) or Err(exception) on failure.
81
- """
52
+ """Convert a private key to its corresponding Ethereum address."""
82
53
  try:
83
54
  acc: LocalAccount = Account.from_key(private_key)
84
55
  address = acc.address.lower() if lower else acc.address
@@ -88,15 +59,7 @@ def private_to_address(private_key: str, lower: bool = False) -> Result[str]:
88
59
 
89
60
 
90
61
  def is_private_key(private_key: str) -> bool:
91
- """
92
- Checks if a given hex string is a valid Ethereum private key.
93
-
94
- Args:
95
- private_key (str): Hex-encoded private key.
96
-
97
- Returns:
98
- bool: True if valid, False otherwise.
99
- """
62
+ """Check if a hex string is a valid Ethereum private key."""
100
63
  try:
101
64
  key_api.PrivateKey(eth_utils.decode_hex(private_key)).public_key.to_address()
102
65
  return True # noqa: TRY300
@@ -1,3 +1,5 @@
1
+ """Anvil (local Ethereum node) process management."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import socket
@@ -11,27 +13,34 @@ from mm_eth import account, rpc
11
13
 
12
14
 
13
15
  class Anvil:
16
+ """Manages an Anvil local Ethereum node process."""
17
+
14
18
  def __init__(self, *, chain_id: int, port: int, mnemonic: str) -> None:
19
+ """Initialize Anvil configuration without starting the process."""
15
20
  self.chain_id = chain_id
16
21
  self.port = port
17
22
  self.mnemonic = mnemonic
18
23
  self.process: Popen | None = None # type: ignore[type-arg]
19
24
 
20
25
  def start_process(self) -> None:
26
+ """Start the Anvil subprocess."""
21
27
  cmd = f"anvil -m '{self.mnemonic}' -p {self.port} --chain-id {self.chain_id}"
22
28
  self.process = Popen(cmd, shell=True) # noqa: S602 # nosec
23
29
  time.sleep(3)
24
30
 
25
31
  def stop(self) -> None:
32
+ """Kill the Anvil subprocess if running."""
26
33
  if self.process:
27
34
  self.process.kill()
28
35
 
29
36
  async def check(self) -> bool:
37
+ """Verify the Anvil node is running and has the expected chain ID."""
30
38
  res = await rpc.eth_chain_id(self.rpc_url)
31
39
  return res.is_ok() and res.unwrap() == self.chain_id
32
40
 
33
41
  @property
34
42
  def rpc_url(self) -> str:
43
+ """Return the local HTTP RPC URL for this Anvil instance."""
35
44
  return f"http://localhost:{self.port}"
36
45
 
37
46
  @classmethod
@@ -42,6 +51,7 @@ class Anvil:
42
51
  mnemonic: str = "",
43
52
  attempts: int = 3,
44
53
  ) -> Result[Anvil]:
54
+ """Launch an Anvil instance, retrying on failure up to the given number of attempts."""
45
55
  if not mnemonic:
46
56
  mnemonic = account.generate_mnemonic()
47
57
 
@@ -58,6 +68,7 @@ class Anvil:
58
68
 
59
69
 
60
70
  def get_free_local_port() -> int:
71
+ """Find and return an available local TCP port."""
61
72
  sock = socket.socket()
62
73
  sock.bind(("", 0))
63
74
  port = sock.getsockname()[1]
@@ -0,0 +1 @@
1
+ """CLI interface for mm-eth."""
@@ -1,11 +1,15 @@
1
+ """Expression calculators for ETH and token values."""
2
+
1
3
  import mm_web3
2
4
 
3
5
  from mm_eth.cli.validators import SUFFIX_DECIMALS
4
6
 
5
7
 
6
8
  def calc_eth_expression(expression: str, variables: dict[str, int] | None = None) -> int:
9
+ """Evaluate an expression with ETH unit suffixes (eth, gwei, ether) to wei."""
7
10
  return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals=SUFFIX_DECIMALS)
8
11
 
9
12
 
10
13
  def calc_token_expression(expression: str, token_decimals: int, variables: dict[str, int] | None = None) -> int:
14
+ """Evaluate an expression with token unit suffix (t) to the smallest token unit."""
11
15
  return mm_web3.calc_expression_with_vars(expression, variables, unit_decimals={"t": token_decimals})
@@ -1,10 +1,12 @@
1
+ """Main CLI application and command definitions."""
2
+
1
3
  import asyncio
2
4
  import importlib.metadata
3
5
  from pathlib import Path
4
6
  from typing import Annotated
5
7
 
6
- import mm_print
7
8
  import typer
9
+ from mm_print import print_plain
8
10
 
9
11
  from mm_eth.account import DEFAULT_DERIVATION_PATH
10
12
  from mm_eth.cli.cli_utils import PrintFormat
@@ -31,6 +33,7 @@ def mnemonic_command( # nosec
31
33
  limit: int = typer.Option(10, "--limit", "-l"),
32
34
  save_file: str = typer.Option("", "--save", "-s", help="Save private keys to a file"),
33
35
  ) -> None:
36
+ """Generate or derive ETH accounts from a mnemonic phrase."""
34
37
  mnemonic_cmd.run(
35
38
  mnemonic,
36
39
  passphrase=passphrase,
@@ -44,6 +47,7 @@ def mnemonic_command( # nosec
44
47
 
45
48
  @wallet_app.command(name="private-key", help="Print an address for a private key")
46
49
  def private_key_command(private_key: str) -> None:
50
+ """Print the address for a given private key."""
47
51
  private_key_cmd.run(private_key)
48
52
 
49
53
 
@@ -53,6 +57,7 @@ def node_command(
53
57
  proxy: Annotated[str | None, typer.Option("--proxy", "-p", help="Proxy")] = None,
54
58
  print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.TABLE,
55
59
  ) -> None:
60
+ """Check RPC node URLs and display chain info."""
56
61
  asyncio.run(node_cmd.run(urls, proxy, print_format))
57
62
 
58
63
 
@@ -64,6 +69,7 @@ def balance_command(
64
69
  wei: bool = typer.Option(False, "--wei", "-w", help="Print balances in wei units"),
65
70
  print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
66
71
  ) -> None:
72
+ """Print ETH and optional ERC-20 token balance for an address."""
67
73
  asyncio.run(balance_cmd.run(rpc_url, wallet_address, token_address, wei, print_format))
68
74
 
69
75
 
@@ -74,6 +80,7 @@ def balances_command(
74
80
  nonce: bool = typer.Option(False, "--nonce", "-n", help="Print nonce also"),
75
81
  wei: bool = typer.Option(False, "--wei", "-w", help="Show balances in WEI"),
76
82
  ) -> None:
83
+ """Print base and ERC-20 token balances for multiple addresses."""
77
84
  asyncio.run(
78
85
  balances_cmd.run(BalancesCmdParams(config_path=config_path, print_config=print_config, wei=wei, show_nonce=nonce))
79
86
  )
@@ -85,6 +92,7 @@ def solc_command(
85
92
  tmp_dir: Path = Path("/tmp"), # noqa: S108 # nosec
86
93
  print_format: Annotated[PrintFormat, typer.Option("--format", "-f", help="Print format")] = PrintFormat.PLAIN,
87
94
  ) -> None:
95
+ """Compile a Solidity file and print the ABI and bytecode."""
88
96
  solc_cmd.run(contract_path, tmp_dir, print_format)
89
97
 
90
98
 
@@ -93,6 +101,7 @@ def deploy_command(
93
101
  config_path: Path,
94
102
  print_config: bool = typer.Option(False, "--config", "-c", help="Print config and exit"),
95
103
  ) -> None:
104
+ """Deploy a smart contract onchain from a config file."""
96
105
  asyncio.run(deploy_cmd.run(DeployCmdParams(config_path=config_path, print_config=print_config)))
97
106
 
98
107
 
@@ -108,6 +117,7 @@ def transfer_command(
108
117
  skip_receipt: bool = typer.Option(False, "--skip-receipt", help="Don't wait for a tx receipt"),
109
118
  debug: bool = typer.Option(False, "--debug", "-d", help="Print debug info"),
110
119
  ) -> None:
120
+ """Transfer ETH or ERC-20 tokens based on a config file."""
111
121
  asyncio.run(
112
122
  transfer_cmd.run(
113
123
  TransferCmdParams(
@@ -124,14 +134,15 @@ def transfer_command(
124
134
 
125
135
 
126
136
  def version_callback(value: bool) -> None:
137
+ """Print the version and exit when --version is passed."""
127
138
  if value:
128
- mm_print.plain(f"mm-eth: {importlib.metadata.version('mm-eth')}")
139
+ print_plain(f"mm-eth: {importlib.metadata.version('mm-eth')}")
129
140
  raise typer.Exit
130
141
 
131
142
 
132
143
  @app.callback()
133
144
  def main(_version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True)) -> None:
134
- pass
145
+ """CLI entry point for mm-eth."""
135
146
 
136
147
 
137
148
  if __name__ == "__main_":
@@ -1,8 +1,11 @@
1
+ """Shared CLI utilities, config base classes, and output helpers."""
2
+
1
3
  import importlib.metadata
2
- from enum import Enum, unique
4
+ from enum import StrEnum, unique
3
5
  from pathlib import Path
6
+ from typing import NoReturn
4
7
 
5
- import mm_print
8
+ import typer
6
9
  from pydantic import BaseModel
7
10
  from rich.table import Table
8
11
 
@@ -10,13 +13,16 @@ from mm_eth import rpc
10
13
 
11
14
 
12
15
  @unique
13
- class PrintFormat(str, Enum):
16
+ class PrintFormat(StrEnum):
17
+ """Output format for CLI commands."""
18
+
14
19
  PLAIN = "plain"
15
20
  TABLE = "table"
16
21
  JSON = "json"
17
22
 
18
23
 
19
24
  def public_rpc_url(url: str | None) -> str:
25
+ """Resolve a network name or alias to a public RPC URL."""
20
26
  if not url or url == "1":
21
27
  return "https://ethereum-rpc.publicnode.com"
22
28
  if url.startswith(("http://", "https://", "ws://", "wss://")):
@@ -38,20 +44,31 @@ def public_rpc_url(url: str | None) -> str:
38
44
 
39
45
 
40
46
  class BaseConfigParams(BaseModel):
47
+ """Base parameters shared by CLI commands that read a config file."""
48
+
41
49
  config_path: Path
42
50
  print_config: bool
43
51
 
44
52
 
45
53
  async def check_nodes_for_chain_id(nodes: list[str], chain_id: int) -> None:
54
+ """Validate that all nodes return the expected chain ID, exiting on mismatch."""
46
55
  for node in nodes:
47
56
  res = (await rpc.eth_chain_id(node)).unwrap("can't get chain_id")
48
57
  if res != chain_id:
49
- mm_print.exit_with_error(f"node {node} has a wrong chain_id: {res}")
58
+ fatal(f"node {node} has a wrong chain_id: {res}")
50
59
 
51
60
 
52
61
  def add_table_raw(table: Table, *row: object) -> None:
62
+ """Add a row to a Rich table, converting all values to strings."""
53
63
  table.add_row(*[str(cell) for cell in row])
54
64
 
55
65
 
56
66
  def get_version() -> str:
67
+ """Return the installed mm-eth package version."""
57
68
  return importlib.metadata.version("mm-eth")
69
+
70
+
71
+ def fatal(message: str) -> NoReturn:
72
+ """Print an error message and exit with code 1."""
73
+ typer.echo(message)
74
+ raise typer.Exit(1)
@@ -0,0 +1 @@
1
+ """CLI command implementations."""
@@ -1,5 +1,7 @@
1
+ """CLI command: print account balance."""
2
+
1
3
  import eth_utils
2
- import mm_print
4
+ from mm_print import print_json, print_plain
3
5
 
4
6
  from mm_eth import converters, rpc
5
7
  from mm_eth.cli import cli_utils
@@ -7,13 +9,14 @@ from mm_eth.cli.cli import PrintFormat
7
9
 
8
10
 
9
11
  async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei: bool, print_format: PrintFormat) -> None:
12
+ """Fetch and print ETH and optional ERC-20 token balance for an address."""
10
13
  result: dict[str, object] = {}
11
14
  rpc_url = cli_utils.public_rpc_url(rpc_url)
12
15
 
13
16
  # nonce
14
17
  result["nonce"] = (await rpc.eth_get_transaction_count(rpc_url, wallet_address)).value_or_error()
15
18
  if print_format == PrintFormat.PLAIN:
16
- mm_print.plain(f"nonce: {result['nonce']}")
19
+ print_plain(f"nonce: {result['nonce']}")
17
20
 
18
21
  # eth balance
19
22
  result["eth_balance"] = (
@@ -22,18 +25,18 @@ async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei:
22
25
  .value_or_error()
23
26
  )
24
27
  if print_format == PrintFormat.PLAIN:
25
- mm_print.plain(f"eth_balance: {result['eth_balance']}")
28
+ print_plain(f"eth_balance: {result['eth_balance']}")
26
29
 
27
30
  if token_address:
28
31
  # token decimal
29
32
  result["token_decimal"] = (await rpc.erc20_decimals(rpc_url, token_address)).value_or_error()
30
33
  if print_format == PrintFormat.PLAIN:
31
- mm_print.plain(f"token_decimal: {result['token_decimal']}")
34
+ print_plain(f"token_decimal: {result['token_decimal']}")
32
35
 
33
36
  # token symbol
34
37
  result["token_symbol"] = (await rpc.erc20_symbol(rpc_url, token_address)).value_or_error()
35
38
  if print_format == PrintFormat.PLAIN:
36
- mm_print.plain(f"token_symbol: {result['token_symbol']}")
39
+ print_plain(f"token_symbol: {result['token_symbol']}")
37
40
 
38
41
  # token balance
39
42
  result["token_balance"] = (await rpc.erc20_balance(rpc_url, token_address, wallet_address)).value_or_error()
@@ -41,7 +44,7 @@ async def run(rpc_url: str, wallet_address: str, token_address: str | None, wei:
41
44
  result["token_balance"] = converters.from_wei(result["token_balance"], "t", decimals=result["token_decimal"])
42
45
 
43
46
  if print_format == PrintFormat.PLAIN:
44
- mm_print.plain(f"token_balance: {result['token_balance']}")
47
+ print_plain(f"token_balance: {result['token_balance']}")
45
48
 
46
49
  if print_format == PrintFormat.JSON:
47
- mm_print.json(data=result)
50
+ print_json(data=result)
@@ -1,18 +1,21 @@
1
+ """CLI command: print balances for multiple addresses and tokens."""
2
+
1
3
  from dataclasses import dataclass
2
4
  from typing import Annotated
3
5
 
4
- import mm_print
5
6
  from mm_web3 import Web3CliConfig
6
7
  from pydantic import BeforeValidator
7
8
  from rich.live import Live
8
9
  from rich.table import Table
9
10
 
10
11
  from mm_eth import converters, retry
11
- from mm_eth.cli.cli_utils import BaseConfigParams
12
+ from mm_eth.cli.cli_utils import BaseConfigParams, fatal
12
13
  from mm_eth.cli.validators import Validators
13
14
 
14
15
 
15
16
  class Config(Web3CliConfig):
17
+ """Configuration for the balances command."""
18
+
16
19
  addresses: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
17
20
  tokens: Annotated[list[str], BeforeValidator(Validators.eth_addresses(unique=True))]
18
21
  nodes: Annotated[list[str], BeforeValidator(Validators.nodes())]
@@ -21,17 +24,22 @@ class Config(Web3CliConfig):
21
24
 
22
25
  @dataclass
23
26
  class Token:
27
+ """Resolved ERC-20 token metadata."""
28
+
24
29
  address: str
25
30
  decimals: int
26
31
  symbol: str
27
32
 
28
33
 
29
34
  class BalancesCmdParams(BaseConfigParams):
35
+ """Parameters for the balances command."""
36
+
30
37
  wei: bool
31
38
  show_nonce: bool
32
39
 
33
40
 
34
41
  async def run(params: BalancesCmdParams) -> None:
42
+ """Read config, fetch balances for all addresses/tokens, and display a table."""
35
43
  config = Config.read_toml_config_or_exit(params.config_path)
36
44
  if params.print_config:
37
45
  config.print_and_exit()
@@ -101,16 +109,17 @@ async def run(params: BalancesCmdParams) -> None:
101
109
 
102
110
 
103
111
  async def _get_tokens_info(config: Config) -> list[Token]:
112
+ """Fetch decimals and symbol for each configured token address."""
104
113
  result: list[Token] = []
105
114
  for address in config.tokens:
106
115
  decimals_res = await retry.erc20_decimals(5, config.nodes, None, token=address)
107
116
  if decimals_res.is_err():
108
- mm_print.exit_with_error(f"can't get token {address} decimals: {decimals_res.unwrap_err()}")
117
+ fatal(f"can't get token {address} decimals: {decimals_res.unwrap_err()}")
109
118
  decimal = decimals_res.unwrap()
110
119
 
111
120
  symbols_res = await retry.erc20_symbol(5, config.nodes, None, token=address)
112
121
  if symbols_res.is_err():
113
- mm_print.exit_with_error(f"can't get token {address} symbol: {symbols_res.unwrap_err()}")
122
+ fatal(f"can't get token {address} symbol: {symbols_res.unwrap_err()}")
114
123
  symbol = symbols_res.unwrap()
115
124
 
116
125
  result.append(Token(address=address, decimals=decimal, symbol=symbol))
@@ -1,7 +1,9 @@
1
+ """CLI command: deploy a smart contract."""
2
+
1
3
  from typing import cast
2
4
 
3
- import mm_print
4
5
  import tomlkit
6
+ from mm_print import print_json
5
7
  from mm_web3 import Web3CliConfig
6
8
  from pydantic import StrictStr
7
9
 
@@ -10,6 +12,8 @@ from mm_eth.cli.cli_utils import BaseConfigParams
10
12
 
11
13
 
12
14
  class Config(Web3CliConfig):
15
+ """Configuration for the deploy command."""
16
+
13
17
  private_key: StrictStr
14
18
  nonce: int | None = None
15
19
  gas: int
@@ -24,10 +28,13 @@ class Config(Web3CliConfig):
24
28
 
25
29
 
26
30
  class DeployCmdParams(BaseConfigParams):
31
+ """Parameters for the deploy command."""
32
+
27
33
  broadcast: bool = False
28
34
 
29
35
 
30
36
  async def run(cli_params: DeployCmdParams) -> None:
37
+ """Read deploy config, build contract data, and print the deployment payload."""
31
38
  config = Config.read_toml_config_or_exit(cli_params.config_path)
32
39
  if cli_params.print_config:
33
40
  config.print_and_exit({"private_key"})
@@ -44,4 +51,4 @@ async def run(cli_params: DeployCmdParams) -> None:
44
51
  )
45
52
 
46
53
  res = deploy.get_deploy_contract_data(config.contract_bin, constructor_types, constructor_values)
47
- mm_print.json(res)
54
+ print_json(res)