Skip to content

Commit fd8d19f

Browse files
committed
feat: support algopy.Array and algopy.ImmutableArray from algorand-python 2.7
1 parent 847f6c7 commit fd8d19f

File tree

10 files changed

+605
-47
lines changed

10 files changed

+605
-47
lines changed

src/_algopy_testing/arc4.py

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ def is_dynamic(self) -> bool:
745745
return True
746746

747747

748-
class _DynamicArrayMeta(type(_ABIEncoded), typing.Generic[_TArrayItem, _TArrayLength]): # type: ignore # noqa: PGH003
748+
class _DynamicArrayMeta(type(_ABIEncoded), typing.Generic[_TArrayItem]): # type: ignore[misc]
749749
__concrete__: typing.ClassVar[dict[type, type]] = {}
750750

751751
def __getitem__(cls, key_t: type[_TArrayItem]) -> type:
@@ -1015,17 +1015,18 @@ def __repr__(self) -> str:
10151015

10161016

10171017
class _StructTypeInfo(_TypeInfo):
1018-
def __init__(self, struct_type: type[Struct]) -> None:
1018+
def __init__(self, struct_type: type[Struct], *, frozen: bool) -> None:
10191019
self.struct_type = struct_type
10201020
self.fields = dataclasses.fields(struct_type)
10211021
self.field_names = [field.name for field in self.fields]
1022+
self.frozen = frozen
10221023

10231024
@property
10241025
def typ(self) -> type:
10251026
return self.struct_type
10261027

10271028
@property
1028-
def child_types(self) -> Iterable[_TypeInfo]:
1029+
def child_types(self) -> list[_TypeInfo]:
10291030
return _tuple_type_from_struct(self.struct_type)._type_info.child_types
10301031

10311032
@property
@@ -1056,8 +1057,11 @@ class Struct(MutableBytes, _ABIEncoded, metaclass=_StructMeta): # type: ignore[
10561057
_type_info: typing.ClassVar[_StructTypeInfo] # type: ignore[misc]
10571058

10581059
def __init_subclass__(cls, *args: typing.Any, **kwargs: dict[str, typing.Any]) -> None:
1059-
dataclasses.dataclass(cls, *args, **kwargs)
1060-
cls._type_info = _StructTypeInfo(cls)
1060+
# make implementation not frozen, so we can conditionally control behaviour
1061+
dataclasses.dataclass(cls, *args, **{**kwargs, "frozen": False})
1062+
frozen = kwargs.get("frozen", False)
1063+
assert isinstance(frozen, bool)
1064+
cls._type_info = _StructTypeInfo(cls, frozen=frozen)
10611065

10621066
def __post_init__(self) -> None:
10631067
# calling base class here to init Mutable
@@ -1073,6 +1077,10 @@ def __setattr__(self, key: str, value: typing.Any) -> None:
10731077
super().__setattr__(key, value)
10741078
# don't update backing value until base class has been init'd
10751079
if hasattr(self, "_on_mutate") and key in self._type_info.field_names:
1080+
if self._type_info.frozen:
1081+
raise dataclasses.FrozenInstanceError(
1082+
f"{type(self)} is frozen and cannot be modified"
1083+
)
10761084
self._update_backing_value()
10771085

10781086
def _update_backing_value(self) -> None:
@@ -1154,34 +1162,16 @@ def emit(event: str | Struct, /, *args: object) -> None:
11541162
log(event_hash[:4] + event_data.value)
11551163

11561164

1157-
def native_value_to_arc4(value: object) -> _ABIEncoded: # noqa: PLR0911
1158-
import algopy
1159-
1160-
if isinstance(value, _ABIEncoded):
1161-
return value
1162-
if isinstance(value, bool):
1163-
return Bool(value)
1164-
if isinstance(value, algopy.UInt64):
1165-
return UInt64(value)
1166-
if isinstance(value, algopy.BigUInt):
1167-
return UInt512(value)
1168-
if isinstance(value, algopy.Bytes):
1169-
return DynamicBytes(value)
1170-
if isinstance(value, algopy.String):
1171-
return String(value)
1172-
if isinstance(value, tuple):
1173-
return Tuple(tuple(map(native_value_to_arc4, value)))
1174-
raise TypeError(f"Unsupported type: {type(value).__name__}")
1175-
1176-
11771165
def _cast_arg_as_arc4(arg: object) -> _ABIEncoded:
1166+
from _algopy_testing.serialize import native_to_arc4
1167+
11781168
if isinstance(arg, int) and not isinstance(arg, bool):
11791169
return UInt64(arg) if arg <= MAX_UINT64 else UInt512(arg)
11801170
if isinstance(arg, bytes):
11811171
return DynamicBytes(arg)
11821172
if isinstance(arg, str):
11831173
return String(arg)
1184-
return native_value_to_arc4(arg)
1174+
return native_to_arc4(arg)
11851175

11861176

11871177
def _find_bool(
@@ -1245,13 +1235,13 @@ def _get_max_bytes_len(type_info: _TypeInfo) -> int:
12451235
size = 0
12461236
if isinstance(type_info, _DynamicArrayTypeInfo):
12471237
size += _ABI_LENGTH_SIZE
1248-
elif isinstance(type_info, _TupleTypeInfo | _StaticArrayTypeInfo):
1238+
elif isinstance(type_info, _TupleTypeInfo | _StructTypeInfo | _StaticArrayTypeInfo):
12491239
i = 0
1250-
child_types = (
1251-
type_info.child_types
1252-
if isinstance(type_info, _TupleTypeInfo)
1253-
else [type_info.item_type] * type_info.size
1254-
)
1240+
if isinstance(type_info, _TupleTypeInfo | _StructTypeInfo):
1241+
child_types = type_info.child_types
1242+
else:
1243+
typing.assert_type(type_info, _StaticArrayTypeInfo)
1244+
child_types = [type_info.item_type] * type_info.size
12551245
while i < len(child_types):
12561246
if isinstance(child_types[i], _BoolTypeInfo):
12571247
after = _find_bool_types(child_types, i, 1)

src/_algopy_testing/decorators/arc4.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,18 @@ def get_ordered_args(
7979
params = list(sig.parameters.values())[1:] # Skip 'self'
8080
app_args_iter = iter(app_args)
8181

82-
ordered_args = [
83-
(
84-
kwargs.get(p.name, next(app_args_iter, p.default))
85-
if p.default is not p.empty
86-
else kwargs.get(p.name) or next(app_args_iter)
87-
)
88-
for p in params
89-
]
82+
ordered_args = []
83+
for p in params:
84+
try:
85+
arg = kwargs[p.name]
86+
except KeyError:
87+
try:
88+
arg = next(app_args_iter)
89+
except StopIteration:
90+
if p.default is p.empty:
91+
raise TypeError(f"missing required argument {p.name}") from None
92+
arg = p.default
93+
ordered_args.append(arg)
9094

9195
if list(app_args_iter):
9296
raise TypeError("Too many positional arguments")
@@ -168,6 +172,8 @@ def abimethod( # noqa: PLR0913
168172

169173
@functools.wraps(fn)
170174
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
175+
from _algopy_testing.serialize import native_to_arc4
176+
171177
contract, *app_args = args
172178
assert isinstance(contract, _algopy_testing.ARC4Contract), "expected ARC4 contract"
173179
assert fn is not None, "expected function"
@@ -186,7 +192,8 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
186192
check_routing_conditions(app_id, metadata)
187193
result = fn(*args, **kwargs)
188194
if result is not None:
189-
abi_result = _algopy_testing.arc4.native_value_to_arc4(result)
195+
196+
abi_result = native_to_arc4(result)
190197
log(ARC4_RETURN_PREFIX, abi_result)
191198
return result
192199

@@ -273,6 +280,8 @@ def _extract_arrays_from_args(
273280
app: algopy.Application,
274281
sender: algopy.Account,
275282
) -> _TxnArrays:
283+
from _algopy_testing.serialize import native_to_arc4
284+
276285
txns = list[_algopy_testing.gtxn.TransactionBase]()
277286
apps = [app]
278287
assets = list[_algopy_testing.Asset]()
@@ -292,7 +301,7 @@ def _extract_arrays_from_args(
292301
app_args.append(_algopy_testing.arc4.UInt8(len(apps)))
293302
apps.append(arg_app)
294303
case _ as maybe_native:
295-
app_args.append(_algopy_testing.arc4.native_value_to_arc4(maybe_native))
304+
app_args.append(native_to_arc4(maybe_native))
296305
if len(app_args) > 15:
297306
packed = _algopy_testing.arc4.Tuple(tuple(app_args[14:]))
298307
app_args[14:] = [packed]
@@ -320,6 +329,7 @@ def _type_to_arc4(annotation: types.GenericAlias | type | None) -> str: # noqa:
320329
from _algopy_testing.arc4 import _ABIEncoded
321330
from _algopy_testing.gtxn import Transaction, TransactionBase
322331
from _algopy_testing.models import Account, Application, Asset
332+
from _algopy_testing.primitives import ImmutableArray
323333

324334
if annotation is None:
325335
return "void"
@@ -331,6 +341,13 @@ def _type_to_arc4(annotation: types.GenericAlias | type | None) -> str: # noqa:
331341
if not isinstance(annotation, type):
332342
raise TypeError(f"expected type: {annotation!r}")
333343

344+
if typing.NamedTuple in getattr(annotation, "__orig_bases__", []):
345+
tuple_fields = list(inspect.get_annotations(annotation).values())
346+
tuple_args = [_type_to_arc4(a) for a in tuple_fields]
347+
return f"({','.join(tuple_args)})"
348+
349+
if issubclass(annotation, ImmutableArray):
350+
return f"{_type_to_arc4(annotation._element_type)}[]"
334351
# arc4 types
335352
if issubclass(annotation, _ABIEncoded):
336353
return annotation._type_info.arc4_name

src/_algopy_testing/models/contract.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from _algopy_testing.decorators.arc4 import get_active_txn_fields, maybe_arc4_metadata
1010
from _algopy_testing.mutable import set_attr_on_mutate
1111
from _algopy_testing.primitives import Bytes, UInt64
12-
from _algopy_testing.protocols import BytesBacked, UInt64Backed
12+
from _algopy_testing.protocols import BytesBacked, Serializable, UInt64Backed
1313
from _algopy_testing.state.utils import deserialize, serialize
1414

1515
if typing.TYPE_CHECKING:
@@ -165,7 +165,7 @@ def __setattr__(self, name: str, value: typing.Any) -> None:
165165
state._key = name_bytes
166166
case _algopy_testing.BoxMap() as box_map if box_map._key_prefix is None:
167167
box_map._key_prefix = name_bytes
168-
case Bytes() | UInt64() | BytesBacked() | UInt64Backed() | bool():
168+
case Bytes() | UInt64() | BytesBacked() | Serializable() | UInt64Backed() | bool():
169169
app_id = _get_self_or_active_app_id(self)
170170
lazy_context.ledger.set_global_state(app_id, name_bytes, serialize(value))
171171
cls = type(self)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from _algopy_testing.primitives.array import Array, ImmutableArray
12
from _algopy_testing.primitives.biguint import BigUInt
23
from _algopy_testing.primitives.bytes import Bytes
34
from _algopy_testing.primitives.string import String
45
from _algopy_testing.primitives.uint64 import UInt64
56

6-
__all__ = ["BigUInt", "Bytes", "String", "UInt64"]
7+
__all__ = ["Array", "BigUInt", "Bytes", "ImmutableArray", "String", "UInt64"]
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import types
2+
import typing
3+
from collections.abc import Iterable, Iterator, Reversible
4+
5+
from _algopy_testing.primitives.uint64 import UInt64
6+
from _algopy_testing.protocols import Serializable
7+
from _algopy_testing.serialize import deserialize_from_bytes, serialize_to_bytes
8+
9+
_T = typing.TypeVar("_T")
10+
11+
12+
class _ImmutableArrayMeta(type):
13+
__concrete__: typing.ClassVar[dict[type, type]] = {}
14+
15+
# get or create a type that is parametrized with element_t
16+
def __getitem__(cls, element_t: type) -> type:
17+
cache = cls.__concrete__
18+
if c := cache.get(element_t, None):
19+
return c
20+
21+
cls_name = f"{cls.__name__}[{element_t.__name__}]"
22+
cache[element_t] = c = types.new_class(
23+
cls_name,
24+
bases=(cls,),
25+
exec_body=lambda ns: ns.update(
26+
_element_type=element_t,
27+
),
28+
)
29+
30+
return c
31+
32+
33+
class ImmutableArray(Serializable, typing.Generic[_T], metaclass=_ImmutableArrayMeta):
34+
_element_type: typing.ClassVar[type]
35+
36+
# ensure type is fully parameterized by looking up type from metaclass
37+
def __new__(cls, *items: _T) -> typing.Self:
38+
from _algopy_testing.serialize import type_of
39+
40+
try:
41+
assert cls._element_type
42+
except AttributeError:
43+
try:
44+
item = items[0]
45+
except IndexError:
46+
raise TypeError("array must have an item type") from None
47+
cls = cls[type_of(item)]
48+
instance = super().__new__(cls)
49+
return instance
50+
51+
def __init__(self, *items: _T):
52+
for item in items:
53+
if not isinstance(item, typing.get_origin(self._element_type) or self._element_type):
54+
raise TypeError(f"expected items of type {self._element_type}")
55+
self._items = tuple(items)
56+
57+
def __iter__(self) -> Iterator[_T]:
58+
return iter(self._items)
59+
60+
def __reversed__(self) -> Iterator[_T]:
61+
return reversed(self._items)
62+
63+
@property
64+
def length(self) -> UInt64:
65+
return UInt64(len(self._items))
66+
67+
def __getitem__(self, index: UInt64 | int) -> _T:
68+
return self._items[index]
69+
70+
def replace(self, index: UInt64 | int, value: _T) -> "ImmutableArray[_T]":
71+
copied = list(self._items)
72+
copied[int(index)] = value
73+
return self._from_iter(copied)
74+
75+
def append(self, item: _T, /) -> "ImmutableArray[_T]":
76+
copied = list(self._items)
77+
copied.append(item)
78+
return self._from_iter(copied)
79+
80+
def __add__(self, other: Iterable[_T], /) -> "ImmutableArray[_T]":
81+
return self._from_iter((*self._items, *other))
82+
83+
def pop(self) -> "ImmutableArray[_T]":
84+
copied = list(self._items)
85+
copied.pop()
86+
return self._from_iter(copied)
87+
88+
def _from_iter(self, items: Iterable[_T]) -> "ImmutableArray[_T]":
89+
"""Returns a new array populated with items, also ensures element type info is
90+
preserved."""
91+
el_type = self._element_type
92+
typ = ImmutableArray[el_type] # type: ignore[valid-type]
93+
return typ(*items)
94+
95+
def __bool__(self) -> bool:
96+
return bool(self._items)
97+
98+
def serialize(self) -> bytes:
99+
return serialize_to_bytes(self)
100+
101+
@classmethod
102+
def from_bytes(cls, value: bytes, /) -> typing.Self:
103+
return deserialize_from_bytes(cls, value)
104+
105+
106+
class Array(Reversible[_T]):
107+
108+
def __init__(self, *items: _T):
109+
self._items = list(items)
110+
111+
def __iter__(self) -> Iterator[_T]:
112+
return iter(list(self._items))
113+
114+
def __reversed__(self) -> Iterator[_T]:
115+
return reversed(self._items)
116+
117+
@property
118+
def length(self) -> UInt64:
119+
return UInt64(len(self._items))
120+
121+
def __getitem__(self, index: UInt64 | int) -> _T:
122+
return self._items[int(index)]
123+
124+
def __setitem__(self, index: UInt64 | int, value: _T) -> _T:
125+
self._items[int(index)] = value
126+
return value
127+
128+
def append(self, item: _T, /) -> None:
129+
self._items.append(item)
130+
131+
def extend(self, other: Iterable[_T], /) -> None:
132+
self._items.extend(other)
133+
134+
def pop(self) -> _T:
135+
return self._items.pop()
136+
137+
def copy(self) -> "Array[_T]":
138+
return Array(*self._items)
139+
140+
def freeze(self) -> ImmutableArray[_T]:
141+
return ImmutableArray(*self._items)
142+
143+
def __bool__(self) -> bool:
144+
return bool(self._items)

src/_algopy_testing/protocols.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@
66
import algopy
77

88

9+
class Serializable:
10+
"""For algopy testing only, allows serializing to/from bytes for types that aren't
11+
BytesBacked."""
12+
13+
@classmethod
14+
def from_bytes(cls, value: bytes, /) -> typing.Self:
15+
raise NotImplementedError
16+
17+
def serialize(self) -> bytes:
18+
raise NotImplementedError
19+
20+
921
class BytesBacked:
1022
"""Represents a type that is a single bytes value."""
1123

0 commit comments

Comments
 (0)