Skip to content

Commit 1be2b17

Browse files
committed
feat: make BoxRef methods directly accessible on Box class
1 parent c219098 commit 1be2b17

File tree

11 files changed

+666
-72
lines changed

11 files changed

+666
-72
lines changed

examples/proof_of_attendance/contract.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@ def confirm_attendance_with_box_ref(self) -> None:
4545
minted_asset = self._mint_poa(algopy.Txn.sender)
4646
self.total_attendees += 1
4747

48-
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
48+
box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes)
4949
has_claimed = bool(box_ref)
5050
assert not has_claimed, "Already claimed POA"
5151

52-
box_ref.put(algopy.op.itob(minted_asset.id))
52+
box_ref.value = algopy.op.itob(minted_asset.id)
5353

5454
@algopy.arc4.abimethod()
5555
def confirm_attendance_with_box_map(self) -> None:
@@ -78,7 +78,7 @@ def get_poa_id_with_box(self) -> algopy.UInt64:
7878

7979
@algopy.arc4.abimethod(readonly=True)
8080
def get_poa_id_with_box_ref(self) -> algopy.UInt64:
81-
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
81+
box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes)
8282
poa_id, exists = box_ref.maybe()
8383
assert exists, "POA not found"
8484
return algopy.op.btoi(poa_id)
@@ -130,7 +130,7 @@ def claim_poa_with_box(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -
130130

131131
@algopy.arc4.abimethod()
132132
def claim_poa_with_box_ref(self, opt_in_txn: algopy.gtxn.AssetTransferTransaction) -> None:
133-
box_ref = algopy.BoxRef(key=algopy.Txn.sender.bytes)
133+
box_ref = algopy.Box(algopy.Bytes, key=algopy.Txn.sender.bytes)
134134
poa_id, exists = box_ref.maybe()
135135
assert exists, "POA not found, attendance validation failed!"
136136
assert opt_in_txn.xfer_asset.id == algopy.op.btoi(poa_id), "POA ID mismatch"

pyproject.toml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ dependencies = [
3232
"coincurve>=19.0.1",
3333
# TODO: uncomment below and remove direct git reference once puya 5.0 is released
3434
# "algorand-python>=3",
35-
"algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7#subdirectory=stubs",
35+
"algorand-python@git+https://github.com/algorandfoundation/puya.git@feat/replace-box-ref#subdirectory=stubs",
3636
]
3737

3838
[project.urls]
@@ -54,7 +54,7 @@ python = "3.12"
5454
dependencies = [
5555
# TODO: uncomment below and remove direct git reference once puya 5.0 is released
5656
# "puyapy>=5",
57-
"puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7",
57+
"puyapy@git+https://github.com/algorandfoundation/puya.git@feat/replace-box-ref",
5858
"pytest>=7.4",
5959
"pytest-mock>=3.10.0",
6060
"pytest-xdist[psutil]>=3.3",
@@ -138,7 +138,7 @@ dependencies = [
138138
"algokit-utils>=3.0.0",
139139
# TODO: uncomment below and remove direct git reference once puya 5.0 is released
140140
# "puyapy>=5",
141-
"puyapy@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7",
141+
"puyapy@git+https://github.com/algorandfoundation/puya.git@feat/replace-box-ref",
142142
]
143143

144144
[tool.hatch.envs.test.scripts]
@@ -191,7 +191,7 @@ post-install-commands = [
191191
dependencies = [
192192
# TODO: uncomment below and remove direct git reference once puya 5.0 is released
193193
# "algorand-python>=3",
194-
"algorand-python@git+https://github.com/algorandfoundation/puya.git@v5.0.0-rc.7#subdirectory=stubs",
194+
"algorand-python@git+https://github.com/algorandfoundation/puya.git@feat/replace-box-ref#subdirectory=stubs",
195195
"pytest>=7.4",
196196
"pytest-mock>=3.10.0",
197197
"pytest-xdist[psutil]>=3.3",

src/_algopy_testing/arc4.py

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ class _ABIEncoded(BytesBacked):
140140
def from_bytes(cls, value: algopy.Bytes | bytes, /) -> typing.Self:
141141
"""Construct an instance from the underlying bytes (no validation)"""
142142
instance = cls()
143-
instance._value = as_bytes(value)
143+
instance._value = value.value if isinstance(value, Bytes) else value
144144
return instance
145145

146146
@classmethod
@@ -556,11 +556,11 @@ class Bool(_ABIEncoded):
556556
_value: bytes
557557

558558
# True value is encoded as having a 1 on the most significant bit (0x80 = 128)
559-
_true_int_value = 128
560-
_false_int_value = 0
559+
_true_byte_value = int_to_bytes(128, 1)
560+
_false_byte_value = int_to_bytes(0, 1)
561561

562562
def __init__(self, value: bool = False, /) -> None: # noqa: FBT001, FBT002
563-
self._value = int_to_bytes(self._true_int_value if value else self._false_int_value, 1)
563+
self._value = self._true_byte_value if value else self._false_byte_value
564564

565565
def __eq__(self, other: object) -> bool:
566566
try:
@@ -576,8 +576,7 @@ def __bool__(self) -> bool:
576576
@property
577577
def native(self) -> bool:
578578
"""Return the bool representation of the value after ARC4 decoding."""
579-
int_value = int.from_bytes(self._value)
580-
return int_value == self._true_int_value
579+
return self._value == self._true_byte_value
581580

582581
def __str__(self) -> str:
583582
return f"{self.native}"
@@ -669,7 +668,8 @@ def __init__(self, *_items: _TArrayItem):
669668
f"item must be of type {self._type_info.item_type!r}, not {item._type_info!r}"
670669
)
671670

672-
self._value = _encode(items)
671+
item_list = list(items)
672+
self._value = _encode(item_list)
673673

674674
def __iter__(self) -> Iterator[_TArrayItem]:
675675
# """Returns an iterator for the items in the array"""
@@ -854,7 +854,8 @@ def __init__(self, *_items: _TArrayItem):
854854
raise TypeError(
855855
f"item must be of type {self._type_info.item_type!r}, not {item._type_info!r}"
856856
)
857-
self._value = self._encode_with_length(items)
857+
item_list = list(items)
858+
self._value = self._encode_with_length(item_list)
858859

859860
def __iter__(self) -> typing.Iterator[_TArrayItem]:
860861
"""Returns an iterator for the items in the array."""
@@ -1294,6 +1295,9 @@ def _find_bool(
12941295
is_looking_forward = delta > 0
12951296
is_looking_backward = delta < 0
12961297
values_length = len(values) if isinstance(values, tuple | list) else values.length.value
1298+
if isinstance(values, (StaticArray | DynamicArray | list)):
1299+
return 0 if is_looking_backward else values_length - index - 1
1300+
12971301
while True:
12981302
curr = index + delta * until
12991303
is_curr_at_end = curr == values_length - 1
@@ -1311,12 +1315,16 @@ def _find_bool(
13111315
return until
13121316

13131317

1314-
def _find_bool_types(values: typing.Sequence[_TypeInfo], index: int, delta: int) -> int:
1318+
def _find_bool_types(
1319+
values: typing.Sequence[_TypeInfo], index: int, delta: int, *, is_homogeneous: bool = False
1320+
) -> int:
13151321
"""Helper function to find consecutive booleans from current index in a tuple."""
13161322
until = 0
13171323
is_looking_forward = delta > 0
13181324
is_looking_backward = delta < 0
13191325
values_length = len(values)
1326+
if is_homogeneous:
1327+
return 0 if is_looking_backward else values_length - index - 1
13201328
while True:
13211329
curr = index + delta * until
13221330
is_curr_at_end = curr == values_length - 1
@@ -1438,6 +1446,7 @@ def _encode( # noqa: PLR0912
14381446
raise ValueError(
14391447
"expected before index should have number of bool mod 8 equal 0"
14401448
)
1449+
14411450
after = min(7, after)
14421451
consecutive_bool_list = [values[i] for i in range(i, i + after + 1)]
14431452
compressed_int = _compress_multiple_bool(consecutive_bool_list)
@@ -1467,14 +1476,16 @@ def _encode( # noqa: PLR0912
14671476
return values_length_bytes + b"".join(heads) + b"".join(tails)
14681477

14691478

1470-
def _decode_tuple_items( # noqa: PLR0912
1479+
def _decode_tuple_items( # noqa: PLR0912, PLR0915
14711480
value: bytes, child_types: list[_TypeInfo]
14721481
) -> list[typing.Any]:
14731482
dynamic_segments: list[list[int]] = [] # Store the start and end of a dynamic element
14741483
value_partitions: list[bytes] = []
14751484
i = 0
14761485
array_index = 0
14771486

1487+
is_homogeneous_child_types = len(set(child_types)) == 1
1488+
14781489
while i < len(child_types):
14791490
child_type = child_types[i]
14801491
if child_type.is_dynamic:
@@ -1489,8 +1500,12 @@ def _decode_tuple_items( # noqa: PLR0912
14891500
value_partitions.append(b"")
14901501
array_index += _ABI_LENGTH_SIZE
14911502
elif isinstance(child_type, _BoolTypeInfo):
1492-
before = _find_bool_types(child_types, i, -1)
1493-
after = _find_bool_types(child_types, i, 1)
1503+
before = _find_bool_types(
1504+
child_types, index=i, delta=-1, is_homogeneous=is_homogeneous_child_types
1505+
)
1506+
after = _find_bool_types(
1507+
child_types, index=i, delta=1, is_homogeneous=is_homogeneous_child_types
1508+
)
14941509

14951510
if before % 8 != 0:
14961511
raise ValueError("expected before index should have number of bool mod 8 equal 0")

src/_algopy_testing/primitives/array.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def _from_iter(self, items: Iterable[_TArrayItem]) -> "FixedArray[_TArrayItem, _
246246
return typ(items)
247247

248248
def serialize(self) -> bytes:
249-
return serialize_to_bytes(self)
249+
return self._value
250250

251251
@classmethod
252252
def from_bytes(cls, value: bytes, /) -> typing.Self:
@@ -524,6 +524,16 @@ def __getattribute__(self, name: str) -> typing.Any:
524524
value = super().__getattribute__(name)
525525
return add_mutable_callback(lambda _: self._update_backing_value(), value)
526526

527+
def __setattr__(self, key: str, value: typing.Any) -> None:
528+
super().__setattr__(key, value)
529+
# don't update backing value until base class has been init'd
530+
if hasattr(self, "_on_mutate") and key not in {
531+
"_MutableBytes__value",
532+
"_on_mutate",
533+
"_value",
534+
}:
535+
self._update_backing_value()
536+
527537
def copy(self) -> typing.Self:
528538
return self.__class__.from_bytes(self.serialize())
529539

src/_algopy_testing/primitives/bytes.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from _algopy_testing.constants import MAX_BYTES_SIZE
1414
from _algopy_testing.primitives.uint64 import UInt64
15-
from _algopy_testing.utils import as_bytes, as_int64, check_type
15+
from _algopy_testing.utils import as_bytes, check_type
1616

1717
# TypeError, ValueError are used for operations that are compile time errors
1818
# ArithmeticError and subclasses are used for operations that would fail during AVM execution
@@ -74,7 +74,8 @@ def __getitem__(
7474
if isinstance(index, slice):
7575
return Bytes(self.value[index])
7676
else:
77-
int_index = as_int64(index)
77+
int_index = index.value if isinstance(index, UInt64) else index
78+
int_index = len(self.value) + int_index if int_index < 0 else int_index
7879
# my_bytes[0:1] => b'j' whereas my_bytes[0] => 106
7980
return Bytes(self.value[slice(int_index, int_index + 1)])
8081

src/_algopy_testing/serialize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class TempStruct(arc4.Struct):
174174

175175

176176
def serialize_to_bytes(value: object) -> bytes:
177-
return native_to_arc4(value).bytes.value
177+
return native_to_arc4(value)._value
178178

179179

180180
def type_of(value: object) -> type:

src/_algopy_testing/state/box.py

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import typing
44
import warnings
55

6+
from typing_extensions import deprecated
7+
68
import _algopy_testing
79
from _algopy_testing.constants import MAX_BOX_SIZE
810
from _algopy_testing.context_helpers import lazy_context
@@ -100,6 +102,61 @@ def value(self, value: _TValue) -> None:
100102
def value(self) -> None:
101103
lazy_context.ledger.delete_box(self.app_id, self.key)
102104

105+
@property
106+
@deprecated("Box methods previously accessed via `.ref` are now directly available")
107+
def ref(self) -> BoxRef:
108+
return BoxRef(key=self.key)
109+
110+
def extract(
111+
self, start_index: algopy.UInt64 | int, length: algopy.UInt64 | int
112+
) -> algopy.Bytes:
113+
"""Extract a slice of bytes from the box.
114+
115+
Fails if the box does not exist, or if `start_index + length > len(box)`
116+
117+
:arg start_index: The offset to start extracting bytes from
118+
:arg length: The number of bytes to extract
119+
:return: The extracted bytes
120+
"""
121+
return _BoxRef(key=self.key).extract(start_index, length)
122+
123+
def resize(self, new_size: algopy.UInt64 | int) -> None:
124+
"""Resizes the box the specified `new_size`. Truncating existing data if the new
125+
value is shorter or padding with zero bytes if it is longer.
126+
127+
:arg new_size: The new size of the box
128+
"""
129+
return _BoxRef(key=self.key).resize(new_size)
130+
131+
def replace(self, start_index: algopy.UInt64 | int, value: algopy.Bytes | bytes) -> None:
132+
"""Write `value` to the box starting at `start_index`. Fails if the box does not
133+
exist, or if `start_index + len(value) > len(box)`
134+
135+
:arg start_index: The offset to start writing bytes from
136+
:arg value: The bytes to be written
137+
"""
138+
return _BoxRef(key=self.key).replace(start_index, value)
139+
140+
def splice(
141+
self,
142+
start_index: algopy.UInt64 | int,
143+
length: algopy.UInt64 | int,
144+
value: algopy.Bytes | bytes,
145+
) -> None:
146+
"""Set box to contain its previous bytes up to index `start_index`, followed by
147+
`bytes`, followed by the original bytes of the box that began at index
148+
`start_index + length`
149+
150+
**Important: This op does not resize the box**
151+
If the new value is longer than the box size, it will be truncated.
152+
If the new value is shorter than the box size, it will be padded with zero bytes
153+
154+
:arg start_index: The index to start inserting `value`
155+
:arg length: The number of bytes after `start_index` to omit from the new value
156+
:arg value: The `value` to be inserted.
157+
"""
158+
return _BoxRef(key=self.key).splice(start_index, length, value)
159+
103160
def get(self, *, default: _TValue) -> _TValue:
104161
box_content, box_exists = self.maybe()
105162
return default if not box_exists else box_content
@@ -117,7 +174,7 @@ def length(self) -> algopy.UInt64:
117174
return _algopy_testing.UInt64(len(lazy_context.ledger.get_box(self.app_id, self.key)))
118175

119176

120-
class BoxRef:
177+
class _BoxRef:
121178
"""BoxRef abstracts the reading and writing of boxes containing raw binary data.
122179
123180
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:
186243
lazy_context.ledger.set_box(self.app_id, self.key, updated_content)
187244

188245
def replace(self, start_index: algopy.UInt64 | int, value: algopy.Bytes | bytes) -> None:
246+
replace_content = value.value if isinstance(value, _algopy_testing.Bytes) else value
189247
box_content, box_exists = self._maybe()
190248
if not box_exists:
191249
raise RuntimeError("Box has not been created")
192250
start = int(start_index)
193-
length = len(value)
251+
length = len(replace_content)
194252
if (start + length) > len(box_content):
195253
raise ValueError("Replacement content exceeds box size")
196-
updated_content = box_content[:start] + value + box_content[start + length :]
254+
updated_content = box_content[:start] + replace_content + box_content[start + length :]
197255
lazy_context.ledger.set_box(self.app_id, self.key, updated_content)
198256

199257
def splice(
@@ -260,6 +318,11 @@ def length(self) -> algopy.UInt64:
260318
return _algopy_testing.UInt64(len(box_content))
261319

262320

321+
@deprecated("Methods in BoxRef are now directly available on Box")
322+
class BoxRef(_BoxRef):
323+
pass
324+
325+
263326
class BoxMap(typing.Generic[_TKey, _TValue]):
264327
"""BoxMap abstracts the reading and writing of a set of boxes using a common key and
265328
content type.
@@ -338,3 +401,8 @@ def length(self, key: _TKey) -> algopy.UInt64:
338401

339402
def _full_key(self, key: _TKey) -> algopy.Bytes:
340403
return self.key_prefix + cast_to_bytes(key)
404+
405+
def box(self, key: _TKey) -> Box[_TValue]:
406+
"""Returns a Box holding the box value at key."""
407+
key_bytes = self._full_key(key)
408+
return Box(self._value_type, key=key_bytes)

0 commit comments

Comments
 (0)