Five Iterations in Algorand Python

Algorand Python v1 was released today! 🥳

There are quite a few changes and improvements from the developer preview, so things might look a little different to my previous posts.

If you are new to Python on Algorand, I would recommend watching Alessandro's introduction video and reading through the AlgoKit documentation.

Let's explore how we can use iteration in smart contracts to solve some common problems.

Contract Outline

We'll start by importing from algopy and declaring a TypeAlias for a dynamic array of unsigned integers:

from typing import TypeAlias

from algopy import ARC4Contract, String, UInt64, arc4, uenumerate, urange

UInt64Array: TypeAlias = arc4.DynamicArray[arc4.UInt64]


class Iteration(ARC4Contract):
    """A contract demonstrating iteration methods in algopy."""
    ...

Using a type alias here will make our method signatures easier to read.

Sum an Array

To sum an array of numbers, we define an ARC-4 ABI method that accepts a UInt64Array:

@arc4.abimethod
def sum(self, array: UInt64Array) -> UInt64:
    """Sums an array of numbers.

    Args:
        array (UInt64Array): The array to sum.

    Returns:
        UInt64: The sum of the array.
    """
    total = UInt64(0)
    for n in array:
        total += n.native
    return total

Looping over the array has exactly the same syntax as iterating over a Python list.

The only difference is how we work with numbers.

Since the array contains arc4.UInt64 encoded numbers, we need to convert each number to the native AVM uint64 type before adding it to the total.

Where supported, the native equivalent of an ARC4 type can be obtained via the .native property. It is possible to use native types in an ABI method and the router will automatically encode and decode these types to their ARC4 equivalent.

Source: https://algorandfoundation.github.io/puya/lg-arc4.html

Find the First Even Number

To find the first even number in the array:

@arc4.abimethod
def first_even(self, array: UInt64Array) -> UInt64:
    """Returns the first even number in the array.

    Defaults to zero.

    Args:
        array (UInt64Array): The array to search.

    Returns:
        UInt64: The first even number.
    """
    for n in array:
        if n.native % 2 == 0:
            return n.native
    return UInt64(0)

We loop over the array exactly the same as before, and return the first even native value that we find.

If no even number is found, we return zero.

In algopy, UInt64() is the same as UInt64(0), but I think the latter is a bit clearer here.

Find the Index of the Last Even Number

Let's make it a bit more difficult.

To find the index of the last even number in the array:

@arc4.abimethod
def last_even_index(self, array: UInt64Array) -> UInt64:
    """Returns the index of the last even number in the array.

    Defaults to zero.

    Args:
        array (UInt64Array): The array to search.

    Returns:
        UInt64: The index of the last even number.
    """
    for i, n in reversed(uenumerate(array)):
        if n.native % 2 == 0:
            return i
    return UInt64(0)

The enumerate function in Python is extremely handy.

It lets you loop over some iterable, and use both the index and the value found at that index.

Fortunately, algopy has an equivalent:

uenumerate is similar to Python’s built-in enumerate function, but for UInt64 numbers; it allows you to loop over a sequence and have an automatic counter.

Source: https://algorandfoundation.github.io/puya/lg-control.html

And to iterate backwards, we can use the native Python reversed function.

Pretty cool!

Repeat an Action

range is another extremely useful Python function, and again algopy provides an equivalent for working with UInt64 numbers: urange.

We can use it to repeat an action n times (or do much more complicated things!).

Let's use it to concatenate a string with itself:

@arc4.abimethod
def repeat(self, string: arc4.String, times: UInt64) -> String:
    """Repeats a string a number of times.

    Args:
        string (arc4.String): The string to repeat.
        times (UInt64): The number of times to repeat the string.

    Returns:
        String: The repeated string.
    """
    result = String()
    for _i in urange(times):
        result += string.native
    return result

Note that _ is the Python convention for throwaway variables, but it's not supported in algopy at the moment.

I'm using _i to denote that the number is not actually used.

Recursion

Finally, let's look at using recursion to calculate Fibonacci numbers.

It should be noted that recursion is usually inefficient, so it's probably not the best thing to do in a smart contract unless the compiler supports tail call elimination or other optimisation techniques.

But sometimes it is the nicest way to express an idea:

@arc4.abimethod
def fibonacci(self, n: UInt64) -> UInt64:
    """Returns the nth Fibonacci number.

    Args:
        n (UInt64): The index of the Fibonacci number to return.

    Returns:
        UInt64: The nth Fibonacci number.
    """
    return n if n <= 1 else self.fibonacci(n - 1) + self.fibonacci(n - 2)

Calling this method with any number > 8 will run out of opcodes.

We could increase the opcode budget, but this will do for now.

The Complete Contract Code

The code below is also available on GitHub:

from typing import TypeAlias

from algopy import ARC4Contract, String, UInt64, arc4, uenumerate, urange

UInt64Array: TypeAlias = arc4.DynamicArray[arc4.UInt64]


class Iteration(ARC4Contract):
    """A contract demonstrating iteration methods in algopy."""

    @arc4.abimethod
    def sum(self, array: UInt64Array) -> UInt64:
        """Sums an array of numbers.

        Args:
            array (UInt64Array): The array to sum.

        Returns:
            UInt64: The sum of the array.
        """
        total = UInt64(0)
        for n in array:
            total += n.native
        return total

    @arc4.abimethod
    def first_even(self, array: UInt64Array) -> UInt64:
        """Returns the first even number in the array.

        Defaults to zero.

        Args:
            array (UInt64Array): The array to search.

        Returns:
            UInt64: The first even number.
        """
        for n in array:
            if n.native % 2 == 0:
                return n.native
        return UInt64(0)

    @arc4.abimethod
    def last_even_index(self, array: UInt64Array) -> UInt64:
        """Returns the index of the last even number in the array.

        Defaults to zero.

        Args:
            array (UInt64Array): The array to search.

        Returns:
            UInt64: The index of the last even number.
        """
        for i, n in reversed(uenumerate(array)):
            if n.native % 2 == 0:
                return i
        return UInt64(0)

    @arc4.abimethod
    def repeat(self, string: arc4.String, times: UInt64) -> String:
        """Repeats a string a number of times.

        Args:
            string (arc4.String): The string to repeat.
            times (UInt64): The number of times to repeat the string.

        Returns:
            String: The repeated string.
        """
        result = String()
        for _i in urange(times):
            result += string.native
        return result

    @arc4.abimethod
    def fibonacci(self, n: UInt64) -> UInt64:
        """Returns the nth Fibonacci number.

        Args:
            n (UInt64): The index of the Fibonacci number to return.

        Returns:
            UInt64: The nth Fibonacci number.
        """
        return n if n <= 1 else self.fibonacci(n - 1) + self.fibonacci(n - 2)

Writing Tests

Tests can be written in pytest.

We can use all the nice features of the library, including fixtures and test parameterisation.

You will need to have a LocalNet instance running in order to execute these.

The code below is also available on GitHub:

import algokit_utils
import pytest
from algokit_utils import get_localnet_default_account
from algokit_utils.config import config
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient

from smart_contracts.artifacts.iteration.client import IterationClient


@pytest.fixture(scope="session")
def iteration_client(algod_client: AlgodClient, indexer_client: IndexerClient) -> IterationClient:
    config.configure(
        debug=True,
        # trace_all=True,
    )

    client = IterationClient(
        algod_client,
        creator=get_localnet_default_account(algod_client),
        indexer_client=indexer_client,
    )

    client.deploy(
        on_schema_break=algokit_utils.OnSchemaBreak.AppendApp,
        on_update=algokit_utils.OnUpdate.AppendApp,
    )
    return client


@pytest.mark.parametrize(
    "array, total",
    [
        ([], 0),
        ([1], 1),
        ([1, 2, 3, 4, 5], 15),
    ],
)
def test_sum_array(iteration_client: IterationClient, array: list[int], total: int) -> None:
    """Tests the sum_array() method."""
    assert iteration_client.sum(array=array).return_value == total


@pytest.mark.parametrize(
    "array, n",
    [
        ([], 0),
        ([1], 0),
        ([1, 2, 3, 4, 5], 2),
    ],
)
def test_first_even(iteration_client: IterationClient, array: list[int], n: int) -> None:
    """Tests the first_even() method."""
    assert iteration_client.first_even(array=array).return_value == n


@pytest.mark.parametrize(
    "array, index",
    [
        ([], 0),
        ([1], 0),
        ([1, 2, 3, 4, 5], 3),
    ],
)
def test_last_even_index(iteration_client: IterationClient, array: list[int], index: int) -> None:
    """Tests the last_even_index() method."""
    assert iteration_client.last_even_index(array=array).return_value == index


@pytest.mark.parametrize(
    "string, times, result",
    [
        ("", 0, ""),
        ("a", 0, ""),
        ("a", 1, "a"),
        ("a", 3, "aaa"),
    ],
)
def test_repeat(iteration_client: IterationClient, string: str, times: int, result: str) -> None:
    """Tests the repeat() method."""
    assert iteration_client.repeat(string=string, times=times).return_value == result


@pytest.mark.parametrize(
    "n, expected",
    [
        (0, 0),
        (1, 1),
        (2, 1),
        (3, 2),
        (4, 3),
        (5, 5),
        (6, 8),
        (7, 13),
    ],
)
def test_fibonacci(iteration_client: IterationClient, n: int, expected: int) -> None:
    """Tests the fibonacci() method."""
    assert iteration_client.fibonacci(n=n).return_value == expected