algorand-python-testing 0.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. algopy/__init__.py +58 -0
  2. algopy/arc4.py +1 -0
  3. algopy/gtxn.py +1 -0
  4. algopy/itxn.py +1 -0
  5. algopy/op.py +1 -0
  6. algopy/py.typed +0 -0
  7. algopy_testing/__init__.py +55 -0
  8. algopy_testing/arc4.py +1533 -0
  9. algopy_testing/constants.py +22 -0
  10. algopy_testing/context.py +1194 -0
  11. algopy_testing/decorators/__init__.py +0 -0
  12. algopy_testing/decorators/abimethod.py +204 -0
  13. algopy_testing/decorators/baremethod.py +83 -0
  14. algopy_testing/decorators/subroutine.py +9 -0
  15. algopy_testing/enums.py +42 -0
  16. algopy_testing/gtxn.py +261 -0
  17. algopy_testing/itxn.py +665 -0
  18. algopy_testing/models/__init__.py +31 -0
  19. algopy_testing/models/account.py +128 -0
  20. algopy_testing/models/application.py +72 -0
  21. algopy_testing/models/asset.py +109 -0
  22. algopy_testing/models/block.py +34 -0
  23. algopy_testing/models/box.py +158 -0
  24. algopy_testing/models/contract.py +82 -0
  25. algopy_testing/models/gitxn.py +42 -0
  26. algopy_testing/models/global_values.py +72 -0
  27. algopy_testing/models/gtxn.py +56 -0
  28. algopy_testing/models/itxn.py +85 -0
  29. algopy_testing/models/logicsig.py +44 -0
  30. algopy_testing/models/template_variable.py +23 -0
  31. algopy_testing/models/transactions.py +158 -0
  32. algopy_testing/models/txn.py +113 -0
  33. algopy_testing/models/unsigned_builtins.py +36 -0
  34. algopy_testing/op.py +1098 -0
  35. algopy_testing/primitives/__init__.py +6 -0
  36. algopy_testing/primitives/biguint.py +148 -0
  37. algopy_testing/primitives/bytes.py +174 -0
  38. algopy_testing/primitives/string.py +68 -0
  39. algopy_testing/primitives/uint64.py +213 -0
  40. algopy_testing/protocols.py +18 -0
  41. algopy_testing/py.typed +0 -0
  42. algopy_testing/state/__init__.py +4 -0
  43. algopy_testing/state/global_state.py +73 -0
  44. algopy_testing/state/local_state.py +54 -0
  45. algopy_testing/utilities/__init__.py +3 -0
  46. algopy_testing/utilities/budget.py +23 -0
  47. algopy_testing/utilities/log.py +55 -0
  48. algopy_testing/utils.py +249 -0
  49. algorand_python_testing-0.0.0b1.dist-info/METADATA +81 -0
  50. algorand_python_testing-0.0.0b1.dist-info/RECORD +52 -0
  51. algorand_python_testing-0.0.0b1.dist-info/WHEEL +4 -0
  52. algorand_python_testing-0.0.0b1.dist-info/licenses/LICENSE +14 -0
@@ -0,0 +1,1194 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import string
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass
8
+
9
+ # Define the union type
10
+ from typing import TYPE_CHECKING, Any, TypeVar, Unpack, cast, overload
11
+
12
+ import algosdk
13
+
14
+ from algopy_testing.constants import (
15
+ ARC4_RETURN_PREFIX,
16
+ DEFAULT_ACCOUNT_MIN_BALANCE,
17
+ DEFAULT_ASSET_CREATE_MIN_BALANCE,
18
+ DEFAULT_ASSET_OPT_IN_MIN_BALANCE,
19
+ DEFAULT_GLOBAL_GENESIS_HASH,
20
+ DEFAULT_MAX_TXN_LIFE,
21
+ MAX_BYTES_SIZE,
22
+ MAX_UINT64,
23
+ )
24
+ from algopy_testing.gtxn import NULL_GTXN_GROUP_INDEX
25
+ from algopy_testing.models.account import AccountFields
26
+ from algopy_testing.models.application import ApplicationFields
27
+ from algopy_testing.models.asset import AssetFields
28
+ from algopy_testing.models.global_values import GlobalFields
29
+ from algopy_testing.models.txn import TxnFields
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Callable, Generator, Sequence
33
+
34
+ import algopy
35
+
36
+ from algopy_testing.gtxn import (
37
+ AssetTransferFields,
38
+ PaymentFields,
39
+ TransactionFields,
40
+ )
41
+ from algopy_testing.models.transactions import (
42
+ AssetConfigFields,
43
+ AssetFreezeFields,
44
+ KeyRegistrationFields,
45
+ _ApplicationCallFields,
46
+ )
47
+
48
+ InnerTransactionResultType = (
49
+ algopy.itxn.InnerTransactionResult
50
+ | algopy.itxn.PaymentInnerTransaction
51
+ | algopy.itxn.KeyRegistrationInnerTransaction
52
+ | algopy.itxn.AssetConfigInnerTransaction
53
+ | algopy.itxn.AssetTransferInnerTransaction
54
+ | algopy.itxn.AssetFreezeInnerTransaction
55
+ | algopy.itxn.ApplicationCallInnerTransaction
56
+ )
57
+
58
+
59
+ T = TypeVar("T")
60
+
61
+
62
+ @dataclass
63
+ class AccountContextData:
64
+ """
65
+ Stores account-related information.
66
+
67
+ Attributes:
68
+ opted_asset_balances (dict[int, algopy.UInt64]): Mapping of asset IDs to balances.
69
+ opted_apps (dict[int, algopy.UInt64]): Mapping of application IDs to instances.
70
+ fields (AccountFields): Additional account fields.
71
+ """
72
+
73
+ opted_asset_balances: dict[algopy.UInt64, algopy.UInt64]
74
+ opted_apps: dict[algopy.UInt64, algopy.Application]
75
+ fields: AccountFields
76
+
77
+
78
+ @dataclass
79
+ class ContractContextData:
80
+ """
81
+ Stores contract-related information.
82
+
83
+ Attributes:
84
+ contract (algopy.Contract | algopy.ARC4Contract): Contract instance.
85
+ app_id (algopy.UInt64): Application ID.
86
+ """
87
+
88
+ contract: algopy.Contract | algopy.ARC4Contract
89
+ app_id: algopy.UInt64
90
+
91
+
92
+ class ITxnLoader:
93
+ """
94
+ Stores inner transaction references.
95
+ """
96
+
97
+ def __init__(self, inner_txn: InnerTransactionResultType):
98
+ self._inner_txn = inner_txn
99
+
100
+ def _get_itxn(self, txn_type: type[T]) -> T:
101
+ txn = self._inner_txn
102
+
103
+ if not isinstance(txn, txn_type):
104
+ raise TypeError(f"Last transaction is not of type {txn_type.__name__}!")
105
+
106
+ return txn
107
+
108
+ @property
109
+ def payment(self) -> algopy.itxn.PaymentInnerTransaction:
110
+ """
111
+ Retrieve the last PaymentInnerTransaction.
112
+
113
+ Raises:
114
+ ValueError: If the transaction is not found or not of the expected type.
115
+ """
116
+ import algopy
117
+
118
+ return self._get_itxn(algopy.itxn.PaymentInnerTransaction)
119
+
120
+ @property
121
+ def asset_config(self) -> algopy.itxn.AssetConfigInnerTransaction:
122
+ """
123
+ Retrieve the last AssetConfigInnerTransaction.
124
+
125
+ Raises:
126
+ ValueError: If the transaction is not found or not of the expected type.
127
+ """
128
+ import algopy
129
+
130
+ return self._get_itxn(algopy.itxn.AssetConfigInnerTransaction)
131
+
132
+ @property
133
+ def asset_transfer(self) -> algopy.itxn.AssetTransferInnerTransaction:
134
+ """
135
+ Retrieve the last AssetTransferInnerTransaction.
136
+
137
+ Raises:
138
+ ValueError: If the transaction is not found or not of the expected type.
139
+ """
140
+ import algopy
141
+
142
+ return self._get_itxn(algopy.itxn.AssetTransferInnerTransaction)
143
+
144
+ @property
145
+ def asset_freeze(self) -> algopy.itxn.AssetFreezeInnerTransaction:
146
+ """
147
+ Retrieve the last AssetFreezeInnerTransaction.
148
+
149
+ Raises:
150
+ ValueError: If the transaction is not found or not of the expected type.
151
+ """
152
+ import algopy
153
+
154
+ return self._get_itxn(algopy.itxn.AssetFreezeInnerTransaction)
155
+
156
+ @property
157
+ def application_call(self) -> algopy.itxn.ApplicationCallInnerTransaction:
158
+ """
159
+ Retrieve the last ApplicationCallInnerTransaction.
160
+
161
+ Raises:
162
+ ValueError: If the transaction is not found or not of the expected type.
163
+ """
164
+ import algopy
165
+
166
+ return self._get_itxn(algopy.itxn.ApplicationCallInnerTransaction)
167
+
168
+ @property
169
+ def key_registration(self) -> algopy.itxn.KeyRegistrationInnerTransaction:
170
+ """
171
+ Retrieve the last KeyRegistrationInnerTransaction.
172
+
173
+ Raises:
174
+ ValueError: If the transaction is not found or not of the expected type.
175
+ """
176
+ import algopy
177
+
178
+ return self._get_itxn(algopy.itxn.KeyRegistrationInnerTransaction)
179
+
180
+ @property
181
+ def transaction(self) -> algopy.itxn.InnerTransactionResult:
182
+ """
183
+ Retrieve the last InnerTransactionResult.
184
+
185
+ Raises:
186
+ ValueError: If the transaction is not found or not of the expected type.
187
+ """
188
+ import algopy
189
+
190
+ return self._get_itxn(algopy.itxn.InnerTransactionResult)
191
+
192
+
193
+ class ITxnGroupLoader:
194
+ @overload
195
+ def __getitem__(self, index: int) -> ITxnLoader: ...
196
+
197
+ @overload
198
+ def __getitem__(self, index: slice) -> list[ITxnLoader]: ...
199
+
200
+ def __getitem__(self, index: int | slice) -> ITxnLoader | list[ITxnLoader]:
201
+ if isinstance(index, int):
202
+ return ITxnLoader(self._inner_txn_group[index])
203
+ elif isinstance(index, slice):
204
+ return [ITxnLoader(self._inner_txn_group[i]) for i in range(*index.indices(len(self)))]
205
+ else:
206
+ raise TypeError("Index must be int or slice")
207
+
208
+ def __len__(self) -> int:
209
+ return len(self._inner_txn_group)
210
+
211
+ def __init__(self, inner_txn_group: Sequence[InnerTransactionResultType]):
212
+ self._inner_txn_group = inner_txn_group
213
+
214
+ def _get_itxn(self, index: int, txn_type: type[T]) -> T:
215
+ try:
216
+ txn = self._inner_txn_group[index]
217
+ except IndexError as err:
218
+ raise ValueError(f"No inner transaction available at index {index}!") from err
219
+
220
+ if not isinstance(txn, txn_type):
221
+ raise TypeError(f"Last transaction is not of type {txn_type.__name__}!")
222
+
223
+ return txn
224
+
225
+ def payment(self, index: int) -> algopy.itxn.PaymentInnerTransaction:
226
+ import algopy
227
+
228
+ return ITxnLoader(self._get_itxn(index, algopy.itxn.PaymentInnerTransaction)).payment
229
+
230
+ def asset_config(self, index: int) -> algopy.itxn.AssetConfigInnerTransaction:
231
+ import algopy
232
+
233
+ return self._get_itxn(index, algopy.itxn.AssetConfigInnerTransaction)
234
+
235
+ def asset_transfer(self, index: int) -> algopy.itxn.AssetTransferInnerTransaction:
236
+ import algopy
237
+
238
+ return self._get_itxn(index, algopy.itxn.AssetTransferInnerTransaction)
239
+
240
+ def asset_freeze(self, index: int) -> algopy.itxn.AssetFreezeInnerTransaction:
241
+ import algopy
242
+
243
+ return self._get_itxn(index, algopy.itxn.AssetFreezeInnerTransaction)
244
+
245
+ def application_call(self, index: int) -> algopy.itxn.ApplicationCallInnerTransaction:
246
+ import algopy
247
+
248
+ return self._get_itxn(index, algopy.itxn.ApplicationCallInnerTransaction)
249
+
250
+ def key_registration(self, index: int) -> algopy.itxn.KeyRegistrationInnerTransaction:
251
+ import algopy
252
+
253
+ return self._get_itxn(index, algopy.itxn.KeyRegistrationInnerTransaction)
254
+
255
+ def transaction(self, index: int) -> algopy.itxn.InnerTransactionResult:
256
+ import algopy
257
+
258
+ return self._get_itxn(index, algopy.itxn.InnerTransactionResult)
259
+
260
+
261
+ @dataclass
262
+ class AlgopyTestContext:
263
+ def __init__(
264
+ self,
265
+ *,
266
+ default_creator: algopy.Account | None = None,
267
+ default_application: algopy.Application | None = None,
268
+ template_vars: dict[str, Any] | None = None,
269
+ ) -> None:
270
+ import algopy
271
+
272
+ self._asset_id = iter(range(1, 2**64))
273
+ self._app_id = iter(range(1, 2**64))
274
+
275
+ self._contracts: list[algopy.Contract | algopy.ARC4Contract] = []
276
+ self._txn_fields: TxnFields = {}
277
+ self._gtxns: list[algopy.gtxn.TransactionBase] = []
278
+ self._active_transaction_index: int | None = None
279
+ self._application_data: dict[int, ApplicationFields] = {}
280
+ self._application_logs: dict[int, list[bytes]] = {}
281
+ self._asset_data: dict[int, AssetFields] = {}
282
+ self._inner_transaction_groups: list[Sequence[InnerTransactionResultType]] = []
283
+ self._constructing_inner_transaction_group: list[InnerTransactionResultType] = []
284
+ self._constructing_inner_transaction: InnerTransactionResultType | None = None
285
+ self._scratch_spaces: dict[str, list[algopy.Bytes | algopy.UInt64 | bytes | int]] = {}
286
+ self._template_vars: dict[str, Any] = template_vars or {}
287
+ self._blocks: dict[int, dict[str, int]] = {}
288
+ self._boxes: dict[bytes, algopy.Bytes] = {}
289
+ self._lsigs: dict[algopy.LogicSig, Callable[[], algopy.UInt64 | bool]] = {}
290
+ self._active_lsig_args: Sequence[algopy.Bytes] = []
291
+
292
+ self.default_creator = default_creator or algopy.Account(
293
+ algosdk.account.generate_account()[1]
294
+ )
295
+
296
+ default_application_id = next(self._app_id)
297
+ default_application_address = algosdk.logic.get_application_address(default_application_id)
298
+
299
+ self.default_application = default_application or self.any_application(
300
+ id=default_application_id,
301
+ address=algopy.Account(default_application_address),
302
+ )
303
+
304
+ self._global_fields: GlobalFields = {
305
+ "min_txn_fee": algopy.UInt64(algosdk.constants.MIN_TXN_FEE),
306
+ "min_balance": algopy.UInt64(DEFAULT_ACCOUNT_MIN_BALANCE),
307
+ "max_txn_life": algopy.UInt64(DEFAULT_MAX_TXN_LIFE),
308
+ "zero_address": algopy.Account(algosdk.constants.ZERO_ADDRESS),
309
+ "creator_address": self.default_creator,
310
+ "current_application_address": algopy.Account(default_application_address),
311
+ "current_application_id": self.default_application,
312
+ "asset_create_min_balance": algopy.UInt64(DEFAULT_ASSET_CREATE_MIN_BALANCE),
313
+ "asset_opt_in_min_balance": algopy.UInt64(DEFAULT_ASSET_OPT_IN_MIN_BALANCE),
314
+ "genesis_hash": algopy.Bytes(DEFAULT_GLOBAL_GENESIS_HASH),
315
+ }
316
+
317
+ self._account_data: dict[str, AccountContextData] = {
318
+ str(self.default_creator): AccountContextData(
319
+ fields=AccountFields(), opted_asset_balances={}, opted_apps={}
320
+ )
321
+ }
322
+
323
+ def patch_global_fields(self, **global_fields: Unpack[GlobalFields]) -> None:
324
+ """
325
+ Patch 'Global' fields in the test context.
326
+
327
+ Args:
328
+ **global_fields: Key-value pairs for global fields.
329
+
330
+ Raises:
331
+ AttributeError: If a key is invalid.
332
+ """
333
+ invalid_keys = global_fields.keys() - GlobalFields.__annotations__.keys()
334
+
335
+ if invalid_keys:
336
+ raise AttributeError(
337
+ f"Invalid field(s) found during patch for `Global`: {', '.join(invalid_keys)}"
338
+ )
339
+
340
+ self._global_fields.update(global_fields)
341
+
342
+ def patch_txn_fields(self, **txn_fields: Unpack[TxnFields]) -> None:
343
+ """
344
+ Patch 'algopy.Txn' fields in the test context.
345
+
346
+ Args:
347
+ **txn_fields: Key-value pairs for transaction fields.
348
+
349
+ Raises:
350
+ AttributeError: If a key is invalid.
351
+ """
352
+ invalid_keys = txn_fields.keys() - TxnFields.__annotations__.keys()
353
+ if invalid_keys:
354
+ raise AttributeError(
355
+ f"Invalid field(s) found during patch for `Txn`: {', '.join(invalid_keys)}"
356
+ )
357
+
358
+ self._txn_fields.update(txn_fields)
359
+
360
+ def set_scratch_space(
361
+ self, txn: str, scratch_space: dict[int, algopy.Bytes | algopy.UInt64 | bytes | int]
362
+ ) -> None:
363
+ new_scratch_space: list[algopy.Bytes | algopy.UInt64 | bytes | int] = [0] * 256
364
+ # insert values to list at specific indexes, use key as index and value as value to set
365
+ for index, value in scratch_space.items():
366
+ new_scratch_space[index] = value
367
+
368
+ self._scratch_spaces[str(txn)] = new_scratch_space
369
+
370
+ def set_template_var(self, name: str, value: Any) -> None:
371
+ self._template_vars[name] = value
372
+
373
+ def get_account(self, address: str) -> algopy.Account:
374
+ """
375
+ Retrieve an account by address.
376
+
377
+ Args:
378
+ address (str): Account address.
379
+
380
+ Returns:
381
+ algopy.Account: The account associated with the address.
382
+ """
383
+ import algopy
384
+
385
+ if address not in self._account_data:
386
+ raise ValueError("Account not found in testing context!")
387
+
388
+ return algopy.Account(address)
389
+
390
+ def get_account_data(self) -> dict[str, AccountContextData]:
391
+ """
392
+ Retrieve all account data.
393
+
394
+ Returns:
395
+ dict[str, AccountContextData]: The account data.
396
+ """
397
+ return self._account_data
398
+
399
+ def get_asset_data(self) -> dict[int, AssetFields]:
400
+ """
401
+ Retrieve all asset data.
402
+
403
+ Returns:
404
+ dict[int, AssetFields]: The asset data.
405
+ """
406
+ return self._asset_data
407
+
408
+ def get_application_data(self) -> dict[int, ApplicationFields]:
409
+ """
410
+ Retrieve all application data.
411
+
412
+ Returns:
413
+ dict[int, ApplicationFields]: The application data.
414
+ """
415
+ return self._application_data
416
+
417
+ def get_contracts(self) -> list[algopy.Contract | algopy.ARC4Contract]:
418
+ """
419
+ Retrieve all contracts.
420
+
421
+ Returns:
422
+ list[algopy.Contract | algopy.ARC4Contract]: The contracts.
423
+ """
424
+ return self._contracts
425
+
426
+ def update_account(self, address: str, **account_fields: Unpack[AccountFields]) -> None:
427
+ """
428
+ Update an existing account.
429
+
430
+ Args:
431
+ address (str): Account address.
432
+ **account_fields: New account data.
433
+
434
+ Raises:
435
+ TypeError: If the provided object is not an instance of `Account`.
436
+ """
437
+
438
+ if address not in self._account_data:
439
+ raise ValueError("Account not found")
440
+
441
+ self._account_data[address].fields.update(account_fields)
442
+
443
+ def get_opted_asset_balance(
444
+ self, account: algopy.Account, asset_id: algopy.UInt64
445
+ ) -> algopy.UInt64 | None:
446
+ """
447
+ Retrieve the opted asset balance for an account and asset ID.
448
+
449
+ Args:
450
+ account (algopy.Account): Account to retrieve the balance for.
451
+ asset_id (algopy.UInt64): Asset ID.
452
+
453
+ Returns:
454
+ algopy.UInt64 | None: The opted asset balance or None if not opted in.
455
+ """
456
+
457
+ response = self._account_data.get(
458
+ str(account),
459
+ AccountContextData(fields=AccountFields(), opted_asset_balances={}, opted_apps={}),
460
+ ).opted_asset_balances.get(asset_id, None)
461
+
462
+ return response
463
+
464
+ def get_asset(self, asset_id: algopy.UInt64 | int) -> algopy.Asset:
465
+ """
466
+ Retrieve an asset by ID.
467
+
468
+ Args:
469
+ asset_id (int): Asset ID.
470
+
471
+ Returns:
472
+ algopy.Asset: The asset associated with the ID.
473
+ """
474
+ import algopy
475
+
476
+ asset_id = int(asset_id) if isinstance(asset_id, algopy.UInt64) else asset_id
477
+
478
+ if asset_id not in self._asset_data:
479
+ raise ValueError("Asset not found in testing context!")
480
+
481
+ return algopy.Asset(asset_id)
482
+
483
+ def update_asset(self, asset_id: int, **asset_fields: Unpack[AssetFields]) -> None:
484
+ """
485
+ Update an existing asset.
486
+
487
+ Args:
488
+ asset_id (int): Asset ID.
489
+ **asset_fields: New asset data.
490
+ """
491
+ if asset_id not in self._asset_data:
492
+ raise ValueError("Asset not found in testing context!")
493
+
494
+ self._asset_data[asset_id].update(asset_fields)
495
+
496
+ def get_application(self, app_id: algopy.UInt64 | int) -> algopy.Application:
497
+ """
498
+ Retrieve an application by ID.
499
+
500
+ Args:
501
+ app_id (int): Application ID.
502
+
503
+ Returns:
504
+ algopy.Application: The application associated with the ID.
505
+ """
506
+ import algopy
507
+
508
+ app_id = int(app_id) if isinstance(app_id, algopy.UInt64) else app_id
509
+
510
+ if app_id not in self._application_data:
511
+ raise ValueError("Application not found in testing context!")
512
+
513
+ return algopy.Application(app_id)
514
+
515
+ def update_application(
516
+ self, app_id: int, **application_fields: Unpack[ApplicationFields]
517
+ ) -> None:
518
+ """
519
+ Update an existing application.
520
+
521
+ Args:
522
+ app_id (int): Application ID.
523
+ **application_fields: New application data.
524
+ """
525
+ if app_id not in self._application_data:
526
+ raise ValueError("Application not found in testing context!")
527
+
528
+ self._application_data[app_id].update(application_fields)
529
+
530
+ def _add_contract(
531
+ self,
532
+ contract: algopy.Contract | algopy.ARC4Contract,
533
+ ) -> None:
534
+ """
535
+ Add a contract to the context.
536
+
537
+ Args:
538
+ contract (algopy.Contract | algopy.ARC4Contract): The contract to add.
539
+ """
540
+ self._contracts.append(contract)
541
+
542
+ def _append_inner_transaction_group(
543
+ self,
544
+ itxn: Sequence[InnerTransactionResultType],
545
+ ) -> None:
546
+ """
547
+ Append a group of inner transactions to the context.
548
+
549
+ Args:
550
+ itxn (Sequence[InnerTransactionResultType]): The group of inner transactions to append.
551
+ """
552
+ import algopy.itxn
553
+
554
+ self._inner_transaction_groups.append(cast(list[algopy.itxn.InnerTransactionResult], itxn))
555
+
556
+ def get_submitted_itxn_groups(self) -> list[Sequence[InnerTransactionResultType]]:
557
+ """
558
+ Retrieve the number of inner transaction groups.
559
+
560
+ Returns:
561
+ int: The number of inner transaction groups.
562
+ """
563
+ return self._inner_transaction_groups
564
+
565
+ def get_submitted_itxn_group(self, index: int) -> ITxnGroupLoader:
566
+ """
567
+ Retrieve the last group of inner transactions.
568
+
569
+ Returns:
570
+ Sequence[algopy.itxn.InnerTransactionResult]: The last group of inner transactions.
571
+
572
+ Raises:
573
+ ValueError: If no inner transaction groups have been submitted yet.
574
+ """
575
+
576
+ if not self._inner_transaction_groups:
577
+ raise ValueError("No inner transaction groups submitted yet!")
578
+
579
+ try:
580
+ return ITxnGroupLoader(self._inner_transaction_groups[index])
581
+ except IndexError as err:
582
+ raise ValueError(f"No inner transaction group available at index {index}!") from err
583
+
584
+ @property
585
+ def last_submitted_itxn(self) -> ITxnLoader:
586
+ """
587
+ Retrieve the last submitted inner transaction from the
588
+ last inner transaction group (if both exist).
589
+
590
+ Returns:
591
+ ITxnLoader: The last submitted inner transaction loader.
592
+
593
+ Raises:
594
+ ValueError: If no inner transactions exist in the last inner transaction group.
595
+ """
596
+
597
+ if not self._inner_transaction_groups or not self._inner_transaction_groups[-1]:
598
+ raise ValueError("No inner transactions in the last inner transaction group!")
599
+
600
+ try:
601
+ last_itxn = self._inner_transaction_groups[-1][-1]
602
+ return ITxnLoader(last_itxn)
603
+ except IndexError as err:
604
+ raise ValueError("No inner transactions in the last inner transaction group!") from err
605
+
606
+ def any_account(
607
+ self,
608
+ address: str | None = None,
609
+ opted_asset_balances: dict[algopy.UInt64, algopy.UInt64] | None = None,
610
+ opted_apps: dict[algopy.UInt64, algopy.Application] | None = None,
611
+ **account_fields: Unpack[AccountFields],
612
+ ) -> algopy.Account:
613
+ """
614
+ Generate and add a new account with a random address.
615
+
616
+ Returns:
617
+ algopy.Account: The newly generated account.
618
+ """
619
+ import algopy
620
+
621
+ if address and not algosdk.encoding.is_valid_address(address):
622
+ raise ValueError("Invalid Algorand address supplied!")
623
+
624
+ if address in self._account_data:
625
+ raise ValueError(
626
+ "Account with such address already exists in testing context! "
627
+ "Use `context.get_account(address)` to retrieve the existing account."
628
+ )
629
+
630
+ for key in account_fields:
631
+ if key not in AccountFields.__annotations__:
632
+ raise AttributeError(f"Invalid field '{key}' for Account")
633
+
634
+ new_account_address = address or algosdk.account.generate_account()[1]
635
+ new_account = algopy.Account(new_account_address)
636
+ new_account_fields = AccountFields(**account_fields)
637
+ new_account_data = AccountContextData(
638
+ fields=new_account_fields,
639
+ opted_asset_balances=opted_asset_balances or {},
640
+ opted_apps=opted_apps or {},
641
+ )
642
+
643
+ self._account_data[new_account_address] = new_account_data
644
+
645
+ return new_account
646
+
647
+ def any_asset(
648
+ self, asset_id: int | None = None, **asset_fields: Unpack[AssetFields]
649
+ ) -> algopy.Asset:
650
+ """
651
+ Generate and add a new asset with a unique ID.
652
+
653
+ Returns:
654
+ algopy.Asset: The newly generated asset.
655
+ """
656
+ import algopy
657
+
658
+ if asset_id and asset_id in self._asset_data:
659
+ raise ValueError("Asset with such ID already exists in testing context!")
660
+
661
+ new_asset = algopy.Asset(asset_id or next(self._asset_id))
662
+ self._asset_data[int(new_asset.id)] = AssetFields(**asset_fields)
663
+ return new_asset
664
+
665
+ def any_application( # type: ignore[misc]
666
+ self,
667
+ id: int | None = None,
668
+ address: algopy.Account | None = None,
669
+ **application_fields: Unpack[ApplicationFields],
670
+ ) -> algopy.Application:
671
+ """
672
+ Generate and add a new application with a unique ID.
673
+
674
+ Args:
675
+ id (int | None): Optional application ID. If not provided, a new ID will be generated.
676
+ address (algopy.Account | None): Optional application address. If not provided,
677
+ it will be generated.
678
+ **application_fields: Additional application fields.
679
+
680
+ Returns:
681
+ algopy.Application: The newly generated application.
682
+ """
683
+ import algopy
684
+
685
+ new_app_id = id if id is not None else next(self._app_id)
686
+ new_app = algopy.Application(new_app_id)
687
+
688
+ if address is None:
689
+ address = algopy.Account(algosdk.logic.get_application_address(new_app_id))
690
+
691
+ app_fields = ApplicationFields(address=address, **application_fields) # type: ignore[typeddict-item]
692
+ self._application_data[int(new_app.id)] = app_fields
693
+ return new_app
694
+
695
+ def add_application_logs(
696
+ self,
697
+ *,
698
+ app_id: algopy.UInt64 | algopy.Application | int,
699
+ logs: bytes | list[bytes],
700
+ prepend_arc4_prefix: bool = False,
701
+ ) -> None:
702
+ """
703
+ Add logs for an application.
704
+
705
+ Args:
706
+ app_id (int): The ID of the application.
707
+ logs (bytes | list[bytes]): A single log entry or a list of log entries.
708
+ """
709
+ import algopy
710
+
711
+ raw_app_id = (
712
+ int(app_id)
713
+ if isinstance(app_id, algopy.UInt64)
714
+ else int(app_id.id) if isinstance(app_id, algopy.Application) else app_id
715
+ )
716
+
717
+ if isinstance(logs, bytes):
718
+ logs = [logs]
719
+
720
+ if prepend_arc4_prefix:
721
+ logs = [ARC4_RETURN_PREFIX + log for log in logs]
722
+
723
+ if raw_app_id in self._application_logs:
724
+ self._application_logs[raw_app_id].extend(logs)
725
+ else:
726
+ self._application_logs[raw_app_id] = logs
727
+
728
+ def get_application_logs(self, app_id: algopy.UInt64 | int) -> list[bytes]:
729
+ """
730
+ Retrieve the application logs for a given app ID.
731
+
732
+ Args:
733
+ app_id (int): The ID of the application.
734
+
735
+ Returns:
736
+ list[bytes]: The application logs for the given app ID.
737
+ """
738
+ import algopy
739
+
740
+ app_id = int(app_id) if isinstance(app_id, algopy.UInt64) else app_id
741
+
742
+ if app_id not in self._application_logs:
743
+ raise ValueError(
744
+ f"No application logs available for app ID {app_id} in testing context!"
745
+ )
746
+
747
+ return self._application_logs[app_id]
748
+
749
+ def set_block(
750
+ self, index: int, seed: algopy.UInt64 | int, timestamp: algopy.UInt64 | int
751
+ ) -> None:
752
+ """
753
+ Set the block seed and timestamp for block at index `index`.
754
+ """
755
+ self._blocks[index] = {"seed": int(seed), "timestamp": int(timestamp)}
756
+
757
+ def set_transaction_group(
758
+ self, gtxn: list[algopy.gtxn.TransactionBase], active_transaction_index: int | None = None
759
+ ) -> None:
760
+ """
761
+ Set the transaction group using a list of transactions.
762
+
763
+ Args:
764
+ gtxn (list[algopy.gtxn.TransactionBase]): List of transactions.
765
+ active_transaction_index (int, optional): Index of the active transaction.
766
+ Defaults to None.
767
+ """
768
+ self._gtxns = gtxn
769
+
770
+ if active_transaction_index is not None:
771
+ self.set_active_transaction_index(active_transaction_index)
772
+
773
+ def add_transactions(
774
+ self,
775
+ gtxns: list[algopy.gtxn.TransactionBase],
776
+ ) -> None:
777
+ """
778
+ Add transactions to the current transaction group.
779
+
780
+ Args:
781
+ gtxns (list[algopy.gtxn.TransactionBase]): List of transactions to add.
782
+
783
+ Raises:
784
+ ValueError: If any transaction is not an instance of TransactionBase or if the total
785
+ number of transactions exceeds the group limit.
786
+ """
787
+ # ensure that new len after append is at most 16 txns in a group
788
+ import algopy.gtxn
789
+
790
+ if not all(isinstance(txn, algopy.gtxn.TransactionBase) for txn in gtxns): # type: ignore[arg-type, unused-ignore]
791
+ raise ValueError("All transactions must be instances of TransactionBase")
792
+
793
+ if len(self._gtxns) + len(gtxns) > algosdk.constants.TX_GROUP_LIMIT:
794
+ raise ValueError(
795
+ f"Transaction group can have at most {algosdk.constants.TX_GROUP_LIMIT} "
796
+ "transactions, as per AVM limits."
797
+ )
798
+
799
+ self._gtxns.extend(gtxns)
800
+
801
+ # iterate and refresh group_index to match the order of transactions
802
+ for i, txn in enumerate(self._gtxns):
803
+ txn._fields["group_index"] = i
804
+
805
+ def get_transaction_group(self) -> list[algopy.gtxn.TransactionBase]:
806
+ """
807
+ Retrieve the current transaction group.
808
+
809
+ Returns:
810
+ list[algopy.gtxn.TransactionBase]: The current transaction group.
811
+ """
812
+ return self._gtxns
813
+
814
+ def set_active_transaction_index(self, index: int) -> None:
815
+ """
816
+ Set the index of the active transaction.
817
+
818
+ Args:
819
+ index (int): The index of the active transaction.
820
+ """
821
+ self._active_transaction_index = index
822
+
823
+ def get_active_transaction(
824
+ self,
825
+ ) -> algopy.gtxn.Transaction | None:
826
+ """
827
+ Retrieve the active transaction of a specified type.
828
+
829
+ Args:
830
+ _txn_type (type[T]): The type of the active transaction.
831
+
832
+ Returns:
833
+ T | None: The active transaction if it exists, otherwise None.
834
+ """
835
+ import algopy
836
+
837
+ if self._active_transaction_index is None:
838
+ return None
839
+ active_txn = self._gtxns[self._active_transaction_index]
840
+ return cast(algopy.gtxn.Transaction, active_txn) if active_txn else None
841
+
842
+ def any_uint64(self, min_value: int = 0, max_value: int = MAX_UINT64) -> algopy.UInt64:
843
+ """
844
+ Generate a random UInt64 value within a specified range.
845
+
846
+ Args:
847
+ min_value (int, optional): Minimum value. Defaults to 0.
848
+ max_value (int, optional): Maximum value. Defaults to MAX_UINT64.
849
+
850
+ Returns:
851
+ algopy.UInt64: The randomly generated UInt64 value.
852
+
853
+ Raises:
854
+ ValueError: If `max_value` exceeds MAX_UINT64 or `min_value` exceeds `max_value`.
855
+ """
856
+ import algopy
857
+
858
+ if max_value > MAX_UINT64:
859
+ raise ValueError("max_value must be less than or equal to MAX_UINT64")
860
+ if min_value > max_value:
861
+ raise ValueError("min_value must be less than or equal to max_value")
862
+
863
+ random_value = secrets.randbelow(max_value - min_value) + min_value
864
+ return algopy.UInt64(random_value)
865
+
866
+ def any_bytes(self, length: int = MAX_BYTES_SIZE) -> algopy.Bytes:
867
+ """
868
+ Generate a random byte sequence of a specified length.
869
+
870
+ Args:
871
+ length (int, optional): Length of the byte sequence. Defaults to MAX_BYTES_SIZE.
872
+
873
+ Returns:
874
+ algopy.Bytes: The randomly generated byte sequence.
875
+ """
876
+ import algopy
877
+
878
+ return algopy.Bytes(secrets.token_bytes(length))
879
+
880
+ def any_string(self, length: int = MAX_BYTES_SIZE) -> algopy.String:
881
+ """
882
+ Generate a random string of a specified length.
883
+ """
884
+ import algopy
885
+
886
+ return algopy.String(
887
+ "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(length))
888
+ )
889
+
890
+ def any_application_call_transaction( # type: ignore[misc] # noqa: PLR0913
891
+ self,
892
+ app_id: algopy.Application,
893
+ app_args: Sequence[algopy.Bytes] = [],
894
+ accounts: Sequence[algopy.Account] = [],
895
+ assets: Sequence[algopy.Asset] = [],
896
+ apps: Sequence[algopy.Application] = [],
897
+ approval_program_pages: Sequence[algopy.Bytes] = [],
898
+ clear_state_program_pages: Sequence[algopy.Bytes] = [],
899
+ scratch_space: dict[int, algopy.Bytes | algopy.UInt64 | int | bytes] | None = None,
900
+ **kwargs: Unpack[_ApplicationCallFields],
901
+ ) -> algopy.gtxn.ApplicationCallTransaction:
902
+ """
903
+ Generate a new application call transaction with specified fields.
904
+
905
+ Args:
906
+ **kwargs (Unpack[ApplicationCallFields]): Fields to be set in the transaction.
907
+
908
+ Returns:
909
+ algopy.gtxn.ApplicationCallTransaction: The newly generated application
910
+ call transaction.
911
+ """
912
+ import algopy.gtxn
913
+
914
+ if not isinstance(app_id, algopy.Application):
915
+ raise TypeError("`app_id` must be an instance of algopy.Application")
916
+ if int(app_id.id) not in self._application_data:
917
+ raise ValueError(
918
+ f"algopy.Application with ID {app_id.id} not found in testing context!"
919
+ )
920
+
921
+ dynamic_params = {
922
+ "app_id": lambda: app_id,
923
+ "app_args": lambda index: app_args[index],
924
+ "accounts": lambda index: accounts[index],
925
+ "assets": lambda index: assets[index],
926
+ "apps": lambda index: apps[index],
927
+ "approval_program_pages": lambda index: approval_program_pages[index],
928
+ "clear_state_program_pages": lambda index: clear_state_program_pages[index],
929
+ }
930
+ new_txn = algopy.gtxn.ApplicationCallTransaction(NULL_GTXN_GROUP_INDEX)
931
+
932
+ for key, value in {**kwargs, **dynamic_params}.items():
933
+ setattr(new_txn, key, value)
934
+
935
+ self.set_scratch_space(new_txn.txn_id, scratch_space or {})
936
+
937
+ return new_txn
938
+
939
+ def any_asset_transfer_transaction(
940
+ self, **kwargs: Unpack[AssetTransferFields]
941
+ ) -> algopy.gtxn.AssetTransferTransaction:
942
+ """
943
+ Generate a new asset transfer transaction with specified fields.
944
+
945
+ Args:
946
+ **kwargs (Unpack[AssetTransferFields]): Fields to be set in the transaction.
947
+
948
+ Returns:
949
+ algopy.gtxn.AssetTransferTransaction: The newly generated asset transfer transaction.
950
+ """
951
+ import algopy.gtxn
952
+
953
+ new_txn = algopy.gtxn.AssetTransferTransaction(NULL_GTXN_GROUP_INDEX)
954
+
955
+ for key, value in kwargs.items():
956
+ setattr(new_txn, key, value)
957
+
958
+ return new_txn
959
+
960
+ def any_payment_transaction(
961
+ self, **kwargs: Unpack[PaymentFields]
962
+ ) -> algopy.gtxn.PaymentTransaction:
963
+ """
964
+ Generate a new payment transaction with specified fields.
965
+
966
+ Args:
967
+ **kwargs (Unpack[PaymentFields]): Fields to be set in the transaction.
968
+
969
+ Returns:
970
+ algopy.gtxn.PaymentTransaction: The newly generated payment transaction.
971
+ """
972
+ import algopy.gtxn
973
+
974
+ new_txn = algopy.gtxn.PaymentTransaction(NULL_GTXN_GROUP_INDEX)
975
+
976
+ for key, value in kwargs.items():
977
+ setattr(new_txn, key, value)
978
+
979
+ return new_txn
980
+
981
+ def any_asset_config_transaction(
982
+ self, **kwargs: Unpack[AssetConfigFields]
983
+ ) -> algopy.gtxn.AssetConfigTransaction:
984
+ """
985
+ Generate a new ACFG transaction with specified fields.
986
+ """
987
+ import algopy.gtxn
988
+
989
+ new_txn = algopy.gtxn.AssetConfigTransaction(NULL_GTXN_GROUP_INDEX)
990
+
991
+ for key, value in kwargs.items():
992
+ setattr(new_txn, key, value)
993
+
994
+ return new_txn
995
+
996
+ def any_key_registration_transaction(
997
+ self, **kwargs: Unpack[KeyRegistrationFields]
998
+ ) -> algopy.gtxn.KeyRegistrationTransaction:
999
+ """
1000
+ Generate a new key registration transaction with specified fields.
1001
+ """
1002
+ import algopy.gtxn
1003
+
1004
+ new_txn = algopy.gtxn.KeyRegistrationTransaction(NULL_GTXN_GROUP_INDEX)
1005
+
1006
+ for key, value in kwargs.items():
1007
+ setattr(new_txn, key, value)
1008
+
1009
+ return new_txn
1010
+
1011
+ def any_asset_freeze_transaction(
1012
+ self, **kwargs: Unpack[AssetFreezeFields]
1013
+ ) -> algopy.gtxn.AssetFreezeTransaction:
1014
+ """
1015
+ Generate a new asset freeze transaction with specified fields.
1016
+ """
1017
+ import algopy.gtxn
1018
+
1019
+ new_txn = algopy.gtxn.AssetFreezeTransaction(NULL_GTXN_GROUP_INDEX)
1020
+
1021
+ for key, value in kwargs.items():
1022
+ setattr(new_txn, key, value)
1023
+
1024
+ return new_txn
1025
+
1026
+ def any_transaction( # type: ignore[misc]
1027
+ self,
1028
+ type: algopy.TransactionType, # noqa: A002
1029
+ **kwargs: Unpack[TransactionFields],
1030
+ ) -> algopy.gtxn.Transaction:
1031
+ """
1032
+ Generate a new transaction with specified fields.
1033
+
1034
+ Args:
1035
+ type (algopy.TransactionType): Transaction type.
1036
+ **kwargs (Unpack[TransactionFields]): Fields to be set in the transaction.
1037
+
1038
+ Returns:
1039
+ algopy.gtxn.Transaction: The newly generated transaction.
1040
+ """
1041
+ import algopy.gtxn
1042
+
1043
+ new_txn = algopy.gtxn.Transaction(NULL_GTXN_GROUP_INDEX, type=type) # type: ignore[arg-type, unused-ignore]
1044
+
1045
+ for key, value in kwargs.items():
1046
+ setattr(new_txn, key, value)
1047
+
1048
+ return new_txn
1049
+
1050
+ def get_box(self, name: algopy.Bytes | bytes) -> algopy.Bytes:
1051
+ """Get the content of a box."""
1052
+ import algopy
1053
+
1054
+ name_bytes = name if isinstance(name, bytes) else name.value
1055
+ return self._boxes.get(name_bytes, algopy.Bytes(b""))
1056
+
1057
+ def set_box(self, name: algopy.Bytes | bytes, content: algopy.Bytes | bytes) -> None:
1058
+ """Set the content of a box."""
1059
+ import algopy
1060
+
1061
+ name_bytes = name if isinstance(name, bytes) else name.value
1062
+ content_bytes = content if isinstance(content, bytes) else content.value
1063
+ self._boxes[name_bytes] = algopy.Bytes(content_bytes)
1064
+
1065
+ def execute_logicsig(
1066
+ self, lsig: algopy.LogicSig, lsig_args: Sequence[algopy.Bytes] | None = None
1067
+ ) -> bool | algopy.UInt64:
1068
+ self._active_lsig_args = lsig_args or []
1069
+ # TODO: refine LogicSig class to handle injects into context
1070
+ if lsig not in self._lsigs:
1071
+ self._lsigs[lsig] = lsig.func
1072
+ return lsig.func()
1073
+
1074
+ def clear_box(self, name: algopy.Bytes | bytes) -> None:
1075
+ """Clear the content of a box."""
1076
+
1077
+ name_bytes = name if isinstance(name, bytes) else name.value
1078
+ if name_bytes in self._boxes:
1079
+ del self._boxes[name_bytes]
1080
+
1081
+ def clear_all_boxes(self) -> None:
1082
+ """Clear all boxes."""
1083
+ self._boxes.clear()
1084
+
1085
+ def clear_inner_transaction_groups(self) -> None:
1086
+ """
1087
+ Clear all inner transactions.
1088
+ """
1089
+ self._inner_transaction_groups.clear()
1090
+
1091
+ def clear_transaction_group(self) -> None:
1092
+ """
1093
+ Clear the transaction group.
1094
+ """
1095
+ self._gtxns.clear()
1096
+
1097
+ def clear_accounts(self) -> None:
1098
+ """
1099
+ Clear all accounts.
1100
+ """
1101
+ self._account_data.clear()
1102
+
1103
+ def clear_applications(self) -> None:
1104
+ """
1105
+ Clear all applications.
1106
+ """
1107
+ self._application_data.clear()
1108
+
1109
+ def clear_assets(self) -> None:
1110
+ """
1111
+ Clear all assets.
1112
+ """
1113
+ self._asset_data.clear()
1114
+
1115
+ def clear_application_logs(self) -> None:
1116
+ """
1117
+ Clear all application logs.
1118
+ """
1119
+ self._application_logs.clear()
1120
+
1121
+ def clear_contracts(self) -> None:
1122
+ """
1123
+ Clear all contracts.
1124
+ """
1125
+ self._contracts.clear()
1126
+
1127
+ def clear_scratch_spaces(self) -> None:
1128
+ """
1129
+ Clear all scratch spaces.
1130
+ """
1131
+ self._scratch_spaces.clear()
1132
+
1133
+ def clear_active_transaction_index(self) -> None:
1134
+ """
1135
+ Clear the active transaction index.
1136
+ """
1137
+ self._active_transaction_index = None
1138
+
1139
+ def clear(self) -> None:
1140
+ """
1141
+ Clear all data, including accounts, applications, assets, inner transactions,
1142
+ transaction groups, and application_logs.
1143
+ """
1144
+ self.clear_accounts()
1145
+ self.clear_applications()
1146
+ self.clear_assets()
1147
+ self.clear_inner_transaction_groups()
1148
+ self.clear_transaction_group()
1149
+ self.clear_application_logs()
1150
+ self.clear_contracts()
1151
+ self.clear_scratch_spaces()
1152
+ self.clear_active_transaction_index()
1153
+
1154
+ def reset(self) -> None:
1155
+ """
1156
+ Reset the test context to its initial state, clearing all data and resetting ID counters.
1157
+ """
1158
+ self._account_data = {}
1159
+ self._application_data = {}
1160
+ self._asset_data = {}
1161
+ self._contracts = []
1162
+ self._active_transaction_index = None
1163
+ self._scratch_spaces = {}
1164
+ self._inner_transaction_groups = []
1165
+ self._gtxns = []
1166
+ self._global_fields = {}
1167
+ self._txn_fields = {}
1168
+ self._application_logs = {}
1169
+ self._asset_id = iter(range(1, 2**64))
1170
+ self._app_id = iter(range(1, 2**64))
1171
+
1172
+
1173
+ #
1174
+ _var: ContextVar[AlgopyTestContext] = ContextVar("_var")
1175
+
1176
+
1177
+ def get_test_context() -> AlgopyTestContext:
1178
+ return _var.get()
1179
+
1180
+
1181
+ @contextmanager
1182
+ def algopy_testing_context(
1183
+ *,
1184
+ default_creator: algopy.Account | None = None,
1185
+ ) -> Generator[AlgopyTestContext, None, None]:
1186
+ token = _var.set(
1187
+ AlgopyTestContext(
1188
+ default_creator=default_creator,
1189
+ )
1190
+ )
1191
+ try:
1192
+ yield _var.get()
1193
+ finally:
1194
+ _var.reset(token)