diff --git a/examples/proof_of_attendance/contract.py b/examples/proof_of_attendance/contract.py index 8e7e819..4aefeb5 100644 --- a/examples/proof_of_attendance/contract.py +++ b/examples/proof_of_attendance/contract.py @@ -45,11 +45,11 @@ def confirm_attendance_with_box_ref(self) -> None: minted_asset = self._mint_poa(algopy.Txn.sender) self.total_attendees += 1 - box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes) + box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes) has_claimed = bool(box_ref) assert not has_claimed, "Already claimed POA" - box_ref.put(algopy.op.itob(minted_asset.id)) + box_ref.value = algopy.op.itob(minted_asset.id) @algopy.arc4.abimethod() def confirm_attendance_with_box_map(self) -> None: @@ -78,7 +78,7 @@ def get_poa_id_with_box(self) -> algopy.UInt64: @algopy.arc4.abimethod(readonly=True) def get_poa_id_with_box_ref(self) -> algopy.UInt64: - box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes) + box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes) poa_id, exists = box_ref.maybe() assert exists, "POA not found" return algopy.op.btoi(poa_id) @@ -130,7 +130,7 @@ def claim_poa_with_box(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) - @algopy.arc4.abimethod() def claim_poa_with_box_ref(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None: - box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes) + box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes) poa_id, exists = box_ref.maybe() assert exists, "POA not found, attendance validation failed!" assert opt_in_txn.xfer_asset.id == algopy.op.btoi(poa_id), "POA ID mismatch" diff --git a/pyproject.toml b/pyproject.toml index 5f67782..8c4020a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "coincurve>=19.0.1", # TODO: uncomment below and remove direct git reference once puya 5.0 is released # "algorand-python>=3", - "algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7#subdirectory=stubs", + "algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.10#subdirectory=stubs", ] [project.urls] @@ -54,7 +54,7 @@ python = "3.12" dependencies = [ # TODO: uncomment below and remove direct git reference once puya 5.0 is released # "puyapy>=5", - "puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7", + "puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.10", "pytest>=7.4", "pytest-mock>=3.10.0", "pytest-xdist[psutil]>=3.3", @@ -138,7 +138,7 @@ dependencies = [ "algokit-utils>=3.0.0", # TODO: uncomment below and remove direct git reference once puya 5.0 is released # "puyapy>=5", - "puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7", + "puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.10", ] [tool.hatch.envs.test.scripts] @@ -191,7 +191,7 @@ post-install-commands = [ dependencies = [ # TODO: uncomment below and remove direct git reference once puya 5.0 is released # "algorand-python>=3", - "algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7#subdirectory=stubs", + "algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.10#subdirectory=stubs", "pytest>=7.4", "pytest-mock>=3.10.0", "pytest-xdist[psutil]>=3.3", diff --git a/src/_algopy_testing/arc4.py b/src/_algopy_testing/arc4.py index f080154..1da93b5 100644 --- a/src/_algopy_testing/arc4.py +++ b/src/_algopy_testing/arc4.py @@ -140,7 +140,7 @@ class _ABIEncoded(BytesBacked): def from_bytes(cls, value: algopy.Bytes | bytes, /) -> typing.Self: """Construct an instance from the underlying bytes (no validation)""" instance = cls() - instance._value = as_bytes(value) + instance._value = value.value if isinstance(value, Bytes) else value return instance @classmethod @@ -556,11 +556,11 @@ class Bool(_ABIEncoded): _value: bytes # True value is encoded as having a 1 on the most significant bit (0x80 = 128) - _true_int_value = 128 - _false_int_value = 0 + _true_byte_value = int_to_bytes(128, 1) + _false_byte_value = int_to_bytes(0, 1) def __init__(self, value: bool = False, /) -> None: # noqa: FBT001, FBT002 - self._value = int_to_bytes(self._true_int_value if value else self._false_int_value, 1) + self._value = self._true_byte_value if value else self._false_byte_value def __eq__(self, other: object) -> bool: try: @@ -576,8 +576,7 @@ def __bool__(self) -> bool: @property def native(self) -> bool: """Return the bool representation of the value after ARC4 decoding.""" - int_value = int.from_bytes(self._value) - return int_value == self._true_int_value + return self._value == self._true_byte_value def __str__(self) -> str: return f"{self.native}" @@ -669,7 +668,8 @@ def __init__(self, *_items: _TArrayItem): f"item must be of type {self._type_info.item_type!r}, not {item._type_info!r}" ) - self._value = _encode(items) + item_list = list(items) + self._value = _encode(item_list) def __iter__(self) -> Iterator[_TArrayItem]: # """Returns an iterator for the items in the array""" @@ -854,7 +854,8 @@ def __init__(self, *_items: _TArrayItem): raise TypeError( f"item must be of type {self._type_info.item_type!r}, not {item._type_info!r}" ) - self._value = self._encode_with_length(items) + item_list = list(items) + self._value = self._encode_with_length(item_list) def __iter__(self) -> typing.Iterator[_TArrayItem]: """Returns an iterator for the items in the array.""" @@ -1294,6 +1295,9 @@ def _find_bool( is_looking_forward = delta > 0 is_looking_backward = delta < 0 values_length = len(values) if isinstance(values, tuple | list) else values.length.value + if isinstance(values, (StaticArray | DynamicArray | list)): + return 0 if is_looking_backward else values_length - index - 1 + while True: curr = index + delta * until is_curr_at_end = curr == values_length - 1 @@ -1311,12 +1315,16 @@ def _find_bool( return until -def _find_bool_types(values: typing.Sequence[_TypeInfo], index: int, delta: int) -> int: +def _find_bool_types( + values: typing.Sequence[_TypeInfo], index: int, delta: int, *, is_homogeneous: bool = False +) -> int: """Helper function to find consecutive booleans from current index in a tuple.""" until = 0 is_looking_forward = delta > 0 is_looking_backward = delta < 0 values_length = len(values) + if is_homogeneous: + return 0 if is_looking_backward else values_length - index - 1 while True: curr = index + delta * until is_curr_at_end = curr == values_length - 1 @@ -1438,6 +1446,7 @@ def _encode( # noqa: PLR0912 raise ValueError( "expected before index should have number of bool mod 8 equal 0" ) + after = min(7, after) consecutive_bool_list = [values[i] for i in range(i, i + after + 1)] compressed_int = _compress_multiple_bool(consecutive_bool_list) @@ -1467,7 +1476,7 @@ def _encode( # noqa: PLR0912 return values_length_bytes + b"".join(heads) + b"".join(tails) -def _decode_tuple_items( # noqa: PLR0912 +def _decode_tuple_items( # noqa: PLR0912, PLR0915 value: bytes, child_types: list[_TypeInfo] ) -> list[typing.Any]: dynamic_segments: list[list[int]] = [] # Store the start and end of a dynamic element @@ -1475,6 +1484,8 @@ def _decode_tuple_items( # noqa: PLR0912 i = 0 array_index = 0 + is_homogeneous_child_types = len(set(child_types)) == 1 + while i < len(child_types): child_type = child_types[i] if child_type.is_dynamic: @@ -1489,8 +1500,12 @@ def _decode_tuple_items( # noqa: PLR0912 value_partitions.append(b"") array_index += _ABI_LENGTH_SIZE elif isinstance(child_type, _BoolTypeInfo): - before = _find_bool_types(child_types, i, -1) - after = _find_bool_types(child_types, i, 1) + before = _find_bool_types( + child_types, index=i, delta=-1, is_homogeneous=is_homogeneous_child_types + ) + after = _find_bool_types( + child_types, index=i, delta=1, is_homogeneous=is_homogeneous_child_types + ) if before % 8 != 0: raise ValueError("expected before index should have number of bool mod 8 equal 0") diff --git a/src/_algopy_testing/primitives/array.py b/src/_algopy_testing/primitives/array.py index 0835ece..875842d 100644 --- a/src/_algopy_testing/primitives/array.py +++ b/src/_algopy_testing/primitives/array.py @@ -14,6 +14,9 @@ parameterize_type, ) +if typing.TYPE_CHECKING: + from _typeshed import DataclassInstance + _TArrayItem = typing.TypeVar("_TArrayItem") _TArrayLength = typing.TypeVar("_TArrayLength", bound=int) _T = typing.TypeVar("_T") @@ -246,7 +249,7 @@ def _from_iter(self, items: Iterable[_TArrayItem]) -> "FixedArray[_TArrayItem, _ return typ(items) def serialize(self) -> bytes: - return serialize_to_bytes(self) + return self._value @classmethod def from_bytes(cls, value: bytes, /) -> typing.Self: @@ -509,10 +512,15 @@ def from_bytes(cls, value: bytes, /) -> typing.Self: class Struct(Serializable, MutableBytes): """Base class for Struct types.""" + _field_names: typing.ClassVar[list[str]] + def __init_subclass__(cls, *args: typing.Any, **kwargs: dict[str, typing.Any]) -> None: # make implementation not frozen, so we can conditionally control behaviour dataclasses.dataclass(cls, *args, **{**kwargs, "frozen": False}) frozen = kwargs.get("frozen", False) + cls._field_names = [ + f.name for f in dataclasses.fields(typing.cast("type[DataclassInstance]", cls)) + ] assert isinstance(frozen, bool) def __post_init__(self) -> None: @@ -524,6 +532,13 @@ def __getattribute__(self, name: str) -> typing.Any: value = super().__getattribute__(name) return add_mutable_callback(lambda _: self._update_backing_value(), value) + def __setattr__(self, key: str, value: typing.Any) -> None: + super().__setattr__(key, value) + # don't update backing value until base class has been init'd + + if hasattr(self, "_on_mutate") and key in self._field_names: + self._update_backing_value() + def copy(self) -> typing.Self: return self.__class__.from_bytes(self.serialize()) diff --git a/src/_algopy_testing/primitives/bytes.py b/src/_algopy_testing/primitives/bytes.py index 5da10db..c738a59 100644 --- a/src/_algopy_testing/primitives/bytes.py +++ b/src/_algopy_testing/primitives/bytes.py @@ -12,7 +12,7 @@ from _algopy_testing.constants import MAX_BYTES_SIZE from _algopy_testing.primitives.uint64 import UInt64 -from _algopy_testing.utils import as_bytes, as_int64, check_type +from _algopy_testing.utils import as_bytes, check_type # TypeError, ValueError are used for operations that are compile time errors # ArithmeticError and subclasses are used for operations that would fail during AVM execution @@ -74,7 +74,8 @@ def __getitem__( if isinstance(index, slice): return Bytes(self.value[index]) else: - int_index = as_int64(index) + int_index = index.value if isinstance(index, UInt64) else index + int_index = len(self.value) + int_index if int_index < 0 else int_index # my_bytes[0:1] => b'j' whereas my_bytes[0] => 106 return Bytes(self.value[slice(int_index, int_index + 1)]) diff --git a/src/_algopy_testing/serialize.py b/src/_algopy_testing/serialize.py index 3e4a18e..b4d07ae 100644 --- a/src/_algopy_testing/serialize.py +++ b/src/_algopy_testing/serialize.py @@ -174,7 +174,7 @@ class TempStruct(arc4.Struct): def serialize_to_bytes(value: object) -> bytes: - return native_to_arc4(value).bytes.value + return native_to_arc4(value)._value def type_of(value: object) -> type: diff --git a/src/_algopy_testing/state/box.py b/src/_algopy_testing/state/box.py index 3b2c938..c95aca8 100644 --- a/src/_algopy_testing/state/box.py +++ b/src/_algopy_testing/state/box.py @@ -3,6 +3,8 @@ import typing import warnings +from typing_extensions import deprecated + import _algopy_testing from _algopy_testing.constants import MAX_BOX_SIZE from _algopy_testing.context_helpers import lazy_context @@ -100,6 +102,61 @@ def value(self, value: _TValue) -> None: def value(self) -> None: lazy_context.ledger.delete_box(self.app_id, self.key) + @property + @deprecated("Box methods previously accessed via `.ref` are now directly available") + def ref(self) -> BoxRef: + return BoxRef(key=self.key) + + def extract( + self, start_index: algopy.UInt64 | int, length: algopy.UInt64 | int + ) -> algopy.Bytes: + """Extract a slice of bytes from the box. + + Fails if the box does not exist, or if `start_index + length > len(box)` + + :arg start_index: The offset to start extracting bytes from + :arg length: The number of bytes to extract + :return: The extracted bytes + """ + return _BoxRef(key=self.key).extract(start_index, length) + + def resize(self, new_size: algopy.UInt64 | int) -> None: + """Resizes the box the specified `new_size`. Truncating existing data if the new + value is shorter or padding with zero bytes if it is longer. + + :arg new_size: The new size of the box + """ + return _BoxRef(key=self.key).resize(new_size) + + def replace(self, start_index: algopy.UInt64 | int, value: algopy.Bytes | bytes) -> None: + """Write `value` to the box starting at `start_index`. Fails if the box does not + exist, or if `start_index + len(value) > len(box)` + + :arg start_index: The offset to start writing bytes from + :arg value: The bytes to be written + """ + return _BoxRef(key=self.key).replace(start_index, value) + + def splice( + self, + start_index: algopy.UInt64 | int, + length: algopy.UInt64 | int, + value: algopy.Bytes | bytes, + ) -> None: + """Set box to contain its previous bytes up to index `start_index`, followed by + `bytes`, followed by the original bytes of the box that began at index + `start_index + length` + + **Important: This op does not resize the box** + If the new value is longer than the box size, it will be truncated. + If the new value is shorter than the box size, it will be padded with zero bytes + + :arg start_index: The index to start inserting `value` + :arg length: The number of bytes after `start_index` to omit from the new value + :arg value: The `value` to be inserted. + """ + return _BoxRef(key=self.key).splice(start_index, length, value) + def get(self, *, default: _TValue) -> _TValue: box_content, box_exists = self.maybe() return default if not box_exists else box_content @@ -117,7 +174,7 @@ def length(self) -> algopy.UInt64: return _algopy_testing.UInt64(len(lazy_context.ledger.get_box(self.app_id, self.key))) -class BoxRef: +class _BoxRef: """BoxRef abstracts the reading and writing of boxes containing raw binary data. The size is configured manually, and can be set to values larger than what the AVM @@ -186,14 +243,15 @@ def resize(self, new_size: algopy.UInt64 | int) -> None: lazy_context.ledger.set_box(self.app_id, self.key, updated_content) def replace(self, start_index: algopy.UInt64 | int, value: algopy.Bytes | bytes) -> None: + replace_content = value.value if isinstance(value, _algopy_testing.Bytes) else value box_content, box_exists = self._maybe() if not box_exists: raise RuntimeError("Box has not been created") start = int(start_index) - length = len(value) + length = len(replace_content) if (start + length) > len(box_content): raise ValueError("Replacement content exceeds box size") - updated_content = box_content[:start] + value + box_content[start + length :] + updated_content = box_content[:start] + replace_content + box_content[start + length :] lazy_context.ledger.set_box(self.app_id, self.key, updated_content) def splice( @@ -260,6 +318,11 @@ def length(self) -> algopy.UInt64: return _algopy_testing.UInt64(len(box_content)) +@deprecated("Methods in BoxRef are now directly available on Box") +class BoxRef(_BoxRef): + pass + + class BoxMap(typing.Generic[_TKey, _TValue]): """BoxMap abstracts the reading and writing of a set of boxes using a common key and content type. @@ -338,3 +401,8 @@ def length(self, key: _TKey) -> algopy.UInt64: def _full_key(self, key: _TKey) -> algopy.Bytes: return self.key_prefix + cast_to_bytes(key) + + def box(self, key: _TKey) -> Box[_TValue]: + """Returns a Box holding the box value at key.""" + key_bytes = self._full_key(key) + return Box(self._value_type, key=key_bytes) diff --git a/tests/artifacts/BoxContract/contract.py b/tests/artifacts/BoxContract/contract.py index 7f76a4b..166f9fa 100644 --- a/tests/artifacts/BoxContract/contract.py +++ b/tests/artifacts/BoxContract/contract.py @@ -1,10 +1,74 @@ -from algopy import ARC4Contract, Box, OnCompleteAction, TransactionType, arc4, op +import typing + +from algopy import ( + ARC4Contract, + Array, + Box, + BoxMap, + Bytes, + FixedArray, + Global, + ImmutableFixedArray, + OnCompleteAction, + String, + Struct, + TransactionType, + Txn, + UInt64, + arc4, + ensure_budget, + op, + size_of, + subroutine, +) + +StaticInts: typing.TypeAlias = arc4.StaticArray[arc4.UInt8, typing.Literal[4]] +Bytes1024 = ImmutableFixedArray[arc4.Byte, typing.Literal[1024]] +ManyInts = FixedArray[UInt64, typing.Literal[513]] + + +class LargeStruct(Struct): + a: Bytes1024 + b: Bytes1024 + c: Bytes1024 + d: Bytes1024 + e: UInt64 + f: Bytes1024 + g: Bytes1024 + h: UInt64 + + +class InnerStruct(Struct): + c: UInt64 + arr_arr: Array[Array[UInt64]] + d: UInt64 + + +class NestedStruct(Struct): + a: UInt64 + inner: InnerStruct + woah: Array[InnerStruct] + b: UInt64 + + +class LargeNestedStruct(Struct): + padding: FixedArray[arc4.Byte, typing.Literal[4096]] + nested: NestedStruct class BoxContract(ARC4Contract): def __init__(self) -> None: self.oca = Box(OnCompleteAction) self.txn = Box(TransactionType) + self.box_a = Box(UInt64) + self.box_b = Box[arc4.DynamicBytes](arc4.DynamicBytes, key="b") + self.box_c = Box(arc4.String, key=b"BOX_C") + self.box_d = Box(Bytes) + self.box_map = BoxMap(UInt64, String, key_prefix="") + self.many_ints = Box(ManyInts) + self.box_ref = Box(Bytes) + self.box_large = Box(LargeStruct) + self.too_many_bools = Box(FixedArray[bool, typing.Literal[33_000]]) @arc4.abimethod() def store_enums(self) -> None: @@ -17,3 +81,270 @@ def read_enums(self) -> arc4.Tuple[arc4.UInt64, arc4.UInt64]: assert op.Box.get(b"txn")[0] == op.itob(self.txn.value) return arc4.Tuple((arc4.UInt64(self.oca.value), arc4.UInt64(self.txn.value))) + + @arc4.abimethod + def set_boxes(self, a: UInt64, b: arc4.DynamicBytes, c: arc4.String) -> None: + self.box_a.value = a + self.box_b.value = b.copy() + self.box_c.value = c + self.box_d.value = b.native + self.box_large.create() + self.box_large.value.e = UInt64(42) + self.box_large.replace(size_of(Bytes1024) * 4, arc4.UInt64(42).bytes) + + b_value = self.box_b.value.copy() + assert self.box_b.value.length == b_value.length, "direct reference should match copy" + + self.box_a.value += 3 + + # test .length + assert self.box_a.length == 8 + assert self.box_b.length == b.bytes.length + assert self.box_c.length == c.bytes.length + assert self.box_d.length == b.native.length + + # test .value.bytes + assert self.box_c.value.bytes[0] == c.bytes[0] + assert self.box_c.value.bytes[-1] == c.bytes[-1] + assert self.box_c.value.bytes[:-1] == c.bytes[:-1] + assert self.box_c.value.bytes[:2] == c.bytes[:2] + + # test .value with Bytes type + assert self.box_d.value[0] == b.native[0] + assert self.box_d.value[-1] == b.native[-1] + assert self.box_d.value[:-1] == b.native[:-1] + assert self.box_d.value[:5] == b.native[:5] + assert self.box_d.value[: UInt64(2)] == b.native[: UInt64(2)] + + assert self.box_large.length == size_of(LargeStruct) + + @arc4.abimethod + def boxes_exist(self) -> tuple[bool, bool, bool, bool]: + return bool(self.box_a), bool(self.box_b), bool(self.box_c), bool(self.box_large) + + @arc4.abimethod + def check_keys(self) -> None: + assert self.box_a.key == b"box_a", "box a key ok" + assert self.box_b.key == b"b", "box b key ok" + assert self.box_c.key == b"BOX_C", "box c key ok" + assert self.box_large.key == b"box_large", "box large key ok" + + @arc4.abimethod + def read_boxes(self) -> tuple[UInt64, Bytes, arc4.String, UInt64]: + return ( + get_box_value_plus_1(self.box_a) - 1, + self.box_b.value.native, + self.box_c.value, + self.box_large.value.e, + ) + + @arc4.abimethod() + def indirect_extract_and_replace(self) -> None: + large = self.box_large.value.copy() + large.e += 1 + self.box_large.value = large.copy() + + @arc4.abimethod + def delete_boxes(self) -> None: + del self.box_a.value + del self.box_b.value + del self.box_c.value + assert self.box_a.get(default=UInt64(42)) == 42 + assert self.box_b.get(default=arc4.DynamicBytes(b"42")).native == b"42" + assert self.box_c.get(default=arc4.String("42")) == "42" + a, a_exists = self.box_a.maybe() + assert not a_exists + assert a == 0 + del self.box_large.value + + @arc4.abimethod + def slice_box(self) -> None: + box_0 = Box(Bytes, key=String("0")) + box_0.value = Bytes(b"Testing testing 123") + assert box_0.value[0:7] == b"Testing" + + self.box_c.value = arc4.String("Hello") + assert self.box_c.value.bytes[2:10] == b"Hello" + + @arc4.abimethod + def arc4_box(self) -> None: + box_d = Box(StaticInts, key=Bytes(b"d")) + box_d.value = StaticInts(arc4.UInt8(0), arc4.UInt8(1), arc4.UInt8(2), arc4.UInt8(3)) + + assert box_d.value[0] == 0 + assert box_d.value[1] == 1 + assert box_d.value[2] == 2 + assert box_d.value[3] == 3 + + @arc4.abimethod() + def create_many_ints(self) -> None: + self.many_ints.create() + + @arc4.abimethod() + def set_many_ints(self, index: UInt64, value: UInt64) -> None: + self.many_ints.value[index] = value + + @arc4.abimethod() + def sum_many_ints(self) -> UInt64: + ensure_budget(10_500) + total = UInt64(0) + for val in self.many_ints.value: + total = total + val + return total + + @arc4.abimethod + def test_box_ref(self) -> None: + # init ref, with valid key types + box_ref = Box(Bytes, key="blob") + assert not box_ref, "no data" + box_ref = Box(Bytes, key=b"blob") + assert not box_ref, "no data" + box_ref = Box(Bytes, key=Bytes(b"blob")) + assert not box_ref, "no data" + box_ref = Box(Bytes, key=String("blob")) + assert not box_ref, "no data" + + # create + assert box_ref.create(size=UInt64(32)) + assert box_ref, "has data" + + # manipulate data + sender_bytes = Txn.sender.bytes + app_address = Global.current_application_address.bytes + value_3 = Bytes(b"hello") + box_ref.replace(0, sender_bytes) + box_ref.resize(8000) + box_ref.splice(0, 0, app_address) + box_ref.replace(64, value_3) + prefix = box_ref.extract(0, 32 * 2 + value_3.length) + assert prefix == app_address + sender_bytes + value_3 + + # delete + del box_ref.value + assert box_ref.key == b"blob" + + # query + value, exists = box_ref.maybe() + assert not exists + assert value == b"" + assert box_ref.get(default=sender_bytes) == sender_bytes + + # update + box_ref.value = sender_bytes + app_address + assert box_ref, "Blob exists" + assert box_ref.length == 64 + assert get_box_ref_length(box_ref) == 64 + + # instance box ref + self.box_ref.create(size=UInt64(32)) + assert self.box_ref, "has data" + del self.box_ref.value + + @arc4.abimethod() + def set_nested_struct(self, struct: NestedStruct) -> None: + box = Box(LargeNestedStruct, key="box") + assert struct.a, "struct.a is truthy" + struct_bytes = Txn.application_args(1) + struct_size = struct_bytes.length + tail_offset = UInt64(4096 + 2) + # initialize box to zero + box.create(size=tail_offset + struct_size) + # set correct offset for dynamic portion + box.replace(tail_offset - 2, arc4.UInt16(tail_offset).bytes) + # set dynamic data + box.replace(tail_offset, struct_bytes) + + @arc4.abimethod() + def nested_write(self, index: UInt64, value: UInt64) -> None: + box = Box(LargeNestedStruct, key="box") + box.value.nested.a = value + box.value.nested.b = value + 1 + box.value.nested.inner.arr_arr[index][index] = value + 2 + box.value.nested.inner.c = value + 3 + box.value.nested.inner.d = value + 4 + box.value.nested.woah[index].arr_arr[index][index] = value + 5 + + @arc4.abimethod() + def nested_read(self, i1: UInt64, i2: UInt64, i3: UInt64) -> UInt64: + box = Box(LargeNestedStruct, key="box") + a = box.value.nested.a + b = box.value.nested.b + arr_arr = box.value.nested.inner.arr_arr[i1][i2] + c = box.value.nested.inner.c + d = box.value.nested.inner.d + woah_arr_arr = box.value.nested.woah[i1].arr_arr[i2][i3] + + return a + b + arr_arr + c + d + woah_arr_arr + + @arc4.abimethod + def create_bools(self) -> None: + self.too_many_bools.create() + + @arc4.abimethod + def set_bool(self, index: UInt64, value: bool) -> None: # noqa: FBT001 + self.too_many_bools.value[index] = value + + @arc4.abimethod() + def sum_bools(self, stop_at_total: UInt64) -> UInt64: + total = UInt64() + for value in self.too_many_bools.value: + if value: + total += 1 + if total == stop_at_total: + break + return total + + @arc4.abimethod + def box_map_test(self) -> None: + key_0 = UInt64(0) + key_1 = UInt64(1) + value = String("Hmmmmm") + self.box_map[key_0] = value + box_0 = self.box_map.box(key_0) + + assert self.box_map[key_0].bytes.length == value.bytes.length + assert self.box_map[key_0].bytes.length == box_0.length + assert self.box_map.length(key_0) == value.bytes.length + + assert self.box_map.get(key_1, default=String("default")) == String("default") + value, exists = self.box_map.maybe(key_1) + assert not exists + assert key_0 in self.box_map + assert self.box_map.key_prefix == b"" + + # test box map not assigned to the class and passed to subroutine + tmp_box_map = BoxMap(UInt64, String, key_prefix=Bytes()) + tmp_box_map[key_1] = String("hello") + assert get_box_map_value_from_key_plus_1(tmp_box_map, UInt64(0)) == "hello" + del tmp_box_map[key_1] + + @arc4.abimethod + def box_map_set(self, key: UInt64, value: String) -> None: + self.box_map[key] = value + + @arc4.abimethod + def box_map_get(self, key: UInt64) -> String: + return self.box_map[key] + + @arc4.abimethod + def box_map_del(self, key: UInt64) -> None: + del self.box_map[key] + + @arc4.abimethod + def box_map_exists(self, key: UInt64) -> bool: + return key in self.box_map + + +@subroutine +def get_box_ref_length(ref: Box[Bytes]) -> UInt64: + return ref.length + + +@subroutine +def get_box_value_plus_1(box: Box[UInt64]) -> UInt64: + return box.value + 1 + + +@subroutine +def get_box_map_value_from_key_plus_1(box_map: BoxMap[UInt64, String], key: UInt64) -> String: + return box_map[key + 1] diff --git a/tests/models/test_box.py b/tests/models/test_box.py index a5d2851..cdb003f 100644 --- a/tests/models/test_box.py +++ b/tests/models/test_box.py @@ -4,7 +4,8 @@ import algopy import pytest -from _algopy_testing import algopy_testing_context, arc4 +from _algopy_testing import algopy_testing_context, arc4, op +from _algopy_testing.constants import MAX_BYTES_SIZE from _algopy_testing.context import AlgopyTestContext from _algopy_testing.models.account import Account from _algopy_testing.models.application import Application @@ -25,7 +26,12 @@ from _algopy_testing.state.utils import cast_to_bytes from _algopy_testing.utils import as_bytes, as_string -from tests.artifacts.BoxContract.contract import BoxContract +from tests.artifacts.BoxContract.contract import ( + BoxContract, + InnerStruct, + LargeNestedStruct, + NestedStruct, +) BOX_NOT_CREATED_ERROR = "Box has not been created" @@ -453,3 +459,130 @@ def test_arrays_and_struct_in_boxes(context: AlgopyTestContext) -> None: # noqa box5.value.a[1] = UInt64(20) assert list(box5.value.a) == [UInt64(1), UInt64(20), UInt64(3)] + + +def test_box() -> None: + with algopy_testing_context(): + contract = BoxContract() + + (a_exist, b_exist, c_exist, large_exist) = contract.boxes_exist() + assert not a_exist + assert not b_exist + assert not c_exist + assert not large_exist + + contract.set_boxes(a=UInt64(56), b=arc4.DynamicBytes(b"Hello"), c=arc4.String("World")) + + (a_exist, b_exist, c_exist, large_exist) = contract.boxes_exist() + assert a_exist + assert b_exist + assert c_exist + assert large_exist + + contract.check_keys() + + (a, b, c, large) = contract.read_boxes() + + assert (a, b, c, large) == (59, b"Hello", "World", 42) + + contract.indirect_extract_and_replace() + + contract.delete_boxes() + + (a_exist, b_exist, c_exist, large_exist) = contract.boxes_exist() + + assert not a_exist + assert not b_exist + assert not c_exist + assert not large_exist + + contract.slice_box() + + contract.arc4_box() + + contract.create_many_ints() + + contract.set_many_ints(index=UInt64(1), value=UInt64(1)) + contract.set_many_ints(index=UInt64(2), value=UInt64(2)) + contract.set_many_ints(index=UInt64(256), value=UInt64(256)) + contract.set_many_ints(index=UInt64(511), value=UInt64(511)) + contract.set_many_ints(index=UInt64(512), value=UInt64(512)) + + sum_many_ints = contract.sum_many_ints() + assert sum_many_ints == (1 + 2 + 256 + 511 + 512) + + +def test_nested_struct_box() -> None: + with algopy_testing_context() as ctx: + contract = BoxContract() + r = iter(range(1, 256)) + + def n() -> UInt64: + return UInt64(next(r)) + + def inner() -> object: + c, arr, d = (n() for _ in range(3)) + return InnerStruct(c=c, arr_arr=Array([Array([arr] * 4) for _ in range(3)]), d=d) + + struct = NestedStruct(a=n(), inner=inner(), woah=Array([inner() for _ in range(3)]), b=n()) + assert n() < 100, "too many ints" + contract.set_nested_struct(struct=struct) + response = contract.nested_read(i1=UInt64(1), i2=UInt64(2), i3=UInt64(3)) + assert response == 33, "expected sum to be correct" + + contract.nested_write(index=UInt64(1), value=UInt64(10)) + response = contract.nested_read(i1=UInt64(1), i2=UInt64(2), i3=UInt64(3)) + assert response == 60, "expected sum to be correct" + + # verify box contents + with ctx.txn.create_group( + [ctx.any.txn.application_call(app_id=ctx.ledger.get_app(contract))] + ): + box_length = op.Box.length(b"box")[0] + padding_bytes = op.Box.extract(b"box", 0, MAX_BYTES_SIZE) + struct_bytes = op.Box.extract(b"box", MAX_BYTES_SIZE, box_length - MAX_BYTES_SIZE) + box_bytes = padding_bytes.value + struct_bytes.value + large_nested_struct = LargeNestedStruct.from_bytes(box_bytes) + assert list(large_nested_struct.padding) == [ + arc4.Byte(b) for b in b"\x00" * MAX_BYTES_SIZE + ] + assert large_nested_struct.nested.a == 10 + assert large_nested_struct.nested.b == 11 + assert large_nested_struct.nested.inner.arr_arr[1][1] == 12 + assert large_nested_struct.nested.inner.c == 13 + assert large_nested_struct.nested.inner.d == 14 + assert large_nested_struct.nested.woah[1].arr_arr[1][1] == 15 + + +def test_too_many_bools() -> None: + with algopy_testing_context(): + contract = BoxContract() + + contract.create_bools() + + contract.set_bool(index=UInt64(0), value=True) + contract.set_bool(index=UInt64(42), value=True) + contract.set_bool(index=UInt64(500), value=True) + contract.set_bool(index=UInt64(32_999), value=True) + + total = contract.sum_bools(stop_at_total=UInt64(3)) + expected_sum = 3 + assert total == expected_sum, f"expected sum to be {expected_sum}" + + box_response = contract.too_many_bools.value + assert box_response[0] + assert box_response[42] + assert box_response[500] + assert box_response[32_999] + + too_many_bools = [False] * 33_000 + too_many_bools[0] = True + too_many_bools[42] = True + too_many_bools[500] = True + too_many_bools[32_999] = True + # encode bools into bytes (as SDK is too slow) + expected_bytes = sum( + val << shift for shift, val in enumerate(reversed(too_many_bools)) + ).to_bytes(length=33_000 // 8) + + assert box_response._value == expected_bytes, "expected box contents to be correct" diff --git a/tests/models/test_box_map.py b/tests/models/test_box_map.py index 338e7f7..482e3f2 100644 --- a/tests/models/test_box_map.py +++ b/tests/models/test_box_map.py @@ -13,6 +13,8 @@ from _algopy_testing.state.utils import cast_to_bytes from _algopy_testing.utils import as_bytes, as_string +from tests.artifacts.BoxContract.contract import BoxContract + BOX_NOT_CREATED_ERROR = "Box has not been created" @@ -254,6 +256,29 @@ def test_maybe_when_box_does_not_exists( assert not op_box_content +def test_box_map() -> None: + with algopy_testing_context(): + contract = BoxContract() + + contract.box_map_test() + + key = UInt64(2) + + assert not contract.box_map_exists(key=key), "Box does not exist (yet)" + + contract.box_map_set( + key=key, + value=String("Hello 123"), + ) + assert contract.box_map_get(key=key) == "Hello 123", "Box value is what was set" + + assert contract.box_map_exists(key=key), "Box exists" + + contract.box_map_del(key=key) + + assert not contract.box_map_exists(key=key), "Box does not exist after deletion" + + def _assert_box_content_equality( expected_value: typing.Any, box_content: typing.Any, op_box_content: Bytes ) -> None: diff --git a/tests/models/test_box_ref.py b/tests/models/test_box_ref.py index 7bdcd95..0eff31d 100644 --- a/tests/models/test_box_ref.py +++ b/tests/models/test_box_ref.py @@ -7,16 +7,18 @@ from _algopy_testing.context import AlgopyTestContext from _algopy_testing.primitives.bytes import Bytes from _algopy_testing.primitives.string import String -from _algopy_testing.state.box import BoxRef +from _algopy_testing.state.box import Box from _algopy_testing.utils import as_bytes, as_string +from tests.artifacts.BoxContract.contract import BoxContract + TEST_BOX_KEY = b"test_key" BOX_NOT_CREATED_ERROR = "Box has not been created" class ATestContract(algopy.ARC4Contract): def __init__(self) -> None: - self.uint_64_box_ref = algopy.BoxRef() + self.uint_64_box_ref = Box(Bytes) @pytest.fixture() @@ -45,7 +47,7 @@ def test_init_with_key( context: AlgopyTestContext, # noqa: ARG001 key: bytes | str | Bytes | String, ) -> None: - box = BoxRef(key=key) + box = Box(Bytes, key=key) assert not box assert len(box.key) > 0 @@ -71,7 +73,7 @@ def test_create( context: AlgopyTestContext, # noqa: ARG001 size: int, ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=size) @@ -83,7 +85,7 @@ def test_create( def test_create_overflow( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) with pytest.raises(ValueError, match=f"Box size cannot exceed {MAX_BOX_SIZE}"): box.create(size=MAX_BOX_SIZE + 1) @@ -92,13 +94,13 @@ def test_create_overflow( def test_delete( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=MAX_BOX_SIZE) assert box.length == MAX_BOX_SIZE - box_existed = box.delete() - assert box_existed + assert box + del box.value assert not box with pytest.raises(RuntimeError, match=BOX_NOT_CREATED_ERROR): @@ -110,7 +112,7 @@ def test_delete( with pytest.raises(RuntimeError, match=BOX_NOT_CREATED_ERROR): box.replace(0, b"\x11") - assert not box.delete() + assert not box @pytest.mark.parametrize( @@ -127,7 +129,7 @@ def test_resize_to_smaller( size: int, new_size: int, ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=size) _initialise_box_value(box, b"\x11" * size) @@ -151,7 +153,7 @@ def test_resize_to_bigger( size: int, new_size: int, ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=size) _initialise_box_value(box, b"\x11" * size) @@ -165,7 +167,7 @@ def test_resize_to_bigger( def test_resize_overflow( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=10) @@ -176,7 +178,7 @@ def test_resize_overflow( def test_replace_extract( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=MAX_BOX_SIZE) box_value = b"\x01\x02" * int(MAX_BOX_SIZE / 2) @@ -189,7 +191,7 @@ def test_replace_extract( def test_replace_when_box_does_not_exists( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) with pytest.raises(RuntimeError, match=BOX_NOT_CREATED_ERROR): box.replace(0, b"\x11") @@ -208,7 +210,7 @@ def test_replace_overflow( start: int, replacement: bytes, ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=10) @@ -221,10 +223,10 @@ def test_maybe( ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) box.create(size=10) box_value = b"\x01\x02" * 5 - box.put(box_value) + box.value = Bytes(box_value) box_content, box_exists = box.maybe() @@ -241,11 +243,11 @@ def test_maybe_when_box_does_not_exist( ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) box.create(size=10) box_value = b"\x01\x02" * 5 - box.put(box_value) - box.delete() + box.value = Bytes(box_value) + del box.value box_content, box_exists = box.maybe() assert not box_content @@ -271,12 +273,12 @@ def test_put_get( ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) box.create(size=size) - box.put(value) + box.value = Bytes(value) - box_content = box.get(default=b"\x00" * size) + box_content = box.get(default=Bytes(b"\x00" * size)) assert box_content == Bytes(value) op_box_content, op_box_exists = algopy.op.Box.get(key) @@ -289,11 +291,11 @@ def test_put_when_box_does_not_exist( ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) box_value = b"\x01\x02" * 5 - box.put(box_value) + box.value = Bytes(box_value) - box_content = box.get(default=b"\x00" * 10) + box_content = box.get(default=Bytes(b"\x00" * 10)) assert box_content == Bytes(box_value) @@ -301,11 +303,11 @@ def test_get_when_box_does_not_exist( context: AlgopyTestContext, # noqa: ARG001 ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) - default_value = b"\x00" * 10 + default_value = Bytes(b"\x00" * 10) box_content = box.get(default=default_value) - assert box_content == Bytes(default_value) + assert box_content == default_value def test_put_get_overflow( @@ -313,27 +315,27 @@ def test_put_get_overflow( ) -> None: key = b"test_key" - box = BoxRef(key=key) + box = Box(Bytes, key=key) box.create(size=MAX_BOX_SIZE) with pytest.raises(ValueError, match=f"expected value length <= {MAX_BYTES_SIZE}"): - box.put(b"\x11" * MAX_BOX_SIZE) + box.value = Bytes(b"\x11" * MAX_BOX_SIZE) with pytest.raises(ValueError, match=f"expected value length <= {MAX_BYTES_SIZE}"): - box.get(default=b"\x00" * MAX_BOX_SIZE) + box.get(default=Bytes(b"\x00" * MAX_BOX_SIZE)) def test_splice_when_new_value_is_longer( context: AlgopyTestContext, # noqa: ARG001 ) -> None: size = 10 - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=size) box_value = b"\x01\x02" * 5 replacement_value = b"\x11" * 2 - box.put(box_value) + box.value = Bytes(box_value) box.splice(1, 1, replacement_value) - box_content = box.get(default=b"\x00" * size) + box_content = box.get(default=Bytes(b"\x00" * size)) op_box_key = b"another_key" algopy.op.Box.create(op_box_key, size) @@ -351,14 +353,14 @@ def test_splice_when_new_value_is_shorter( context: AlgopyTestContext, # noqa: ARG001 ) -> None: size = 10 - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=size) box_value = b"\x01\x02" * 5 replacement_value = b"\x11" * 2 - box.put(box_value) + box.value = Bytes(box_value) box.splice(1, 5, replacement_value) - box_content = box.get(default=b"\x00" * size) + box_content = box.get(default=Bytes(b"\x00" * size)) op_box_key = b"another_key" algopy.op.Box.create(op_box_key, size) @@ -375,7 +377,7 @@ def test_splice_when_new_value_is_shorter( def test_splice_when_box_does_not_exist( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) with pytest.raises(RuntimeError, match=BOX_NOT_CREATED_ERROR): box.splice(0, 1, b"\x11") @@ -384,14 +386,23 @@ def test_splice_when_box_does_not_exist( def test_splice_out_of_bounds( context: AlgopyTestContext, # noqa: ARG001 ) -> None: - box = BoxRef(key=TEST_BOX_KEY) + box = Box(Bytes, key=TEST_BOX_KEY) box.create(size=10) with pytest.raises(ValueError, match="Start index exceeds box size"): box.splice(11, 1, b"\x11") -def _initialise_box_value(box: BoxRef, value: bytes) -> None: +def test_box_ref() -> None: + # Arrange + with algopy_testing_context(): + contract = BoxContract() + + # Act + contract.test_box_ref() + + +def _initialise_box_value(box: Box[Bytes], value: bytes) -> None: index = 0 size = len(value) while index < size: @@ -400,7 +411,7 @@ def _initialise_box_value(box: BoxRef, value: bytes) -> None: index += length -def _assert_box_value(box: BoxRef, expected_value: bytes, start: int = 0) -> None: +def _assert_box_value(box: Box[Bytes], expected_value: bytes, start: int = 0) -> None: index = start size = len(expected_value) while index < size: