Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pythonic class generator from contract's ABI #3016

Open
F4ever opened this issue Jul 4, 2023 · 4 comments
Open

Pythonic class generator from contract's ABI #3016

F4ever opened this issue Jul 4, 2023 · 4 comments

Comments

@F4ever
Copy link

F4ever commented Jul 4, 2023

General
Currently, in web3py contract functions are dynamically generated in runtime. However code completion, type inference, and other intelligent features provided by IDEs doesn't work seamlessly with dynamically generated code.

It's easier to interact with smart contracts when interface and documentation is in human-readable state - just in simple pythonic classes generated via cli.

Benefits

  • Type Safety: Statically generated contract classes bring type safety to the interaction with smart contracts. By generating contract classes, developers can leverage the power of static type checking provided by Python tools like mypy.
  • Code Readability and Maintainability: Statically generated contract classes provide a clean and concise interface for interacting with smart contracts. Developers can easily understand the available contract functions, their parameters, and return types, leading to more expressive and self-documented code.
  • Mocking: Statically generated contract classes allow for effective mocking of contract interactions during testing. It's just easier to use pytest mocks instead of mocking the provider.
  • Forward Compatibility: If a contract is upgraded or modified, developers can regenerate the contract classes and see all compatibility issues.

Pseudocode generation example

MyContract.json

  {
    "inputs": [
      {
        "internalType": "bytes32",
        "name": "role",
        "type": "bytes32"
      } 
    ],
    "name": "contractFunctionName",
    "outputs": [
      {
        "components": [
          {
            "internalType": "uint256",
            "name": "firstInt",
            "type": "uint256"
          },
          {
            "internalType": "bytes32",
            "name": "secondByte",
            "type": "bytes32"
          }
        ],
        "internalType": "struct MyContract.SimpleStruct",
        "name": "inputStructName",
        "type": "tuple"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
python3 web3 generate_class_from_abi ./MyContract.json

Generation example my_contract.py

# Could be dataclass or pydentic class
@dataclass
class SimpleStruct:
    first_int: int
    second_bytes: bytes

    
class MyContract(Contract):
    def __init__(w3, address):
        # Load abi from source json
        abi = self._load_abi()

        # Some contract initialization and generation from ContractFactoryClass
        ....
    
    def contract_function_name(self, role: bytes, block_identifier) -> SimpleStruct:
        # Here some validation that role is 32 bytes only
        # ...
        result = self.functions.contractFunctionName(role).call(block_identifier=block_identifier)
        
        return SimpleStruct(result)

Possible features

  • From camelCase to snake_case
  • Input validations
  • Custom exception generation that contract could throw

Challenges

  • No CLI in web3py.
  • ABI updating. Just overwrite whole file?
@F4ever
Copy link
Author

F4ever commented Jul 4, 2023

I saw something similar in js.
Any suggestions? Will it be helpful?
I'm ready to do MVP.

@fselmo
Copy link
Collaborator

fselmo commented Jul 5, 2023

Interesting thought @F4ever. This isn't something that we currently plan on building out but I'm happy to leave this open and continue the conversation if it generates interest and would prove useful to users.

@F4ever F4ever changed the title Contract class generator from ABI Pythonic class generator from contract's ABI Jul 6, 2023
@sentilesdal
Copy link

I just started working on this actually, to make our own dev ex better. I'd be happy to share what I come up with and possible collaboration with web3.py to get it into the repo or at least link as a tool.

@abdelaziz-mahdy
Copy link

i was searching for the same thing and couldnt findit

so this is my result code

import json
import warnings
import keyword
# Define type mappings from Solidity to Python
type_mapping = {
    'address': 'str',  # Solidity address to Python string
    'uint': 'int',     # Solidity unsigned integer to Python int
    'uint256': 'int',  # Solidity unsigned integer (256-bit) to Python int
    'int': 'int',      # Solidity integer to Python int
    'bool': 'bool',    # Solidity boolean to Python bool
    # Add more mappings as needed
}

def map_type(solidity_type):
    """
    Map a Solidity type to a Python type. If mapping is not found, return None.
    """
    python_type = type_mapping.get(solidity_type)
    if python_type is None:
        warnings.warn(f"No Python type mapping found for Solidity type '{solidity_type}'. Type annotation will be omitted.")
    return python_type
def adjust_param_name(name):
    """
    Adjust the parameter name if it conflicts with Python keywords.
    """
    return "_"+name if keyword.iskeyword(name) else name 


class ContractGenerator:
    def __init__(self, file_path):
        with open(file_path) as file:
            contract_data = json.load(file)
            if 'abi' not in contract_data:
                raise ValueError('ABI not found in contract data')
            if 'contractName' not in contract_data:
                raise ValueError('Contract name not found in contract data')
            self.abi = contract_data['abi']
            self.contract_name = contract_data.get('contractName', 'UnknownContract')
        self.views = []
        self.calls = []
        self.events = []
        self._categorize_abi()
        
    def _categorize_abi(self):
        for function in self.abi:
            if function['type'] == 'function':
                if function['stateMutability'] == 'view':
                    self.views.append(function)
                elif function['stateMutability'] in ['nonpayable', 'payable']:
                    self.calls.append(function)
            elif function['type'] == 'event':
                self.events.append(function)

    def _generate_function_doc(self, function):
        """
        Generate a documentation string for a function based on its inputs.
        """
        if not function['inputs']:
            return ""
        doc = "Parameters:\n"
        for inp in function['inputs']:
            python_type = map_type(inp['type'])
            param_name = adjust_param_name(inp['name'])
            doc += f"    {param_name} ({python_type if python_type else 'No type mapping'}): {inp['type']} type in Solidity\n"
        return doc
                
    def _generate_event_filter_method(self, event):
        params = []
        doc_params = []
        for inp in event['inputs']:
            if inp['indexed']:  # Consider only indexed inputs for filters
                python_type = map_type(inp['type'])
                param_name = inp['name']
                params.append(f"{param_name}_filter: {python_type} = None")
                doc_params.append(f"        {param_name}_filter ({python_type if python_type else 'No type mapping'}): Filter for '{inp['name']}' ({inp['type']})")
        params_signature = ", ".join(params)
        
        method = f"    def get_{event['name']}_events(self, fromBlock='latest', {params_signature}):\n"
        method += "        \"\"\"Fetch events of type {event['name']}.\n"
        method += "        Parameters:\n"
        method += f"        fromBlock (str): The starting block from which to fetch events. Defaults to 'latest'.\n"
        method += "\n".join(doc_params) + "\n"
        method += "        \"\"\"\n"
        method += "        filter_params = {}\n"
        for inp in event['inputs']:
            if inp['indexed']:
                param_name = inp['name']
                method += f"        if {param_name}_filter is not None:\n"
                method += f"            filter_params['{param_name}'] = {param_name}_filter\n"
        method += "        return self.contract.events." + event['name'] + f".create_filter(fromBlock=fromBlock, argument_filters=filter_params)\n\n"
        return method

    def generate_class(self):
        class_code = f"class {self.contract_name}:\n"
        class_code += "    def __init__(self, w3, address):\n"
        class_code += "        \"\"\"Initialize the contract with a Web3 instance and address.\"\"\"\n"
        class_code += "        self.contract = w3.eth.contract(address=address, abi=self.abi)\n\n"

        # Generate view methods
        for view in self.views:
            method_name = view['name']
            params = ", ".join([f"{adjust_param_name(inp['name'])}: {map_type(inp['type'])}" for inp in view['inputs']])
            method_params = ", ".join([adjust_param_name(inp['name']) for inp in view['inputs']])
            class_code += f"    def {method_name}(self{', ' if params else ''}{params}):\n"
            docstring = self._generate_function_doc(view).strip()
            if docstring:
                class_code += f"        \"\"\"{docstring}\"\"\"\n"
            class_code += f"        return self.contract.functions.{method_name}({method_params}).call()\n\n"
        
        # Generate call methods
        for call in self.calls:
            method_name = call['name']
            params = ", ".join([f"{adjust_param_name(inp['name'])}: {map_type(inp['type'])}" for inp in call['inputs']])
            method_params = ", ".join([adjust_param_name(inp['name']) for inp in call['inputs']])
            class_code += f"    def {method_name}(self{', ' if params else ''}{params}):\n"
            docstring = self._generate_function_doc(call).strip()
            if docstring:
                class_code += f"        \"\"\"{docstring}\"\"\"\n"
            class_code += f"        return self.contract.functions.{method_name}({method_params})\n\n"
        
        # Generate event methods with filters
        for event in self.events:
            class_code += self._generate_event_filter_method(event)

        return class_code


# Example usage
file_path ='test.json'
generator = ContractGenerator(file_path)
class_code = generator.generate_class()

print(class_code)

for input like this

{
"contractName": "ERC20",
"abi": [

    {
        "constant": true,
        "inputs": [],
        "name": "name",
        "outputs": [
            {
                "name": "",
                "type": "string"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "constant": false,
        "inputs": [
            {
                "name": "_spender",
                "type": "address"
            },
            {
                "name": "_value",
                "type": "uint256"
            }
        ],
        "name": "approve",
        "outputs": [
            {
                "name": "",
                "type": "bool"
            }
        ],
        "payable": false,
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "constant": true,
        "inputs": [],
        "name": "totalSupply",
        "outputs": [
            {
                "name": "",
                "type": "uint256"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "constant": false,
        "inputs": [
            {
                "name": "_from",
                "type": "address"
            },
            {
                "name": "_to",
                "type": "address"
            },
            {
                "name": "_value",
                "type": "uint256"
            }
        ],
        "name": "transferFrom",
        "outputs": [
            {
                "name": "",
                "type": "bool"
            }
        ],
        "payable": false,
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "constant": true,
        "inputs": [],
        "name": "decimals",
        "outputs": [
            {
                "name": "",
                "type": "uint8"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "constant": true,
        "inputs": [
            {
                "name": "_owner",
                "type": "address"
            }
        ],
        "name": "balanceOf",
        "outputs": [
            {
                "name": "balance",
                "type": "uint256"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "constant": true,
        "inputs": [],
        "name": "symbol",
        "outputs": [
            {
                "name": "",
                "type": "string"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "constant": false,
        "inputs": [
            {
                "name": "_to",
                "type": "address"
            },
            {
                "name": "_value",
                "type": "uint256"
            }
        ],
        "name": "transfer",
        "outputs": [
            {
                "name": "",
                "type": "bool"
            }
        ],
        "payable": false,
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "constant": true,
        "inputs": [
            {
                "name": "_owner",
                "type": "address"
            },
            {
                "name": "_spender",
                "type": "address"
            }
        ],
        "name": "allowance",
        "outputs": [
            {
                "name": "",
                "type": "uint256"
            }
        ],
        "payable": false,
        "stateMutability": "view",
        "type": "function"
    },
    {
        "payable": true,
        "stateMutability": "payable",
        "type": "fallback"
    },
    {
        "anonymous": false,
        "inputs": [
            {
                "indexed": true,
                "name": "owner",
                "type": "address"
            },
            {
                "indexed": true,
                "name": "spender",
                "type": "address"
            },
            {
                "indexed": false,
                "name": "value",
                "type": "uint256"
            }
        ],
        "name": "Approval",
        "type": "event"
    },
    {
        "anonymous": false,
        "inputs": [
            {
                "indexed": true,
                "name": "from",
                "type": "address"
            },
            {
                "indexed": true,
                "name": "to",
                "type": "address"
            },
            {
                "indexed": false,
                "name": "value",
                "type": "uint256"
            }
        ],
        "name": "Transfer",
        "type": "event"
    }
]}

the output class will be

class ERC20:
    def __init__(self, w3, address):
        """Initialize the contract with a Web3 instance and address."""
        self.contract = w3.eth.contract(address=address, abi=self.abi)

    def name(self):
        return self.contract.functions.name().call()

    def totalSupply(self):
        return self.contract.functions.totalSupply().call()

    def decimals(self):
        return self.contract.functions.decimals().call()

    def balanceOf(self, _owner: str):
        """Parameters:
    _owner (str): address type in Solidity"""
        return self.contract.functions.balanceOf(_owner).call()

    def symbol(self):
        return self.contract.functions.symbol().call()

    def allowance(self, _owner: str, _spender: str):
        """Parameters:
    _owner (str): address type in Solidity
    _spender (str): address type in Solidity"""
        return self.contract.functions.allowance(_owner, _spender).call()

    def approve(self, _spender: str, _value: int):
        """Parameters:
    _spender (str): address type in Solidity
    _value (int): uint256 type in Solidity"""
        return self.contract.functions.approve(_spender, _value)

    def transferFrom(self, _from: str, _to: str, _value: int):
        """Parameters:
    _from (str): address type in Solidity
    _to (str): address type in Solidity
    _value (int): uint256 type in Solidity"""
        return self.contract.functions.transferFrom(_from, _to, _value)

    def transfer(self, _to: str, _value: int):
        """Parameters:
    _to (str): address type in Solidity
    _value (int): uint256 type in Solidity"""
        return self.contract.functions.transfer(_to, _value)

    def get_Approval_events(self, fromBlock='latest', owner_filter: str = None, spender_filter: str = None):
        """Fetch events of type {event['name']}.
        Parameters:
        fromBlock (str): The starting block from which to fetch events. Defaults to 'latest'.
        owner_filter (str): Filter for 'owner' (address)
        spender_filter (str): Filter for 'spender' (address)
        """
        filter_params = {}
        if owner_filter is not None:
            filter_params['owner'] = owner_filter
        if spender_filter is not None:
            filter_params['spender'] = spender_filter
        return self.contract.events.Approval.create_filter(fromBlock=fromBlock, argument_filters=filter_params)

    def get_Transfer_events(self, fromBlock='latest', from_filter: str = None, to_filter: str = None):
        """Fetch events of type {event['name']}.
        Parameters:
        fromBlock (str): The starting block from which to fetch events. Defaults to 'latest'.
        from_filter (str): Filter for 'from' (address)
        to_filter (str): Filter for 'to' (address)
        """
        filter_params = {}
        if from_filter is not None:
            filter_params['from'] = from_filter
        if to_filter is not None:
            filter_params['to'] = to_filter
        return self.contract.events.Transfer.create_filter(fromBlock=fromBlock, argument_filters=filter_params)


sadly i cant integrate it in web3py so i am providing it here incase it helps other people, and would love if it helps adding it to web3py, if there is a way i can provide it as a pr without the cli part i would love to do so

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants