First Impressions of PuyaPy
A couple of people have asked me to share some thoughts on Algorand's new Python-to-TEAL compiler, PuyaPy.
I should preface this by admitting I know sweet f*** all about compilers, and I don't know much about the AVM either.
But perhaps that makes me part of the target audience for this new developer tool.
PuyaPy is still in developer preview, so I expect some of these kinks will be ironed out in the coming months.
Getting Started
I used AlgoKit to generate the initial project templates, which was quick and easy.
I already had a LocalNet instance up and running so everything pretty much worked off the bat.
One thing I found odd when browsing the initial smart contract code was:
Pylance can't find the module 'puyapy'.
And I couldn't see it in the packages either:
If you try to run the contract file:
So that's a bit of a strange first experience.
From digging through the source code, I think the general idea is that the puya
module is the compiler, and what the end user interacts with are the interfaces defined in puyapy-stubs
.
That makes sense, and I'm sure it will be explained in future documentation.
But maybe the IDE support could be improved as well.
Smart Contract Basics
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def hello(self, name: arc4.String) -> arc4.String:
return "Hello, " + name
The smart contract structure is simple and intuitive.
You create a class that inherits from ARC4Contract
.
Methods can either be defined with the @arc4.abimethod()
decorator or the @subroutine()
decorator, both of which are familiar for PyTEAL users.
The former is for methods that you can call from off-chain code, and the latter is for internal methods. There's no support for descriptors yet (@property
) or other types of methods, but maybe they'll be added in the future.
One of the biggest improvements is how easy it is to work with global state.
Modifying the previous example:
class HelloWorld(ARC4Contract):
def __init__(self) -> None:
self.greeting = arc4.String("Hello, ")
@arc4.abimethod()
def hello(self, name: arc4.String) -> arc4.String:
return self.greeting + name
That's pretty darn good! It's very close to a regular Python class experience.
Iteration
Another key improvement is iteration and variables.
In PyTEAL this was a mess:
totalFees = ScratchVar(TealType.uint64)
i = ScratchVar(TealType.uint64)
Seq([
totalFees.store(Int(0)),
For(i.store(Int(0)), i.load() < Global.group_size(), i.store(i.load() + Int(1))).Do(
totalFees.store(totalFees.load() + Gtxn[i.load()].fee())
)
])
Let's say we want to sum a list of numbers (functional programmers, cover your eyes...):
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def sum(self, array: Bytes) -> UInt64:
total = UInt64(0)
for n in array:
total += op.btoi(n)
return total
>>> print(app_client.sum(array=[1, 2, 3, 4, 5]).return_value)
15
That's significantly better.
What if we only want to sum every other number?
In Python we could do:
>>> array = [1, 2, 3, 4, 5]
>>> total = 0
>>> for i, n in enumerate(array):
... if i % 2 == 0:
... total += n
>>> print(f"{total=}")
total=9
And in PuyaPy it's pretty similar:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def sum(self, array: Bytes) -> UInt64:
total = UInt64(0)
for i, n in uenumerate(array):
if i % 2 == 0:
total += op.btoi(n)
return total
>>> print(app_client.sum(array=[1, 2, 3, 4, 5]).return_value)
9
But you may have noticed we're iterating over a byte array and converting each byte to an integer. Why can't we just use lists?
That brings me to my next topic.
Data Structures
It's hard to be productive without some sort of array structure, and some kind of key-value structure. In Python, we usually work with list
and dict
.
It would be really nice not to have to concern myself with Bytes
for a simple task like the previous example.
PuyaPy does have the ARC-4 StaticArray
and DynamicArray
types, but they're a little bit janky to work with.
Let's modify the previous example to use an arc4.StaticArray
of length 5.
Would it be unreasonable to think we could pass a list of UInt64
like this?:
@arc4.abimethod()
def sum(self, array: arc4.StaticArray[UInt64, Literal[5]]) -> UInt64:
Puya doesn't like it:
ValueError: Invalid type for arc4.StaticArray: puyapy.UInt64
So let's use arc4.UInt64
:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def sum(self, array: arc4.StaticArray[arc4.UInt64, Literal[5]]) -> arc4.UInt64:
total = UInt64(0)
for i, n in uenumerate(array):
if i % 2 == 0:
total += n.decode()
return arc4.UInt64(total)
That works but it's quite verbose.
Working with arrays seems a lot simpler in TEALScript.
And look at this type declaration:
const x: uint64[] = [1,2,3];
Wouldn't this be nice in PuyaPy?:
x: list[UInt64] = [1, 2, 3]
Type Inference & Declarations
One of the annoying things about PyTEAL was constantly having to write Int(0)
instead of just 0
.
PuyaPy hasn't completely done away with that.
You can't, for example, have an integer be inferred as a UInt64
by default.
This will not compile:
class HelloWorld(ARC4Contract):
def __init__(self) -> None:
self.n = 1
You have to write:
class HelloWorld(ARC4Contract):
def __init__(self) -> None:
self.n = UInt64(1)
Which is pretty similar to PyTEAL, syntactically.
TEALScript takes a different approach:
If a type declaration is always needed in PuyaPy, this would make more sense (in my head, at least) than the class constructor syntax:
class HelloWorld(ARC4Contract):
def __init__(self) -> None:
self.n: UInt64 = 1
It would be similar to how Pydantic handles custom types (the example below uses annotated types):
from typing import Annotated
from annotated_types import Ge, Lt
from pydantic import BaseModel
UInt64 = Annotated[int, Ge(0), Lt(2**64)]
class Contract(BaseModel):
n: UInt64 = 1
In my mind, what I should be doing is telling the compiler what type n
is.
UInt64(n)
feels more like constructing an instance of UInt64
, which isn't really what's happening. There's no way for me to actually construct such an object. It's merely an interface.
Polymorphism
Let's add a new method:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def add(self, a: UInt64, b: UInt64) -> UInt64:
return a + b
Easy peasy. The syntax here is nice, and is practically indistinguishable from normal Python.
I would have expected to be able to use the arc4.UInt64
type in a similar way:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def add(self, a: arc4.UInt64, b: arc4.UInt64) -> arc4.UInt64:
return a + b
With only the encoding and decoding being different.
But the code above throws these errors:
Which is rather odd for what you would assume is an integer type.
Much like the array example from earlier, the solution is to decode the values into the UInt64
type first, and cast the result as an arc4.UInt64
again:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def add(self, a: arc4.UInt64, b: arc4.UInt64) -> arc4.UInt64:
return arc4.UInt64(a.decode() + b.decode())
Perhaps that can be abstracted away.
Built-Ins
To really make this a seamless transition for Python developers, there needs to be better support for Python's built-in functions.
In Python, if you wanted to check the length of a list, you would use the built-in len()
function:
>>> array = [1, 2, 3, 4, 5]
>>> length = len(array)
>>> print(f"{length=}")
length=5
So, naturally, you might assume this would be possible:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def length(self, array: arc4.DynamicArray[arc4.UInt64]) -> UInt64:
return len(array)
But here we get two more errors:
Instead, we can use:
class HelloWorld(ARC4Contract):
@arc4.abimethod()
def length(self, array: arc4.DynamicArray[arc4.UInt64]) -> UInt64:
return array.length
Which returns:
>>> print(app_client.length(array=[1, 2, 3, 4, 5]).return_value)
5
In Python, if I define my own array classes, I can support len()
quite easily:
>>> class DynamicArray:
... def __init__(self, array: list):
... self.array = array
... def __len__(self):
... return len(self.array)
>>> array = DynamicArray([1, 2, 3, 4, 5])
>>> print(f"{len(array)=}")
len(array)=5
>>> class StaticArray:
... def __init__(self, length: int, array: list):
... self.length = length
... self.array = array
... def __len__(self):
... return self.length
>>> array = StaticArray(5, [1, 2, 3, 4, 5])
>>> print(f"{len(array)=}")
len(array)=5
So why is this not supported in PuyaPy's array types?
There are many other useful built-in functions I would like to see supported, and I'm hopeful they will be.
Daniel from MakerX mentioned that the team will be adding support for the built-in functions min()
and max()
soon.
Extensibility
One final note.
It was quite easy to extend PyTEAL with metaprogramming (pytealext is a great example).
Even though PyTEAL was verbose, you could quite easily create normal Python classes and functions that wrapped or generated PyTEAL expressions.
I don't know if there's any way to do similar things with PuyaPy, but it would be great if some of the logic could be defined in normal functions that are easy to test (rather than subroutines).
Just imagine how much carnage I could cause if I could use the functools
library.
Don't take this dream away from me.
Summary
PuyaPy is a big step forward and a lot closer to a normal Python developer experience.
I've focused more on the negatives in this article, because I want to provide constructive feedback.
But there's a lot to like about it, and I've had fun building with it so far.
I don't know enough about compilers and the various trade-offs to have any sort of informed opinion on the design decisions, so take it all with a pinch of salt.
Subscribe to my newsletter
Read articles from Alexander Codes directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Alexander Codes
Alexander Codes
Data Engineer โจ๏ธ | Pythonista ๐ | Blogger ๐ You can support my work at https://app.nf.domains/name/alexandercodes.algo