In order to simplify onboarding of new developers into the Tezos smartcontract ecosystem, the projects aim to implement a Python backend for Michelson. The goal is thus to allow writing Tezos smart contracts in Python such that the contract behaves isomorphically between the CPython interpreter and the Tezos virtual machine.
Since a contract in PyMich is Python, this means that the classic Python tooling can be used with your favorite editor for linting (Pylint, Flake8, …), type checking (MyPy, PyRighgt, …), powerful language servers (PyRight, Python LSP, …) and code formatters (black, yapf, …).
This project is currently unstable and considered a proof of concept.
Let us demonstrate how to write a simple token contract in PyMich and show that it behaves identically between the CPython interpreter and the PyTezos Michelson virtual machine:
# contract.py
from dataclasses import dataclass
from typing import Dict
from stubs import *
def require(condition: bool, message: str) -> int:
if not condition:
raise Exception(message)
return 0
@dataclass
class Contract:
balances: Dict[address, int]
total_supply: int
admin: address
def mint(self, to: address, amount: int):
require(SENDER == self.admin, "Only admin can mint")
self.total_supply = self.total_supply + amount
if to in self.balances:
self.balances[to] = self.balances[to] + amount
else:
self.balances[to] = amount
def transfer(self, to: address, amount: int):
require(amount > 0, "You need to transfer a positive amount of tokens")
require(self.balances[SENDER] >= amount, "Insufficient sender balance")
self.balances[SENDER] = self.balances[SENDER] - amount
if to in self.balances:
self.balances[to] = self.balances[to] + amount
else:
self.balances[to] = amount
# contract_python_test.py
import unittest
from pytezos.michelson.micheline import MichelsonRuntimeError
import stubs
admin = "Mrs. Foo"
stubs.SENDER = admin
from contract import Contract
class TestContract(unittest.TestCase):
def test_mint(self):
from contract import Contract
contract = Contract(admin=admin, balances={}, total_supply=0)
amount = 10
contract.mint(admin, amount)
assert contract.balances[admin] == amount
contract = Contract(admin="yolo", balances={}, total_supply=0)
try:
contract.mint(admin, amount)
assert 0
except Exception as e:
assert e.args[0] == 'Only admin can mint'
def test_transfer(self):
amount_1 = 10
contract = Contract(admin=admin, balances={admin: amount_1}, total_supply=amount_1)
investor = "Mr. Bar"
amount_2 = 4
contract.transfer(investor, amount_2)
assert contract.balances[admin] == amount_1 - amount_2
assert contract.balances[investor] == amount_2
try:
contract.transfer(admin, -10)
assert 0
except Exception as e:
assert e.args[0] == 'You need to transfer a positive amount of tokens'
try:
contract.transfer(admin, 100)
assert 0
except Exception as e:
assert e.args[0] == 'Insufficient sender balance'
Finally, we can write a similar test using the PyTezos Michelson VM:
# contract_michelson_test.py
import unittest
from compiler import Compiler
from compiler import VM
from pytezos.michelson.micheline import MichelsonRuntimeError
with open("contract.py") as f:
source = f.read()
class TestContract(unittest.TestCase):
def test_mint(self):
micheline = Compiler(source).compile_contract()
vm = VM()
vm.load_contract(micheline)
init_storage = vm.contract.storage.dummy()
init_storage['admin'] = vm.context.sender
new_storage = vm.contract.mint({"to": vm.context.sender, "amount": 10}).interpret(storage=init_storage, sender=vm.context.sender).storage
self.assertEqual(new_storage['balances'], {vm.context.sender: 10})
try:
vm.contract.mint({"to": vm.context.sender, "amount": 10}).interpret(storage=init_storage).storage
assert 0
except MichelsonRuntimeError as e:
self.assertEqual(e.format_stdout(), "FAILWITH: 'Only admin can mint'")
def test_transfer(self):
micheline = Compiler(source).compile_contract()
vm = VM()
vm.load_contract(micheline)
init_storage = vm.contract.storage.dummy()
init_storage['admin'] = vm.context.sender
init_storage['balances'] = {vm.context.sender: 10}
investor = "KT1EwUrkbmGxjiRvmEAa8HLGhjJeRocqVTFi"
new_storage = vm.contract.transfer({"to": investor, "amount": 4}).interpret(storage=init_storage, sender=vm.context.sender).storage
self.assertEqual(new_storage['balances'], {vm.context.sender: 6, investor: 4})
try:
vm.contract.transfer({"to": investor, "amount": -10}).interpret(storage=new_storage).storage
assert 0
except MichelsonRuntimeError as e:
self.assertEqual(e.format_stdout(), "FAILWITH: 'You need to transfer a positive amount of tokens'")
try:
vm.contract.transfer({"to": investor, "amount": 10}).interpret(storage=new_storage, sender=vm.context.sender).storage
assert 0
except MichelsonRuntimeError as e:
self.assertEqual(e.format_stdout(), "FAILWITH: 'Insufficient sender balance'")
As we can see, we’ve written the same tests for both the Python interpreter and the PyTezos VM. As expected, the contract behaves the same way.
Bellow are examples of autocomplete, linting and typechecking with Pyright in Emacs. Since I already had it setup to work with Python, it already works with PyMich !
Autocomplete:
Linting:
Typechecking:
- [x] multi argument functions
- [x] dictionnaries
- [x] functions
- [ ] lists
- [ ] tuples
- [ ] closures
- [x] nested records
- [ ] tuples
We’d like to implement classes by rewritting them to classless Python first and compiling the new AST rather than compiling classes to Michelson directly. The idea is to rewritte the following:
class User:
def __init__(a: int, b: str):
self.a, self.b = a, b
def method1(self, arg1: int, arg2: int) -> string:
self.a = arg1 + arg2
return "success"
def method2(self, arg1: str, arg2: str) -> None:
self.b = arg1 + arg2
user = User(1, "yo")
user.a = 10
user.method1(1, 2)
user.method2("yo", "lo")
As:
@dataclass
class __User_self:
a: int
b: str
def __User___init__(a: int, b:str):
return __User_self(a, b)
def __User_method1(self: __User_self, arg1: int, arg2: int) -> Tuple[__User_self, str]:
self.a = arg1 + arg2
return self, "success"
def __User_method2(self: __User_self, arg1: int, arg2: int) -> __User_self:
self.b = arg1 + arg2
return self
user = __User___init__(1, "yo")
user.a = 10
user = _User_method1(user, 1, 2)[0]
user = _User_method2(user, "yo", "lo")
Similarly, closures can be compiled without touching the Michelson generator by simply rewritting the Python to « closureless » code. We want to transform:
a = "foo"
b = 1
c = 2
def f(d: int) -> int
return len(a) + b + d
d = f(2) + c
Into:
a = "foo"
b = 1
def (a: str, b: int, d: int) -> int
return len(a) + b + d
d = f(a, b, 2) + c
This will ensure that the variables used from the closure are always at the same position on the stack relative to the function body.