Skip to content

fix: fixes type checking of tuples with primitive types #39

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

Merged
merged 5 commits into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,41 @@ jobs:
- name: Check wheels can be built
run: hatch build

- name: Run tests (codebase)
run: hatch run tests

- name: Run tests (examples)
run: hatch run examples:tests

- name: Check doctests
run: hatch run docs:test

test-python-matrix:
runs-on: "ubuntu-latest"
strategy:
matrix:
python-version: ["3.12", "3.13"]
steps:
- name: Checkout source code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: "pip"

- name: Install hatch
run: pip install hatch

- name: Start LocalNet
run: pipx install algokit && algokit localnet start

- name: Run tests with Python ${{ matrix.python-version }}
run: hatch run test.py${{ matrix.python-version }}:ci

- name: Run examples tests with Python ${{ matrix.python-version }}
run: hatch run examples.py${{ matrix.python-version }}:tests

- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
if: ${{ matrix.python-version == '3.13' }}
with:
name: coverage-reports
path: |
./coverage.xml
retention-days: 14
28 changes: 24 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ classifiers = [
"Topic :: Software Development :: Testing",
"Programming Language :: Python",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
]
dependencies = [
# ==========================================================================
Expand All @@ -43,9 +44,6 @@ allow-direct-references = true
[tool.hatch.build.targets.wheel]
packages = ["src/algopy", 'src/algopy_testing', 'src/_algopy_testing']

[[tool.hatch.envs.all.matrix]]
python = ["3.12"]

# default dev environment
[tool.hatch.envs.default]
type = "virtual"
Expand Down Expand Up @@ -121,9 +119,29 @@ path = ".venv.cicd"
dependencies = [
"python-semantic-release>=9.8.5",
]

[tool.hatch.envs.cicd.scripts]
clean_dist = "rm -rf dist"

# Testing environment with matrix
[tool.hatch.envs.test]
dependencies = [
"pytest>=7.4",
"pytest-mock>=3.10.0",
"pytest-xdist[psutil]>=3.3",
"pytest-cov>=4.1.0",
"py-algorand-sdk>=2.4.0",
"algokit-utils>=3.0.0",
"puyapy>=3.0",
]

[tool.hatch.envs.test.scripts]
run = "pytest --cov=src --cov-report=xml {args}"
ci = "pytest --cov=src --cov-report=xml --cov-report=term"

[[tool.hatch.envs.test.matrix]]
python = ["3.12", "3.13"]

# docs environment
[tool.hatch.envs.docs]
path = ".venv.docs"
Expand Down Expand Up @@ -159,7 +177,6 @@ dev = "hatch run docs:test && sphinx-autobuild docs docs/_build"
[tool.hatch.envs.examples]
type = "virtual"
path = ".venv.examples"
python = "3.12"
dev-mode = true
skip-install = false
post-install-commands = [
Expand Down Expand Up @@ -188,6 +205,9 @@ check = [
"hatch run mypy examples",
]

[[tool.hatch.envs.examples.matrix]]
python = ["3.12", "3.13"]

# tool configurations
[tool.black]
line-length = 99
Expand Down
174 changes: 80 additions & 94 deletions src/_algopy_testing/serialize.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import dataclasses
import functools
import inspect
import typing
from collections.abc import Callable, Sequence
Expand All @@ -15,7 +16,6 @@

@dataclasses.dataclass(frozen=True)
class _Serializer(typing.Generic[_T, _U]):
native_type: type[_T]
arc4_type: type[_U]
native_to_arc4: Callable[[_T], _U]
arc4_to_native: Callable[[_U], _T]
Expand All @@ -25,106 +25,92 @@ def identity(i: _T) -> _T:
return i


def get_native_to_arc4_serializer(typ: type[_T]) -> _Serializer: # type: ignore[type-arg] # noqa: PLR0911
def get_native_to_arc4_serializer(typ: type) -> _Serializer[typing.Any, typing.Any]:
from _algopy_testing import arc4
from _algopy_testing.models import Account
from _algopy_testing.primitives import BigUInt, Bytes, ImmutableArray, String
from _algopy_testing.primitives import ImmutableArray
from _algopy_testing.protocols import UInt64Backed

if issubclass(typ, arc4._ABIEncoded):
return _Serializer(
native_type=typ, arc4_type=typ, native_to_arc4=identity, arc4_to_native=identity
)
if issubclass(typ, bool):
return _Serializer(
native_type=typ,
arc4_type=arc4.Bool,
native_to_arc4=arc4.Bool,
arc4_to_native=lambda n: n.native,
)
if issubclass(typ, UInt64Backed):
return _Serializer(
native_type=typ,
arc4_type=arc4.UInt64,
native_to_arc4=lambda n: arc4.UInt64(n.int_),
arc4_to_native=lambda a: typ.from_int(a.native),
)
if issubclass(typ, BigUInt):
return _Serializer(
native_type=typ,
arc4_type=arc4.UInt512,
native_to_arc4=arc4.UInt512,
arc4_to_native=lambda a: a.native,
)
if issubclass(typ, Account):
return _Serializer(
native_type=typ,
arc4_type=arc4.Address,
native_to_arc4=arc4.Address,
arc4_to_native=lambda a: a.native,
)
if issubclass(typ, UInt64):
return _Serializer(
native_type=typ,
arc4_type=arc4.UInt64,
native_to_arc4=arc4.UInt64,
arc4_to_native=lambda a: a.native,
)
if issubclass(typ, Bytes):
return _Serializer(
native_type=typ,
arc4_type=arc4.DynamicBytes,
native_to_arc4=arc4.DynamicBytes,
arc4_to_native=lambda a: a.native,
)
if issubclass(typ, String):
return _Serializer(
native_type=typ,
arc4_type=arc4.String,
native_to_arc4=arc4.String,
arc4_to_native=lambda a: a.native,
)
if issubclass(typ, tuple) or typing.get_origin(typ) is tuple:
origin_type = typing.get_origin(typ)
if origin_type is tuple:
return _get_tuple_serializer(typing.get_args(typ))
elif isinstance(typ, type):
if issubclass(typ, arc4._ABIEncoded):
return _Serializer(arc4_type=typ, native_to_arc4=identity, arc4_to_native=identity)
for native_type, simple_arc4_type in _simple_native_to_arc4_type_map().items():
if issubclass(typ, native_type):
return _Serializer(
arc4_type=simple_arc4_type,
native_to_arc4=simple_arc4_type,
arc4_to_native=lambda n: n.native,
)
if issubclass(typ, UInt64Backed):
return _Serializer(
arc4_type=arc4.UInt64,
native_to_arc4=lambda n: arc4.UInt64(n.int_),
arc4_to_native=lambda a: typ.from_int(a.native),
)
if typing.NamedTuple in getattr(typ, "__orig_bases__", []):
tuple_fields: Sequence[type] = list(inspect.get_annotations(typ).values())
else:
tuple_fields = typing.get_args(typ)
serializers = [get_native_to_arc4_serializer(i) for i in tuple_fields]

def _items_to_arc4(items: Sequence[object]) -> tuple[object, ...]:
result = []
for item, serializer in zip(items, serializers, strict=True):
result.append(serializer.native_to_arc4(item))
return tuple(result)

def _items_to_native(items: Sequence[object]) -> tuple[object, ...]:
result = []
for item, serializer in zip(items, serializers, strict=True):
result.append(serializer.arc4_to_native(item))
return tuple(result)

return _Serializer(
native_type=typ,
arc4_type=arc4.Tuple[*(s.arc4_type for s in serializers)], # type: ignore[misc]
native_to_arc4=lambda t: arc4.Tuple(_items_to_arc4(t)),
arc4_to_native=lambda t: _items_to_native(t),
)
if issubclass(typ, ImmutableArray):
native_element_type = typ._element_type
element_serializer = get_native_to_arc4_serializer(native_element_type)
arc4_element_type = element_serializer.arc4_type
arc4_type = arc4.DynamicArray[arc4_element_type] # type: ignore[valid-type]
return _Serializer(
native_type=typ,
arc4_type=arc4_type,
native_to_arc4=lambda arr: arc4_type(
*(element_serializer.native_to_arc4(e) for e in arr)
),
arc4_to_native=lambda arr: typ(*(element_serializer.arc4_to_native(e) for e in arr)),
)
tuple_fields = tuple(inspect.get_annotations(typ).values())
if any(isinstance(f, str) for f in tuple_fields):
raise TypeError("string annotations in typing.NamedTuple fields are not supported")
return _get_tuple_serializer(tuple_fields)
if issubclass(typ, ImmutableArray):
native_element_type = typ._element_type
element_serializer = get_native_to_arc4_serializer(native_element_type)
arc4_element_type = element_serializer.arc4_type
arc4_type = arc4.DynamicArray[arc4_element_type] # type: ignore[valid-type]
return _Serializer(
arc4_type=arc4_type,
native_to_arc4=lambda arr: arc4_type(
*(element_serializer.native_to_arc4(e) for e in arr)
),
arc4_to_native=lambda arr: typ(
*(element_serializer.arc4_to_native(e) for e in arr)
),
)
raise TypeError(f"unserializable type: {typ}")


@functools.cache
def _simple_native_to_arc4_type_map() -> dict[type, type]:
from _algopy_testing import arc4
from _algopy_testing.models import Account
from _algopy_testing.primitives import BigUInt, Bytes, String

return {
bool: arc4.Bool,
UInt64: arc4.UInt64,
BigUInt: arc4.UInt512,
Account: arc4.Address,
Bytes: arc4.DynamicBytes,
String: arc4.String,
}


def _get_tuple_serializer(item_types: tuple[type, ...]) -> _Serializer[typing.Any, typing.Any]:
from _algopy_testing import arc4

serializers = [get_native_to_arc4_serializer(i) for i in item_types]

def _items_to_arc4(items: Sequence[object]) -> tuple[object, ...]:
result = []
for item, serializer in zip(items, serializers, strict=True):
result.append(serializer.native_to_arc4(item))
return tuple(result)

def _items_to_native(items: Sequence[object]) -> tuple[object, ...]:
result = []
for item, serializer in zip(items, serializers, strict=True):
result.append(serializer.arc4_to_native(item))
return tuple(result)

return _Serializer(
arc4_type=arc4.Tuple[*(s.arc4_type for s in serializers)], # type: ignore[misc]
native_to_arc4=lambda t: arc4.Tuple(_items_to_arc4(t)),
arc4_to_native=lambda t: _items_to_native(t),
)


def serialize_to_bytes(value: object) -> bytes:
return native_to_arc4(value).bytes.value

Expand Down
15 changes: 15 additions & 0 deletions tests/arc4/test_tuple.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import pytest
from _algopy_testing import arc4
from algopy_testing import AlgopyTestContext, algopy_testing_context
from algosdk import abi

from tests.artifacts.Tuples.contract import TuplesContract
from tests.util import int_to_bytes

_abi_string = "hello"
Expand All @@ -15,6 +17,14 @@
_arc4_uint8 = arc4.UInt8(42)
_arc4_bool = arc4.Bool(True)


# New fixture
@pytest.fixture()
def context() -> typing.Generator[AlgopyTestContext, None, None]:
with algopy_testing_context() as ctx:
yield ctx


_test_data = [
(
abi.ABIType.from_string("(uint8,bool,bool)"),
Expand Down Expand Up @@ -279,3 +289,8 @@ def _compare_abi_and_arc4_values(
assert arc4_value.native == abi_value
else:
assert arc4_value.bytes == int_to_bytes(abi_value, len(arc4_value.bytes))


def test_tuple_return_with_primitive_type(context: AlgopyTestContext) -> None: # noqa: ARG001
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried reverting the fix and running this test and it still passed, so I think this isn't fully capturing the issue.

Was the issue related to tuples in storage? As support for this was only recently added to Puya. @boblat is currently working on adding support for that to this library

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@daniel-makerx The error is unique to versions of python starting from 3.13 and higher python/cpython#101162

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a ci to run in matrix, here is a run from temporary commit reverting original code back you can see several tests failing due to new behaviour in 3.13 https://github.com/algorandfoundation/algorand-python-testing/actions/runs/15044342246/job/42283130835?pr=39

contract = TuplesContract()
contract.test_tuple_with_primitive_type()
Empty file.
11 changes: 11 additions & 0 deletions tests/artifacts/Tuples/contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from algopy import (
ARC4Contract,
UInt64,
arc4,
)


class TuplesContract(ARC4Contract, avm_version=11):
@arc4.abimethod()
def test_tuple_with_primitive_type(self) -> tuple[UInt64, bool]:
return (UInt64(0), True)
Loading