From 4273cf49eabea09f53989aef0ebb1d972dc6da9c Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 8 Nov 2024 00:33:35 +0100 Subject: [PATCH 01/19] chore: bump ruff --- poetry.lock | 106 +++++++++++++++++++++++++------------------------ pyproject.toml | 2 +- 2 files changed, 55 insertions(+), 53 deletions(-) diff --git a/poetry.lock b/poetry.lock index 3544afa..2413884 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1214,43 +1214,43 @@ files = [ [[package]] name = "mypy" -version = "1.12.0" +version = "1.13.0" description = "Optional static typing for Python" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4397081e620dc4dc18e2f124d5e1d2c288194c2c08df6bdb1db31c38cd1fe1ed"}, - {file = "mypy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:684a9c508a283f324804fea3f0effeb7858eb03f85c4402a967d187f64562469"}, - {file = "mypy-1.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cabe4cda2fa5eca7ac94854c6c37039324baaa428ecbf4de4567279e9810f9e"}, - {file = "mypy-1.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:060a07b10e999ac9e7fa249ce2bdcfa9183ca2b70756f3bce9df7a92f78a3c0a"}, - {file = "mypy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:0eff042d7257f39ba4ca06641d110ca7d2ad98c9c1fb52200fe6b1c865d360ff"}, - {file = "mypy-1.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b86de37a0da945f6d48cf110d5206c5ed514b1ca2614d7ad652d4bf099c7de7"}, - {file = "mypy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c7c5ce0c1be0b0aea628374e6cf68b420bcc772d85c3c974f675b88e3e6e57"}, - {file = "mypy-1.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a64ee25f05fc2d3d8474985c58042b6759100a475f8237da1f4faf7fcd7e6309"}, - {file = "mypy-1.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:faca7ab947c9f457a08dcb8d9a8664fd438080e002b0fa3e41b0535335edcf7f"}, - {file = "mypy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:5bc81701d52cc8767005fdd2a08c19980de9ec61a25dbd2a937dfb1338a826f9"}, - {file = "mypy-1.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8462655b6694feb1c99e433ea905d46c478041a8b8f0c33f1dab00ae881b2164"}, - {file = "mypy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:923ea66d282d8af9e0f9c21ffc6653643abb95b658c3a8a32dca1eff09c06475"}, - {file = "mypy-1.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ebf9e796521f99d61864ed89d1fb2926d9ab6a5fab421e457cd9c7e4dd65aa9"}, - {file = "mypy-1.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e478601cc3e3fa9d6734d255a59c7a2e5c2934da4378f3dd1e3411ea8a248642"}, - {file = "mypy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:c72861b7139a4f738344faa0e150834467521a3fba42dc98264e5aa9507dd601"}, - {file = "mypy-1.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52b9e1492e47e1790360a43755fa04101a7ac72287b1a53ce817f35899ba0521"}, - {file = "mypy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48d3e37dd7d9403e38fa86c46191de72705166d40b8c9f91a3de77350daa0893"}, - {file = "mypy-1.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f106db5ccb60681b622ac768455743ee0e6a857724d648c9629a9bd2ac3f721"}, - {file = "mypy-1.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:233e11b3f73ee1f10efada2e6da0f555b2f3a5316e9d8a4a1224acc10e7181d3"}, - {file = "mypy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ae8959c21abcf9d73aa6c74a313c45c0b5a188752bf37dace564e29f06e9c1b"}, - {file = "mypy-1.12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eafc1b7319b40ddabdc3db8d7d48e76cfc65bbeeafaa525a4e0fa6b76175467f"}, - {file = "mypy-1.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9b9ce1ad8daeb049c0b55fdb753d7414260bad8952645367e70ac91aec90e07e"}, - {file = "mypy-1.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfe012b50e1491d439172c43ccb50db66d23fab714d500b57ed52526a1020bb7"}, - {file = "mypy-1.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2c40658d4fa1ab27cb53d9e2f1066345596af2f8fe4827defc398a09c7c9519b"}, - {file = "mypy-1.12.0-cp38-cp38-win_amd64.whl", hash = "sha256:dee78a8b9746c30c1e617ccb1307b351ded57f0de0d287ca6276378d770006c0"}, - {file = "mypy-1.12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b5df6c8a8224f6b86746bda716bbe4dbe0ce89fd67b1fa4661e11bfe38e8ec8"}, - {file = "mypy-1.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5feee5c74eb9749e91b77f60b30771563327329e29218d95bedbe1257e2fe4b0"}, - {file = "mypy-1.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77278e8c6ffe2abfba6db4125de55f1024de9a323be13d20e4f73b8ed3402bd1"}, - {file = "mypy-1.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:dcfb754dea911039ac12434d1950d69a2f05acd4d56f7935ed402be09fad145e"}, - {file = "mypy-1.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:06de0498798527451ffb60f68db0d368bd2bae2bbfb5237eae616d4330cc87aa"}, - {file = "mypy-1.12.0-py3-none-any.whl", hash = "sha256:fd313226af375d52e1e36c383f39bf3836e1f192801116b31b090dfcd3ec5266"}, - {file = "mypy-1.12.0.tar.gz", hash = "sha256:65a22d87e757ccd95cbbf6f7e181e6caa87128255eb2b6be901bb71b26d8a99d"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, + {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, + {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, + {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, + {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, + {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, + {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, + {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, + {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, + {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, + {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, + {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, + {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, + {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, + {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, + {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, + {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, + {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, + {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, + {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, + {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, + {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, + {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, + {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, + {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, + {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, + {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, ] [package.dependencies] @@ -1260,6 +1260,7 @@ typing-extensions = ">=4.6.0" [package.extras] dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] install-types = ["pip"] mypyc = ["setuptools (>=50)"] reports = ["lxml"] @@ -2007,28 +2008,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.4.10" +version = "0.7.1" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, - {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, - {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, - {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, - {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, - {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, - {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, - {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, + {file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"}, + {file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"}, + {file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"}, + {file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"}, + {file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"}, + {file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"}, + {file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"}, + {file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"}, + {file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"}, ] [[package]] @@ -2656,4 +2658,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "66e85df44cca4d3edccb50f730dfb4e9dccf93582e78fa0074dc9b47baa925e2" +content-hash = "d0cadf4d47cd2c81976e3752e840c0be5e9702cd6c8a47028df6838073654572" diff --git a/pyproject.toml b/pyproject.toml index bd391ae..0283ac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ deprecated = "^1.2.14" [tool.poetry.group.dev.dependencies] pytest = "^7.2.0" -ruff = ">=0.1.6,<0.5.0" +ruff = ">=0.1.6,<0.7.2" pip-audit = "^2.5.6" pytest-mock = "^3.11.1" mypy = "^1.5.1" From 12e2bf60c7f3948e22d1fddc098cbdb93107d870 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 8 Nov 2024 00:34:09 +0100 Subject: [PATCH 02/19] chore: wip; arc32 to arc56 converter --- .../_legacy_v2/application_client.py | 2 +- src/algokit_utils/applications/app_client.py | 245 ++++++++++++ src/algokit_utils/applications/utils.py | 203 ++++++++++ src/algokit_utils/config.py | 2 +- src/algokit_utils/models/application.py | 69 ++++ src/algokit_utils/protocols/__init__.py | 0 src/algokit_utils/protocols/application.py | 60 +++ .../transactions/transaction_composer.py | 2 +- .../artifacts/arc32_app_spec.json | 378 ++++++++++++++++++ tests/applications/test_app_manager.py | 4 +- tests/applications/test_utils.py | 16 + tests/assets/test_asset_manager.py | 10 +- tests/conftest.py | 6 +- tests/test_transaction_composer.py | 18 +- .../transactions/test_transaction_composer.py | 20 +- .../transactions/test_transaction_creator.py | 28 +- tests/transactions/test_transaction_sender.py | 26 +- tests/utils.py | 29 ++ 18 files changed, 1059 insertions(+), 59 deletions(-) create mode 100644 src/algokit_utils/applications/app_client.py create mode 100644 src/algokit_utils/applications/utils.py create mode 100644 src/algokit_utils/protocols/__init__.py create mode 100644 src/algokit_utils/protocols/application.py create mode 100644 tests/applications/artifacts/arc32_app_spec.json create mode 100644 tests/applications/test_utils.py create mode 100644 tests/utils.py diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index a52639d..33dfe95 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -239,7 +239,7 @@ def prepare( ) return new_client - def _prepare( # noqa: PLR0913 + def _prepare( self, target: "ApplicationClient", *, diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py new file mode 100644 index 0000000..946b307 --- /dev/null +++ b/src/algokit_utils/applications/app_client.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +import algosdk +from algosdk.atomic_transaction_composer import TransactionSigner +from algosdk.logic import get_application_address +from algosdk.source_map import SourceMap +from algosdk.transaction import OnComplete + +from algokit_utils._legacy_v2.models import ( + ABIValue, + AppState, + TransactionResponse, +) +from algokit_utils.applications.app_manager import BoxName +from algokit_utils.models.abi import Arc56Contract +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.models.application import ApplicationSpecification +from algokit_utils.protocols.application import AlgorandClientProtocol +from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer + + +@dataclass +class AppClientParams: + app_id: int + app_spec: Arc56Contract | ApplicationSpecification | str + algorand: AlgorandClientProtocol + app_name: str | None = None + default_sender: str | None = None + default_signer: TransactionSigner | None = None + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + + +@dataclass +class AppClientCompilationParams: + deploy_time_params: dict[str, Any] | None = None + updatable: bool | None = None + deletable: bool | None = None + + +@dataclass +class AppClientCallParams: + sender: str | None = None + signer: TransactionSigner | None = None + note: bytes | None = None + send_params: dict[str, Any] | None = None + args: dict[str, Any] | None = None + method: str | None = None + on_complete: OnComplete | None = None + + +class AppClient: + def __init__(self, params: AppClientParams) -> None: + self._app_id = params.app_id + self._app_spec = self.normalize_app_spec(params.app_spec) + self._algorand = params.algorand + self._app_name = params.app_name or self._app_spec.name + self._default_sender = params.default_sender + self._default_signer = params.default_signer + self._approval_source_map = params.approval_source_map + self._clear_source_map = params.clear_source_map + self._app_address = get_application_address(self._app_id) + + @staticmethod + def normalize_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: + if isinstance(app_spec, str): + spec = json.loads(app_spec) + else: + spec = app_spec + + if isinstance(spec, Arc56Contract): + return spec + elif isinstance(spec, ApplicationSpecification): + # Convert ARC-32 to ARC-56 + from algokit_utils.applications.utils import arc32_to_arc56 + + return arc32_to_arc56(spec) + elif isinstance(spec, dict): + return Arc56Contract(**spec) + else: + raise ValueError("Invalid app spec format") + + @property + def app_id(self) -> int: + return self._app_id + + @property + def app_address(self) -> str: + return self._app_address + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def algorand(self) -> AlgorandClientProtocol: + return self._algorand + + def clone(self, **params: Any) -> AppClient: + return AppClient( + AppClientParams( + app_id=params.get("app_id", self._app_id), + app_spec=self._app_spec, + algorand=self.algorand, + app_name=params.get("app_name", self._app_name), + default_sender=params.get("default_sender", self._default_sender), + default_signer=params.get("default_signer", self._default_signer), + approval_source_map=params.get("approval_source_map", self._approval_source_map), + clear_source_map=params.get("clear_source_map", self._clear_source_map), + ) + ) + + def new_group(self) -> TransactionComposer: + return self.algorand.new_group() + + def _get_sender(self, sender: str | None = None) -> str: + if not sender and not self._default_sender: + raise ValueError( + f"No sender provided and no default sender present in app client for call to app {self._app_name}" + ) + return sender or self._default_sender # type: ignore[return-value] + + def _get_signer( + self, + sender: str | None, + signer: TransactionSigner | None, + ) -> TransactionSigner | None: + return signer or (None if sender else self._default_signer) + + def fund_app_account(self, amount: AlgoAmount, params: AppClientCallParams | None = None) -> None: + sender = self._get_sender(params.sender if params else None) + signer = self._get_signer(sender, params.signer if params else None) + payment_params = PaymentParams( + sender=sender, + signer=signer, + receiver=self.app_address, + amount=amount, + note=params.note if params else None, + **(params.send_params if params and params.send_params else {}), + ) + self._algorand.send.payment(payment_params) + + def get_global_state(self) -> AppState: + return self._algorand.app.get_global_state(self.app_id) + + def get_local_state(self, address: str) -> AppState: + return self._algorand.app.get_local_state(self.app_id, address) + + def get_box_names(self) -> list[BoxName]: + return self._algorand.app.get_box_names(self.app_id) + + def get_box_value(self, name: bytes) -> bytes: + return self._algorand.app.get_box_value(self.app_id, name) + + def get_box_value_from_abi_type(self, name: bytes, abi_type: algosdk.abi.ABIType) -> ABIValue: + return self._algorand.app.get_box_value_from_abi_type(self.app_id, name, abi_type) + + def compile(self, params: AppClientCompilationParams | None = None) -> None: + # Implement compilation logic here + pass + + def create(self, params: AppClientCallParams | None = None) -> TransactionResponse: + # Implement create logic here + pass + + def update(self, params: AppClientCallParams | None = None) -> TransactionResponse: + # Implement update logic here + pass + + def delete(self, params: AppClientCallParams | None = None) -> TransactionResponse: + # Implement delete logic here + pass + + def call(self, params: AppClientCallParams | None = None) -> Any: + # Implement call logic here + pass + + def _handle_call_errors(self, call: Callable[[], Awaitable[Any]]) -> Any: + try: + return call() + except Exception as e: + raise self._expose_logic_error(e) + + def _expose_logic_error(self, e: Exception) -> Exception: + # Implement logic error exposure, possibly augmenting the exception with debugging info + return e + + def get_abi_methods(self) -> list[dict[str, Any]]: + return self._app_spec.contract.methods + + def process_method_call_return(self, result: Any, method: Any) -> Any: + # Process the result of an ABI method call + pass + + def get_state_methods(self): + def get_all(): + state = self.get_global_state() + keys = self._app_spec.state.keys["global"] + result = {} + for key in keys: + result[key] = get_value(key, state) + return result + + def get_value(name: str, state: AppState | None = None): + state = state or self.get_global_state() + key_info = self._app_spec.state.keys["global"][name] + value = state.get(key_info["key"]) + if value is not None: + return value # Decode using ABI if necessary + return None + + def get_map_value(map_name: str, key: Any, state: AppState | None = None): + state = state or self.get_global_state() + map_info = self._app_spec.state.maps["global"][map_name] + full_key = map_info["prefix"] + key + value = state.get(full_key) + if value is not None: + return value # Decode using ABI if necessary + return None + + def get_map(map_name: str): + state = self.get_global_state() + map_info = self._app_spec.state.maps["global"][map_name] + result = {} + for key, value in state.items(): + if key.startswith(map_info["prefix"]): + map_key = key[len(map_info["prefix"]) :] + result[map_key] = value # Decode using ABI if necessary + return result + + return { + "get_all": get_all, + "get_value": get_value, + "get_map_value": get_map_value, + "get_map": get_map, + } diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py new file mode 100644 index 0000000..3217a5e --- /dev/null +++ b/src/algokit_utils/applications/utils.py @@ -0,0 +1,203 @@ +from base64 import b64encode +from typing import Any, Literal, cast + +import algosdk +from algosdk.abi import Method + +from algokit_utils._legacy_v2.application_specification import ( + ApplicationSpecification, + CallConfig, + DefaultArgumentDict, + MethodConfigDict, + MethodHints, + OnCompleteActionName, +) +from algokit_utils.models.application import ( + Arc56Contract, + Arc56Method, + Arc56MethodArg, + Arc56State, + StorageKey, +) + + +def arc32_to_arc56(app_spec: ApplicationSpecification) -> Arc56Contract: # noqa: C901 + """ + Convert ARC-32 application specification to ARC-56 contract format. + + Args: + app_spec: ARC-32 application specification + + Returns: + ARC-56 contract specification + """ + + def convert_structs() -> dict[str, list[dict[str, str]]]: + structs = {} + for hint in app_spec.hints.values(): + if not hint.structs: + continue + for struct in hint.structs.values(): + fields = [{"name": name, "type": type_} for name, type_ in struct["elements"]] + structs[struct["name"]] = fields + return structs + + def get_hint(method: Method) -> MethodHints | None: + sig = method.get_signature() + return app_spec.hints.get(sig) + + def convert_actions(call_config: MethodConfigDict, action_type: Literal["CREATE", "CALL"]) -> list[str]: + actions: list[str] = [] + action_map: dict[OnCompleteActionName, str] = { + "close_out": "CloseOut", + "delete_application": "DeleteApplication", + "no_op": "NoOp", + "opt_in": "OptIn", + "update_application": "UpdateApplication", + } + + for config_key, action_name in action_map.items(): + config_value = call_config.get(config_key, CallConfig.NEVER) + if ( + config_value == CallConfig.ALL + or (config_value == CallConfig.CREATE and action_type == "CREATE") + or (config_value == CallConfig.CALL and action_type == "CALL") + ): + actions.append(action_name) + + return actions + + def get_default_value( + type_: str | algosdk.abi.ABIType, default_arg: DefaultArgumentDict + ) -> dict[str, str | int] | None: + if not default_arg or default_arg["source"] == "abi-method": + return None + + source_map = {"constant": "literal", "global-state": "global", "local-state": "local"} + + data = default_arg["data"] + if isinstance(data, str): + data = b64encode(data.encode()).decode() + + return cast( + dict[str, str | int], + { + "source": source_map[default_arg["source"]], + "data": data, + "type": "AVMString" if type_ == "string" else str(type_), + }, + ) + + def convert_method(method: Method) -> Arc56Method: + hint = get_hint(method) + + args: list[Arc56MethodArg] = [] + for arg in method.args: + if not arg.name: + continue + struct_name = None + if hint and hint.structs and arg.name in hint.structs: + struct_name = hint.structs[arg.name].get("name") + + default_value = None + if hint and hint.default_arguments and arg.name in hint.default_arguments: + default_value = get_default_value(arg.type, hint.default_arguments[arg.name]) + + args.append( + cast( + Arc56MethodArg, + { + "name": arg.name, + "type": arg.type, + "desc": arg.desc, + "struct": struct_name, + "defaultValue": default_value, + }, + ) + ) + + return { + "name": method.name, + "desc": method.desc, + "args": args, + "returns": { + "type": method.returns.type, + "desc": method.returns.desc, + "struct": hint.structs.get("output", {}).get("name") # type: ignore[call-overload] + if hint and hint.structs + else None, + }, + "events": [], + "readonly": hint.read_only if hint else None, + "actions": { + "create": convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], + "call": convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], + }, + } + + def convert_storage_keys(schema_dict: dict[str, Any]) -> dict[str, StorageKey]: + return { + name: cast( + StorageKey, + { + "key": b64encode(spec["key"].encode()).decode(), + "keyType": "AVMString", + "valueType": "AVMUint64" if spec["type"] == "uint64" else "AVMBytes", + "desc": spec.get("descr"), + }, + ) + for name, spec in schema_dict["declared"].items() + } + + # Get schema information from app_spec + global_schema = app_spec.schema.get("global", {}) + local_schema = app_spec.schema.get("local", {}) + + # TODO: remove cast + state: Arc56State = cast( + Arc56State, + { + "schema": { + "global": { + "ints": app_spec.global_state_schema.num_uints, + "bytes": app_spec.global_state_schema.num_byte_slices, + }, + "local": { + "ints": app_spec.local_state_schema.num_uints, + "bytes": app_spec.local_state_schema.num_byte_slices, + }, + }, + "keys": { + "global": convert_storage_keys(global_schema), + "local": convert_storage_keys(local_schema), + "box": {}, + }, + "maps": {"global": {}, "local": {}, "box": {}}, + }, + ) + + bare_actions = cast( + dict[Literal["create", "call"], list[str]], + { + "create": convert_actions(app_spec.bare_call_config, "CREATE"), + "call": convert_actions(app_spec.bare_call_config, "CALL"), + }, + ) + + return Arc56Contract( + arcs=[], + name=app_spec.contract.name, + desc=app_spec.contract.desc, + structs=convert_structs(), + methods=[convert_method(m) for m in app_spec.contract.methods], + state=state, + source={"approval": app_spec.approval_program, "clear": app_spec.clear_program}, + bareActions=bare_actions, + byteCode=None, + compilerInfo=None, + events=None, + networks=None, + scratchVariables=None, + sourceInfo=None, + templateVariables=None, + ) diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index 55850fd..4e5e517 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -68,7 +68,7 @@ def with_debug(self, func: Callable[[], str | None]) -> None: finally: self._debug = original_debug - def configure( # noqa: PLR0913 + def configure( self, *, debug: bool, diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index c68e78a..1a79e40 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,5 +1,74 @@ +from typing import Literal, TypedDict + +import algosdk + UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" """The name of the TEAL template variable for deploy-time permanence control.""" + + +# ===== ARCs ===== + + +# Type definitions +class StorageKey(TypedDict): + key: str # base64 encoded + keyType: Literal["AVMString"] + valueType: Literal["AVMUint64", "AVMBytes"] + desc: str | None + + +class SchemaSpec(TypedDict): + num_uints: int + num_byte_slices: int + + +class Arc56State(TypedDict): + schema: dict[Literal["global", "local"], dict[Literal["ints", "bytes"], int]] + keys: dict[Literal["global", "local", "box"], dict[str, StorageKey]] + maps: dict[Literal["global", "local", "box"], dict] + + +class Arc56MethodArg(TypedDict): + name: str | None + type: str | algosdk.abi.ABIType + desc: str | None + struct: str | None + defaultValue: dict[str, str | int] | None + + +class Arc56MethodReturn(TypedDict): + type: str | algosdk.abi.ABIType + desc: str | None + struct: str | None + + +class Arc56Method(TypedDict): + name: str | None + desc: str | None + args: list[Arc56MethodArg] + returns: Arc56MethodReturn + events: list[str] # Empty for now as per original + readonly: bool | None + actions: dict[Literal["create", "call"], list[str]] + + +class Arc56Contract(TypedDict): + arcs: list[str] # Empty as per original + name: str + desc: str | None + structs: dict[str, list[dict[str, str]]] + methods: list[Arc56Method] + state: Arc56State + source: dict[str, str] + bareActions: dict[Literal["create", "call"], list[str]] + # Following fields are undefined as per original + byteCode: None + compilerInfo: None + events: None + networks: None + scratchVariables: None + sourceInfo: None + templateVariables: None diff --git a/src/algokit_utils/protocols/__init__.py b/src/algokit_utils/protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/algokit_utils/protocols/application.py b/src/algokit_utils/protocols/application.py new file mode 100644 index 0000000..7298fc8 --- /dev/null +++ b/src/algokit_utils/protocols/application.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Protocol + +from typing_extensions import runtime_checkable + +if TYPE_CHECKING: + from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.indexer import IndexerClient + + from algokit_utils.applications.app_manager import AppManager + from algokit_utils.clients.client_manager import ClientManager + from algokit_utils.transactions.transaction_composer import TransactionComposer + from algokit_utils.transactions.transaction_creator import AlgorandClientTransactionCreator + from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender + + +@dataclass +class NetworkDetails: + genesis_id: str + genesis_hash: str + network_name: str + + +@runtime_checkable +class AlgorandClientProtocol(Protocol): + @property + def app(self) -> AppManager: ... + + @property + def app_deployer(self) -> AppManager: ... + + @property + def send(self) -> AlgorandClientTransactionSender: ... + + @property + def create_transaction(self) -> AlgorandClientTransactionCreator: ... + + def new_group(self) -> TransactionComposer: ... + + @property + def client(self) -> ClientManager: ... + + +@runtime_checkable +class ClientManagerProtocol(Protocol): + @property + def algod(self) -> AlgodClient: ... + + @property + def indexer(self) -> IndexerClient | None: ... + + async def network(self) -> NetworkDetails: ... + + async def is_local_net(self) -> bool: ... + + async def is_test_net(self) -> bool: ... + + async def is_main_net(self) -> bool: ... diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 77dea2e..af4dbc2 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -720,7 +720,7 @@ class TransactionComposer: NULL_SIGNER: TransactionSigner = algosdk.atomic_transaction_composer.EmptySigner() - def __init__( # noqa: PLR0913 + def __init__( self, algod: AlgodClient, get_signer: Callable[[str], TransactionSigner], diff --git a/tests/applications/artifacts/arc32_app_spec.json b/tests/applications/artifacts/arc32_app_spec.json new file mode 100644 index 0000000..1ddf81b --- /dev/null +++ b/tests/applications/artifacts/arc32_app_spec.json @@ -0,0 +1,378 @@ +{ + "hints": { + "version()uint64": { + "call_config": { + "no_op": "CALL" + } + }, + "readonly(uint64)void": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "set_box(byte[4],string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "get_box(byte[4])string": { + "call_config": { + "no_op": "CALL" + } + }, + "get_box_readonly(byte[4])string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "update()void": { + "call_config": { + "update_application": "CALL" + } + }, + "update_args(string)void": { + "call_config": { + "update_application": "CALL" + } + }, + "delete()void": { + "call_config": { + "delete_application": "CALL" + } + }, + "delete_args(string)void": { + "call_config": { + "delete_application": "CALL" + } + }, + "create_opt_in()void": { + "call_config": { + "opt_in": "CREATE" + } + }, + "update_greeting(string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "create()void": { + "call_config": { + "no_op": "CREATE" + } + }, + "create_args(string)void": { + "call_config": { + "no_op": "CREATE" + } + }, + "hello(string)string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "hello_remember(string)string": { + "call_config": { + "no_op": "CALL" + } + }, + "get_last()string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "opt_in()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "opt_in_args(string)void": { + "call_config": { + "opt_in": "CALL" + } + }, + "close_out()void": { + "call_config": { + "close_out": "CALL" + } + }, + "close_out_args(string)void": { + "call_config": { + "close_out": "CALL" + } + }, + "call_with_payment(pay)string": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMSAyIDUgVE1QTF9VUERBVEFCTEUgVE1QTF9ERUxFVEFCTEUKYnl0ZWNibG9jayAweCAweDY3NzI2NTY1NzQ2OTZlNjcgMHgxNTFmN2M3NSAweDZjNjE3Mzc0IDB4NTk2NTczIDB4MmMyMAp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNDQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxOWQ2YjE4NiAvLyAidmVyc2lvbigpdWludDY0Igo9PQpibnogbWFpbl9sNDMKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg1M2JkNjE4NiAvLyAicmVhZG9ubHkodWludDY0KXZvaWQiCj09CmJueiBtYWluX2w0Mgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGE0YjRhMjMwIC8vICJzZXRfYm94KGJ5dGVbNF0sc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2w0MQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDdmNWRlMjhmIC8vICJnZXRfYm94KGJ5dGVbNF0pc3RyaW5nIgo9PQpibnogbWFpbl9sNDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgxM2QxMmI1MCAvLyAiZ2V0X2JveF9yZWFkb25seShieXRlWzRdKXN0cmluZyIKPT0KYm56IG1haW5fbDM5CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YTBlODE4NzIgLy8gInVwZGF0ZSgpdm9pZCIKPT0KYm56IG1haW5fbDM4CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4N2QwODUxOGIgLy8gInVwZGF0ZV9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMzcKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgyNDM3OGQzYyAvLyAiZGVsZXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMzYKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg1ODYxYmI1MCAvLyAiZGVsZXRlX2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wzNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDhiZGY5ZWIwIC8vICJjcmVhdGVfb3B0X2luKCl2b2lkIgo9PQpibnogbWFpbl9sMzQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHgwMDU1ZjAwNiAvLyAidXBkYXRlX2dyZWV0aW5nKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMzMKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg0YzVjNjFiYSAvLyAiY3JlYXRlKCl2b2lkIgo9PQpibnogbWFpbl9sMzIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhkMTQ1NGM3OCAvLyAiY3JlYXRlX2FyZ3Moc3RyaW5nKXZvaWQiCj09CmJueiBtYWluX2wzMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sMzAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhiYzFjMWRkNCAvLyAiaGVsbG9fcmVtZW1iZXIoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDI5CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YTlhZTc2MjcgLy8gImdldF9sYXN0KClzdHJpbmciCj09CmJueiBtYWluX2wyOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDMwYzZkNThhIC8vICJvcHRfaW4oKXZvaWQiCj09CmJueiBtYWluX2wyNwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDIyYzdkZWRhIC8vICJvcHRfaW5fYXJncyhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDI2CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MTY1OGFhMmYgLy8gImNsb3NlX291dCgpdm9pZCIKPT0KYm56IG1haW5fbDI1CnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4ZGU4NGQ5YWQgLy8gImNsb3NlX291dF9hcmdzKHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMjQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg4ODk2M2M5OSAvLyAiY2FsbF93aXRoX3BheW1lbnQocGF5KXN0cmluZyIKPT0KYm56IG1haW5fbDIzCmVycgptYWluX2wyMzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjYWxsd2l0aHBheW1lbnRjYXN0ZXJfNDYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDI0Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMiAvLyBDbG9zZU91dAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGFyZ3NjYXN0ZXJfNDUKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDI1Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMiAvLyBDbG9zZU91dAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGNhc3Rlcl80NAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIG9wdGluYXJnc2Nhc3Rlcl80MwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjc6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIG9wdGluY2FzdGVyXzQyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyODoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBnZXRsYXN0Y2FzdGVyXzQxCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBoZWxsb3JlbWVtYmVyY2FzdGVyXzQwCmludGNfMSAvLyAxCnJldHVybgptYWluX2wzMDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBoZWxsb2Nhc3Rlcl8zOQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzE6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlYXJnc2Nhc3Rlcl8zOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlY2FzdGVyXzM3CmludGNfMSAvLyAxCnJldHVybgptYWluX2wzMzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVncmVldGluZ2Nhc3Rlcl8zNgppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18xIC8vIE9wdEluCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CiYmCmFzc2VydApjYWxsc3ViIGNyZWF0ZW9wdGluY2FzdGVyXzM1CmludGNfMSAvLyAxCnJldHVybgptYWluX2wzNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzMgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVsZXRlYXJnc2Nhc3Rlcl8zNAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGRlbGV0ZWNhc3Rlcl8zMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzc6CnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHVwZGF0ZWFyZ3NjYXN0ZXJfMzIKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDM4Ogp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiB1cGRhdGVjYXN0ZXJfMzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDM5Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGdldGJveHJlYWRvbmx5Y2FzdGVyXzMwCmludGNfMSAvLyAxCnJldHVybgptYWluX2w0MDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBnZXRib3hjYXN0ZXJfMjkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHNldGJveGNhc3Rlcl8yOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNDI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgcmVhZG9ubHljYXN0ZXJfMjcKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQzOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHZlcnNpb25jYXN0ZXJfMjYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDQ0Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2w1NAp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQpibnogbWFpbl9sNTMKdHhuIE9uQ29tcGxldGlvbgppbnRjXzIgLy8gQ2xvc2VPdXQKPT0KYm56IG1haW5fbDUyCnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CmJueiBtYWluX2w1MQp0eG4gT25Db21wbGV0aW9uCmludGNfMyAvLyBEZWxldGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sNTAKZXJyCm1haW5fbDUwOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBkZWxldGViYXJlXzkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUxOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGViYXJlXzYKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUyOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBjbG9zZW91dGJhcmVfMjMKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDUzOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiBvcHRpbmJhcmVfMjAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDU0Ogp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAo9PQphc3NlcnQKY2FsbHN1YiBjcmVhdGViYXJlXzEzCmludGNfMSAvLyAxCnJldHVybgoKLy8gdmVyc2lvbgp2ZXJzaW9uXzA6CnByb3RvIDAgMQppbnRjXzAgLy8gMApwdXNoaW50IFRNUExfVkVSU0lPTiAvLyBUTVBMX1ZFUlNJT04KZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gcmVhZG9ubHkKcmVhZG9ubHlfMToKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpibnogcmVhZG9ubHlfMV9sMgppbnRjXzEgLy8gMQpyZXR1cm4KcmVhZG9ubHlfMV9sMjoKaW50Y18wIC8vIDAKLy8gQW4gZXJyb3IKYXNzZXJ0CnJldHN1YgoKLy8gc2V0X2JveApzZXRib3hfMjoKcHJvdG8gMiAwCmZyYW1lX2RpZyAtMgpib3hfZGVsCnBvcApmcmFtZV9kaWcgLTIKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJveF9wdXQKcmV0c3ViCgovLyBnZXRfYm94CmdldGJveF8zOgpwcm90byAxIDEKYnl0ZWNfMCAvLyAiIgpmcmFtZV9kaWcgLTEKYm94X2dldApzdG9yZSAxCnN0b3JlIDAKbG9hZCAxCmFzc2VydApsb2FkIDAKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBnZXRfYm94X3JlYWRvbmx5CmdldGJveHJlYWRvbmx5XzQ6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCmZyYW1lX2RpZyAtMQpib3hfZ2V0CnN0b3JlIDMKc3RvcmUgMgpsb2FkIDMKYXNzZXJ0CmxvYWQgMgpmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIHVwZGF0ZQp1cGRhdGVfNToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTQyNDkgLy8gIlVwZGF0ZWQgQUJJIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9iYXJlCnVwZGF0ZWJhcmVfNjoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MjYxNzI2NSAvLyAiVXBkYXRlZCBCYXJlIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHVwZGF0ZV9hcmdzCnVwZGF0ZWFyZ3NfNzoKcHJvdG8gMSAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIHVwZGF0ZSBjaGVjawphc3NlcnQKaW50YyA0IC8vIFRNUExfVVBEQVRBQkxFCi8vIGlzIHVwZGF0YWJsZQphc3NlcnQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDU1NzA2NDYxNzQ2NTY0MjA0MTcyNjc3MyAvLyAiVXBkYXRlZCBBcmdzIgphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfODoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIGlzIGRlbGV0YWJsZQphc3NlcnQKcmV0c3ViCgovLyBkZWxldGVfYmFyZQpkZWxldGViYXJlXzk6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmdsb2JhbCBDcmVhdG9yQWRkcmVzcwo9PQovLyB1bmF1dGhvcml6ZWQKYXNzZXJ0CmludGMgNSAvLyBUTVBMX0RFTEVUQUJMRQovLyBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlX2FyZ3MKZGVsZXRlYXJnc18xMDoKcHJvdG8gMSAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIGRlbGV0ZSBjaGVjawphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIGlzIGRlbGV0YWJsZQphc3NlcnQKcmV0c3ViCgovLyBjcmVhdGVfb3B0X2luCmNyZWF0ZW9wdGluXzExOgpwcm90byAwIDAKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZSAvLyAiT3B0IEluIgphcHBfZ2xvYmFsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZV9ncmVldGluZwp1cGRhdGVncmVldGluZ18xMjoKcHJvdG8gMSAwCmJ5dGVjXzEgLy8gImdyZWV0aW5nIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKcmV0c3ViCgovLyBjcmVhdGVfYmFyZQpjcmVhdGViYXJlXzEzOgpwcm90byAwIDAKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCnB1c2hieXRlcyAweDQ4NjU2YzZjNmYyMDQyNjE3MjY1IC8vICJIZWxsbyBCYXJlIgphcHBfZ2xvYmFsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNyZWF0ZQpjcmVhdGVfMTQ6CnByb3RvIDAgMApieXRlY18xIC8vICJncmVldGluZyIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjIwNDE0MjQ5IC8vICJIZWxsbyBBQkkiCmFwcF9nbG9iYWxfcHV0CmludGNfMSAvLyAxCnJldHVybgoKLy8gY3JlYXRlX2FyZ3MKY3JlYXRlYXJnc18xNToKcHJvdG8gMSAwCmJ5dGVjXzEgLy8gImdyZWV0aW5nIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBoZWxsbwpoZWxsb18xNjoKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCmFwcF9nbG9iYWxfZ2V0CmJ5dGVjIDUgLy8gIiwgIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGhlbGxvX3JlbWVtYmVyCmhlbGxvcmVtZW1iZXJfMTc6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmFwcF9sb2NhbF9wdXQKYnl0ZWNfMSAvLyAiZ3JlZXRpbmciCmFwcF9nbG9iYWxfZ2V0CmJ5dGVjIDUgLy8gIiwgIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGdldF9sYXN0CmdldGxhc3RfMTg6CnByb3RvIDAgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKYXBwX2xvY2FsX2dldApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIG9wdF9pbgpvcHRpbl8xOToKcHJvdG8gMCAwCnR4biBTZW5kZXIKYnl0ZWNfMyAvLyAibGFzdCIKcHVzaGJ5dGVzIDB4NGY3MDc0MjA0OTZlMjA0MTQyNDkgLy8gIk9wdCBJbiBBQkkiCmFwcF9sb2NhbF9wdXQKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBvcHRfaW5fYmFyZQpvcHRpbmJhcmVfMjA6CnByb3RvIDAgMAp0eG4gU2VuZGVyCmJ5dGVjXzMgLy8gImxhc3QiCnB1c2hieXRlcyAweDRmNzA3NDIwNDk2ZTIwNDI2MTcyNjUgLy8gIk9wdCBJbiBCYXJlIgphcHBfbG9jYWxfcHV0CmludGNfMSAvLyAxCnJldHVybgoKLy8gb3B0X2luX2FyZ3MKb3B0aW5hcmdzXzIxOgpwcm90byAxIDAKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmJ5dGVjIDQgLy8gIlllcyIKPT0KLy8gcGFzc2VzIG9wdF9pbiBjaGVjawphc3NlcnQKdHhuIFNlbmRlcgpieXRlY18zIC8vICJsYXN0IgpwdXNoYnl0ZXMgMHg0ZjcwNzQyMDQ5NmUyMDQxNzI2NzczIC8vICJPcHQgSW4gQXJncyIKYXBwX2xvY2FsX3B1dAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNsb3NlX291dApjbG9zZW91dF8yMjoKcHJvdG8gMCAwCmludGNfMSAvLyAxCnJldHVybgoKLy8gY2xvc2Vfb3V0X2JhcmUKY2xvc2VvdXRiYXJlXzIzOgpwcm90byAwIDAKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBjbG9zZV9vdXRfYXJncwpjbG9zZW91dGFyZ3NfMjQ6CnByb3RvIDEgMApmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYnl0ZWMgNCAvLyAiWWVzIgo9PQovLyBwYXNzZXMgY2xvc2Vfb3V0IGNoZWNrCmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIGNhbGxfd2l0aF9wYXltZW50CmNhbGx3aXRocGF5bWVudF8yNToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKZnJhbWVfZGlnIC0xCmd0eG5zIEFtb3VudAppbnRjXzAgLy8gMAo+CmFzc2VydApwdXNoYnl0ZXMgMHgwMDEyNTA2MTc5NmQ2NTZlNzQyMDUzNzU2MzYzNjU3MzczNjY3NTZjIC8vIDB4MDAxMjUwNjE3OTZkNjU2ZTc0MjA1Mzc1NjM2MzY1NzM3MzY2NzU2YwpmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyB2ZXJzaW9uX2Nhc3Rlcgp2ZXJzaW9uY2FzdGVyXzI2Ogpwcm90byAwIDAKaW50Y18wIC8vIDAKY2FsbHN1YiB2ZXJzaW9uXzAKZnJhbWVfYnVyeSAwCmJ5dGVjXzIgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMAppdG9iCmNvbmNhdApsb2cKcmV0c3ViCgovLyByZWFkb25seV9jYXN0ZXIKcmVhZG9ubHljYXN0ZXJfMjc6CnByb3RvIDAgMAppbnRjXzAgLy8gMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmNhbGxzdWIgcmVhZG9ubHlfMQpyZXRzdWIKCi8vIHNldF9ib3hfY2FzdGVyCnNldGJveGNhc3Rlcl8yODoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAwCnR4bmEgQXBwbGljYXRpb25BcmdzIDIKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAwCmZyYW1lX2RpZyAxCmNhbGxzdWIgc2V0Ym94XzIKcmV0c3ViCgovLyBnZXRfYm94X2Nhc3RlcgpnZXRib3hjYXN0ZXJfMjk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGdldGJveF8zCmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGdldF9ib3hfcmVhZG9ubHlfY2FzdGVyCmdldGJveHJlYWRvbmx5Y2FzdGVyXzMwOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBnZXRib3hyZWFkb25seV80CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIHVwZGF0ZV9jYXN0ZXIKdXBkYXRlY2FzdGVyXzMxOgpwcm90byAwIDAKY2FsbHN1YiB1cGRhdGVfNQpyZXRzdWIKCi8vIHVwZGF0ZV9hcmdzX2Nhc3Rlcgp1cGRhdGVhcmdzY2FzdGVyXzMyOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIHVwZGF0ZWFyZ3NfNwpyZXRzdWIKCi8vIGRlbGV0ZV9jYXN0ZXIKZGVsZXRlY2FzdGVyXzMzOgpwcm90byAwIDAKY2FsbHN1YiBkZWxldGVfOApyZXRzdWIKCi8vIGRlbGV0ZV9hcmdzX2Nhc3RlcgpkZWxldGVhcmdzY2FzdGVyXzM0Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIGRlbGV0ZWFyZ3NfMTAKcmV0c3ViCgovLyBjcmVhdGVfb3B0X2luX2Nhc3RlcgpjcmVhdGVvcHRpbmNhc3Rlcl8zNToKcHJvdG8gMCAwCmNhbGxzdWIgY3JlYXRlb3B0aW5fMTEKcmV0c3ViCgovLyB1cGRhdGVfZ3JlZXRpbmdfY2FzdGVyCnVwZGF0ZWdyZWV0aW5nY2FzdGVyXzM2Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIHVwZGF0ZWdyZWV0aW5nXzEyCnJldHN1YgoKLy8gY3JlYXRlX2Nhc3RlcgpjcmVhdGVjYXN0ZXJfMzc6CnByb3RvIDAgMApjYWxsc3ViIGNyZWF0ZV8xNApyZXRzdWIKCi8vIGNyZWF0ZV9hcmdzX2Nhc3RlcgpjcmVhdGVhcmdzY2FzdGVyXzM4Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIGNyZWF0ZWFyZ3NfMTUKcmV0c3ViCgovLyBoZWxsb19jYXN0ZXIKaGVsbG9jYXN0ZXJfMzk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGhlbGxvXzE2CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGhlbGxvX3JlbWVtYmVyX2Nhc3RlcgpoZWxsb3JlbWVtYmVyY2FzdGVyXzQwOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBoZWxsb3JlbWVtYmVyXzE3CmZyYW1lX2J1cnkgMApieXRlY18yIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGdldF9sYXN0X2Nhc3RlcgpnZXRsYXN0Y2FzdGVyXzQxOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpjYWxsc3ViIGdldGxhc3RfMTgKZnJhbWVfYnVyeSAwCmJ5dGVjXzIgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gb3B0X2luX2Nhc3RlcgpvcHRpbmNhc3Rlcl80MjoKcHJvdG8gMCAwCmNhbGxzdWIgb3B0aW5fMTkKcmV0c3ViCgovLyBvcHRfaW5fYXJnc19jYXN0ZXIKb3B0aW5hcmdzY2FzdGVyXzQzOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApjYWxsc3ViIG9wdGluYXJnc18yMQpyZXRzdWIKCi8vIGNsb3NlX291dF9jYXN0ZXIKY2xvc2VvdXRjYXN0ZXJfNDQ6CnByb3RvIDAgMApjYWxsc3ViIGNsb3Nlb3V0XzIyCnJldHN1YgoKLy8gY2xvc2Vfb3V0X2FyZ3NfY2FzdGVyCmNsb3Nlb3V0YXJnc2Nhc3Rlcl80NToKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKY2FsbHN1YiBjbG9zZW91dGFyZ3NfMjQKcmV0c3ViCgovLyBjYWxsX3dpdGhfcGF5bWVudF9jYXN0ZXIKY2FsbHdpdGhwYXltZW50Y2FzdGVyXzQ2Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgppbnRjXzAgLy8gMAp0eG4gR3JvdXBJbmRleAppbnRjXzEgLy8gMQotCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpndHhucyBUeXBlRW51bQppbnRjXzEgLy8gcGF5Cj09CmFzc2VydApmcmFtZV9kaWcgMQpjYWxsc3ViIGNhbGx3aXRocGF5bWVudF8yNQpmcmFtZV9idXJ5IDAKYnl0ZWNfMiAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3Vi", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDEKY2FsbHN1YiBjbGVhcnN0YXRlXzAKaW50Y18wIC8vIDEKcmV0dXJuCgovLyBjbGVhcl9zdGF0ZQpjbGVhcnN0YXRlXzA6CnByb3RvIDAgMAppbnRjXzAgLy8gMQpyZXR1cm4=" + }, + "state": { + "global": { + "num_byte_slices": 1, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 1, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": { + "greeting": { + "type": "bytes", + "key": "greeting", + "descr": "" + } + }, + "reserved": {} + }, + "local": { + "declared": { + "last": { + "type": "bytes", + "key": "last", + "descr": "" + } + }, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorldApp", + "methods": [ + { + "name": "version", + "args": [], + "returns": { + "type": "uint64" + } + }, + { + "name": "readonly", + "args": [ + { + "type": "uint64", + "name": "error" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "get_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "get_box_readonly", + "args": [ + { + "type": "byte[4]", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "update", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "update_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "delete", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "delete_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "create_opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "update_greeting", + "args": [ + { + "type": "string", + "name": "greeting" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "create", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "create_args", + "args": [ + { + "type": "string", + "name": "greeting" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "hello_remember", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "get_last", + "args": [], + "returns": { + "type": "string" + } + }, + { + "name": "opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "opt_in_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "close_out", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "close_out_args", + "args": [ + { + "type": "string", + "name": "check" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "call_with_payment", + "args": [ + { + "type": "pay", + "name": "payment" + } + ], + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "close_out": "CALL", + "delete_application": "CALL", + "no_op": "CREATE", + "opt_in": "CALL", + "update_application": "CALL" + } +} diff --git a/tests/applications/test_app_manager.py b/tests/applications/test_app_manager.py index 8c9c100..c10508c 100644 --- a/tests/applications/test_app_manager.py +++ b/tests/applications/test_app_manager.py @@ -1,12 +1,12 @@ import pytest + from algokit_utils.applications.app_manager import AppManager from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account - from tests.conftest import check_output_stability -@pytest.fixture() +@pytest.fixture def algorand(funded_account: Account) -> AlgorandClient: client = AlgorandClient.default_local_net() client.set_signer(sender=funded_account.address, signer=funded_account.signer) diff --git a/tests/applications/test_utils.py b/tests/applications/test_utils.py new file mode 100644 index 0000000..2486043 --- /dev/null +++ b/tests/applications/test_utils.py @@ -0,0 +1,16 @@ +from pathlib import Path + +from algokit_utils.applications.utils import arc32_to_arc56 +from tests.utils import load_arc32_spec + +TEST_ARC32_SPEC_FILE_PATH = Path(__file__).parent / "artifacts" / "arc32_app_spec.json" + + +def test_arc32_to_arc56() -> None: + arc32_app_spec = load_arc32_spec( + TEST_ARC32_SPEC_FILE_PATH, deletable=True, updatable=True, template_values={"VERSION": 1} + ) + + arc56_app_spec = arc32_to_arc56(arc32_app_spec) + + assert arc56_app_spec diff --git a/tests/assets/test_asset_manager.py b/tests/assets/test_asset_manager.py index 61e5c25..8d2c6e8 100644 --- a/tests/assets/test_asset_manager.py +++ b/tests/assets/test_asset_manager.py @@ -1,5 +1,7 @@ import algosdk import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner + from algokit_utils import Account, get_account from algokit_utils.assets.asset_manager import ( AccountAssetInformation, @@ -12,22 +14,20 @@ AssetCreateParams, PaymentParams, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner - from tests.conftest import get_unique_name -@pytest.fixture() +@pytest.fixture def sender(funded_account: Account) -> Account: return funded_account -@pytest.fixture() +@pytest.fixture def receiver(algod_client: algosdk.v2client.algod.AlgodClient) -> Account: return get_account(algod_client, get_unique_name()) -@pytest.fixture() +@pytest.fixture def algorand(funded_account: Account) -> AlgorandClient: client = AlgorandClient.default_local_net() client.set_signer(sender=funded_account.address, signer=funded_account.signer) diff --git a/tests/conftest.py b/tests/conftest.py index 18021c2..519a93d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,6 +8,8 @@ import algosdk.transaction import pytest +from dotenv import load_dotenv + from algokit_utils import ( DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -22,8 +24,6 @@ get_kmd_client_from_algod_client, replace_template_variables, ) -from dotenv import load_dotenv - from legacy_v2_tests import app_client_test if TYPE_CHECKING: @@ -142,7 +142,7 @@ def indexer_client() -> "IndexerClient": return get_indexer_client() -@pytest.fixture() +@pytest.fixture def creator(algod_client: "AlgodClient") -> Account: creator_name = get_unique_name() return get_account(algod_client, creator_name) diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py index 5ea937e..ac8fd56 100644 --- a/tests/test_transaction_composer.py +++ b/tests/test_transaction_composer.py @@ -1,6 +1,13 @@ from typing import TYPE_CHECKING import pytest +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -13,27 +20,20 @@ SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: from algokit_utils.transactions.models import Arc2TransactionNote -@pytest.fixture() +@pytest.fixture def algorand(funded_account: Account) -> AlgorandClient: client = AlgorandClient.default_local_net() client.set_signer(sender=funded_account.address, signer=funded_account.signer) return client -@pytest.fixture() +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient) -> Account: secondary_name = get_unique_name() return get_account(algorand.client.algod, secondary_name) diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 619668e..1c9283f 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -3,6 +3,14 @@ import algosdk import pytest +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -16,28 +24,20 @@ SendAtomicTransactionComposerResults, TransactionComposer, ) -from algosdk.transaction import ( - ApplicationCallTxn, - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: from algokit_utils.transactions.models import Arc2TransactionNote -@pytest.fixture() +@pytest.fixture def algorand(funded_account: Account) -> AlgorandClient: client = AlgorandClient.default_local_net() client.set_signer(sender=funded_account.address, signer=funded_account.signer) return client -@pytest.fixture() +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient) -> Account: secondary_name = get_unique_name() return get_account(algorand.client.algod, secondary_name) diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index ec84d65..f6ca31c 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -2,6 +2,18 @@ import algosdk import pytest +from algosdk.transaction import ( + ApplicationCallTxn, + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + AssetDestroyTxn, + AssetFreezeTxn, + AssetTransferTxn, + KeyregTxn, + PaymentTxn, +) + from algokit_utils._legacy_v2.account import get_account from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -19,29 +31,17 @@ OnlineKeyRegistrationParams, PaymentParams, ) -from algosdk.transaction import ( - ApplicationCallTxn, - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - AssetDestroyTxn, - AssetFreezeTxn, - AssetTransferTxn, - KeyregTxn, - PaymentTxn, -) - from legacy_v2_tests.conftest import get_unique_name -@pytest.fixture() +@pytest.fixture def algorand(funded_account: Account) -> AlgorandClient: client = AlgorandClient.default_local_net() client.set_signer(sender=funded_account.address, signer=funded_account.signer) return client -@pytest.fixture() +@pytest.fixture def funded_secondary_account(algorand: AlgorandClient, funded_account: Account) -> Account: secondary_name = get_unique_name() account = get_account(algorand.client.algod, secondary_name) diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index b8514cd..4a24a8c 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -2,6 +2,16 @@ from unittest.mock import MagicMock, patch import pytest +from algosdk.transaction import ( + ApplicationCreateTxn, + AssetConfigTxn, + AssetCreateTxn, + AssetDestroyTxn, + AssetFreezeTxn, + AssetTransferTxn, + PaymentTxn, +) + from algokit_utils import ( Account, get_account, @@ -23,33 +33,23 @@ TransactionComposer, ) from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - AssetDestroyTxn, - AssetFreezeTxn, - AssetTransferTxn, - PaymentTxn, -) - from tests.conftest import get_unique_name if TYPE_CHECKING: import algosdk -@pytest.fixture() +@pytest.fixture def sender(funded_account: Account) -> Account: return funded_account -@pytest.fixture() +@pytest.fixture def receiver(algod_client: "algosdk.v2client.algod.AlgodClient") -> Account: return get_account(algod_client, get_unique_name()) -@pytest.fixture() +@pytest.fixture def transaction_sender( algod_client: "algosdk.v2client.algod.AlgodClient", sender: Account ) -> AlgorandClientTransactionSender: diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..612ea60 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,29 @@ +from pathlib import Path + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME + + +def load_arc32_spec( + path: Path, + *, + updatable: bool | None = None, + deletable: bool | None = None, + template_values: dict | None = None, +) -> ApplicationSpecification: + spec = ApplicationSpecification.from_json(path.read_text(encoding="utf-8")) + + template_variables = template_values or {} + if updatable is not None: + template_variables["UPDATABLE"] = int(updatable) + + if deletable is not None: + template_variables["DELETABLE"] = int(deletable) + + spec.approval_program = ( + AppManager.replace_template_variables(spec.approval_program, template_variables) + .replace(f"// {UPDATABLE_TEMPLATE_NAME}", "// updatable") + .replace(f"// {DELETABLE_TEMPLATE_NAME}", "// deletable") + ) + return spec From c6a7e6e3ebfd486369d08d4493638145cb2d7324 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 12 Nov 2024 22:37:43 +0500 Subject: [PATCH 03/19] chore: wip --- src/algokit_utils/applications/app_client.py | 585 ++++++++++++------ src/algokit_utils/applications/utils.py | 376 +++++++---- src/algokit_utils/clients/algorand_client.py | 4 + src/algokit_utils/clients/client_manager.py | 33 + src/algokit_utils/models/application.py | 208 +++++-- src/algokit_utils/models/state.py | 16 + tests/applications/test_utils.py | 2 +- .../artifacts/hello_world/approval.teal | 0 .../artifacts/hello_world/arc32_app_spec.json | 55 ++ .../artifacts/hello_world/clear.teal | 0 .../legacy_hello_world}/arc32_app_spec.json | 0 tests/conftest.py | 2 +- .../transactions/test_transaction_composer.py | 4 +- .../transactions/test_transaction_creator.py | 4 +- 14 files changed, 930 insertions(+), 359 deletions(-) create mode 100644 src/algokit_utils/models/state.py rename tests/{transactions => }/artifacts/hello_world/approval.teal (100%) create mode 100644 tests/artifacts/hello_world/arc32_app_spec.json rename tests/{transactions => }/artifacts/hello_world/clear.teal (100%) rename tests/{applications/artifacts => artifacts/legacy_hello_world}/arc32_app_spec.json (100%) diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 946b307..61ccdd3 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -1,34 +1,112 @@ from __future__ import annotations +import base64 import json -from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any, Protocol -import algosdk -from algosdk.atomic_transaction_composer import TransactionSigner from algosdk.logic import get_application_address -from algosdk.source_map import SourceMap -from algosdk.transaction import OnComplete - -from algokit_utils._legacy_v2.models import ( - ABIValue, - AppState, - TransactionResponse, -) -from algokit_utils.applications.app_manager import BoxName -from algokit_utils.models.abi import Arc56Contract -from algokit_utils.models.amount import AlgoAmount -from algokit_utils.models.application import ApplicationSpecification -from algokit_utils.protocols.application import AlgorandClientProtocol -from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.utils import get_abi_decoded_value, get_abi_encoded_value +from algokit_utils.models.application import Arc56Contract, StorageKey, StorageMap + +if TYPE_CHECKING: + from collections.abc import Callable + + from algosdk.atomic_transaction_composer import TransactionSigner + from algosdk.source_map import SourceMap + + from algokit_utils.applications.app_manager import AppState + from algokit_utils.models.abi import ABIValue + from algokit_utils.protocols.application import AlgorandClientProtocol + from algokit_utils.transactions.transaction_composer import TransactionComposer + +# TEAL opcodes for constant blocks +BYTE_CBLOCK = 0x20 # bytecblock opcode +INT_CBLOCK = 0x21 # intcblock opcode + + +def get_constant_block_offset(program: bytes) -> int: # noqa: C901 + """Calculate the offset after constant blocks in TEAL program. + + Args: + program: The compiled TEAL program bytes + + Returns: + The maximum offset after bytecblock/intcblock operations + """ + bytes_list = list(program) + program_size = len(bytes_list) + + # Remove version byte + bytes_list.pop(0) + + # Track offsets + bytecblock_offset: int | None = None + intcblock_offset: int | None = None + + while bytes_list: + # Get current byte + byte = bytes_list.pop(0) + + # Check if byte is a constant block opcode + if byte in (BYTE_CBLOCK, INT_CBLOCK): + is_bytecblock = byte == BYTE_CBLOCK + + # Get number of values in constant block + if not bytes_list: + break + values_remaining = bytes_list.pop(0) + + # Process each value in the block + for _ in range(values_remaining): + if is_bytecblock: + # For bytecblock, next byte is length of element + if not bytes_list: + break + length = bytes_list.pop(0) + # Remove the bytes for this element + bytes_list = bytes_list[length:] + else: + # For intcblock, read until we find end of uvarint (MSB not set) + while bytes_list: + byte = bytes_list.pop(0) + if not (byte & 0x80): # Check if MSB is not set + break + + # Update appropriate offset + if is_bytecblock: + bytecblock_offset = program_size - len(bytes_list) - 1 + else: + intcblock_offset = program_size - len(bytes_list) - 1 + + # If next byte isn't a constant block opcode, we're done + if not bytes_list or bytes_list[0] not in (BYTE_CBLOCK, INT_CBLOCK): + break + + # Return maximum offset + return max(bytecblock_offset or 0, intcblock_offset or 0) @dataclass -class AppClientParams: - app_id: int - app_spec: Arc56Contract | ApplicationSpecification | str - algorand: AlgorandClientProtocol +class ProgramSourceInfo: + pc_offset_method: str | None + source_info: list[dict[str, Any]] + + +@dataclass +class ExposedLogicErrorDetails: + is_clear_state_program: bool = False + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + program: bytes | None = None + approval_source_info: ProgramSourceInfo | None = None + clear_source_info: ProgramSourceInfo | None = None + + +@dataclass(kw_only=True) +class _CommonAppClientParams: app_name: str | None = None default_sender: str | None = None default_signer: TransactionSigner | None = None @@ -36,45 +114,217 @@ class AppClientParams: clear_source_map: SourceMap | None = None -@dataclass -class AppClientCompilationParams: - deploy_time_params: dict[str, Any] | None = None - updatable: bool | None = None - deletable: bool | None = None +@dataclass(kw_only=True) +class AppClientParams(_CommonAppClientParams): + app_id: int + app_spec: Arc56Contract | ApplicationSpecification | str + algorand: AlgorandClientProtocol -@dataclass -class AppClientCallParams: - sender: str | None = None - signer: TransactionSigner | None = None - note: bytes | None = None - send_params: dict[str, Any] | None = None - args: dict[str, Any] | None = None - method: str | None = None - on_complete: OnComplete | None = None +@dataclass(kw_only=True) +class CloneAppClientParams(_CommonAppClientParams): + app_id: int | None = None + + +@dataclass(kw_only=True) +class ResolveAppClientByNetwork(_CommonAppClientParams): + app_spec: Arc56Contract | ApplicationSpecification | str + algorand: AlgorandClientProtocol + + +class AppClientStateMethodsProtocol(Protocol): + def get_all(self) -> dict[str, Any]: ... + + def get_value(self, name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: ... + + def get_map_value(self, map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: ... # noqa: ANN401 + + def get_map(self, map_name: str) -> dict[str, ABIValue]: ... + + +class _AppClientStateMethods(AppClientStateMethodsProtocol): + def __init__( + self, + *, + get_all: Callable[[], dict[str, Any]], + get_value: Callable[[str, dict[str, AppState] | None], ABIValue | None], + get_map_value: Callable[[str, bytes | Any, dict[str, AppState] | None], Any], + get_map: Callable[[str], dict[str, ABIValue]], + ) -> None: + self._get_all = get_all + self._get_value = get_value + self._get_map_value = get_map_value + self._get_map = get_map + + def get_all(self) -> dict[str, Any]: + return self._get_all() + + def get_value(self, name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: + return self._get_value(name, app_state) + + def get_map_value(self, map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: # noqa: ANN401 + return self._get_map_value(map_name, key, app_state) + + def get_map(self, map_name: str) -> dict[str, ABIValue]: + return self._get_map(map_name) + + +class AppClientStateAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + def local_state(self, address: str) -> AppClientStateMethodsProtocol: + """Methods to access local state for the current app for a given address""" + return self._get_state_methods( + state_getter=lambda: self._algorand.app.get_local_state(self._app_id, address), + key_getter=lambda: self._app_spec.state.keys.get("local", {}), + map_getter=lambda: self._app_spec.state.maps.get("local", {}), + ) + + @property + def global_state(self) -> AppClientStateMethodsProtocol: + """Methods to access global state for the current app""" + return self._get_state_methods( + state_getter=lambda: self._algorand.app.get_global_state(self._app_id), + key_getter=lambda: self._app_spec.state.keys.get("global", {}), + map_getter=lambda: self._app_spec.state.maps.get("global", {}), + ) + + # @property + # def box(self) -> AppClientStateMethods: + # """Methods to access box storage for the current app""" + # return self._get_state_methods( + # state_getter=lambda: self._algorand.app.get_box_state(self._app_id), + # key_getter=lambda: self._app_spec.state.keys.get("box", {}), + # map_getter=lambda: self._app_spec.state.maps.get("box", {}), + # ) + + def _get_state_methods( # noqa: C901 + self, + state_getter: Callable[[], dict[str, AppState]], + key_getter: Callable[[], dict[str, StorageKey]], + map_getter: Callable[[], dict[str, StorageMap]], + ) -> AppClientStateMethodsProtocol: + def get_all() -> dict[str, Any]: + state = state_getter() + keys = key_getter() + return {key: get_value(key, state) for key in keys} + + def get_value(name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: + state = app_state or state_getter() + key_info = key_getter()[name] + value = next((s for s in state.values() if s.key_base64 == key_info.key), None) + + if value and value.value_raw: + return get_abi_decoded_value(value.value_raw, key_info.value_type, self._app_spec.structs) + + return None + + def get_map_value(map_name: str, key: bytes | Any, app_state: dict[str, AppState] | None = None) -> Any: # noqa: ANN401 + state = app_state or state_getter() + metadata = map_getter()[map_name] + + prefix = bytes(metadata.prefix or "", "base64") + encoded_key = get_abi_encoded_value(key, metadata.key_type, self._app_spec.structs) + full_key = base64.b64encode(prefix + encoded_key).decode("utf-8") + value = next((s for s in state.values() if s.key_base64 == full_key), None) + if value and value.value_raw: + return get_abi_decoded_value(value.value_raw, metadata.value_type, self._app_spec.structs) + return None + + def get_map(map_name: str) -> dict[str, ABIValue]: + state = state_getter() + metadata = map_getter()[map_name] + + prefix = metadata.prefix or "" + + prefixed_state = {k: v for k, v in state.items() if k.startswith(prefix)} + + decoded_map = {} + + for key_encoded, value in prefixed_state.items(): + key_bytes = key_encoded[len(prefix) :] + try: + decoded_key = get_abi_decoded_value(key_bytes, metadata.key_type, self._app_spec.structs) + except Exception as e: + raise ValueError(f"Failed to decode key {key_encoded}") from e + + try: + if value and value.value_raw: + decoded_value = get_abi_decoded_value( + value.value_raw, metadata.value_type, self._app_spec.structs + ) + else: + decoded_value = get_abi_decoded_value(value.value, metadata.value_type, self._app_spec.structs) + except Exception as e: + raise ValueError(f"Failed to decode value {value}") from e + + decoded_map[str(decoded_key)] = decoded_value + + return decoded_map + + return _AppClientStateMethods( + get_all=get_all, + get_value=get_value, + get_map_value=get_map_value, + get_map=get_map, + ) + + def get_local_state(self, address: str) -> dict[str, AppState]: + return self._algorand.app.get_local_state(self._app_id, address) + + def get_global_state(self) -> dict[str, AppState]: + return self._algorand.app.get_global_state(self._app_id) class AppClient: def __init__(self, params: AppClientParams) -> None: self._app_id = params.app_id - self._app_spec = self.normalize_app_spec(params.app_spec) + self._app_spec = self.normalise_app_spec(params.app_spec) self._algorand = params.algorand + self._app_address = get_application_address(self._app_id) self._app_name = params.app_name or self._app_spec.name self._default_sender = params.default_sender self._default_signer = params.default_signer self._approval_source_map = params.approval_source_map self._clear_source_map = params.clear_source_map - self._app_address = get_application_address(self._app_id) + self._state_accessor = AppClientStateAccessor(self) + + @property + def app_id(self) -> int: + return self._app_id + + @property + def app_address(self) -> str: + return self._app_address + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def state(self) -> AppClientStateAccessor: + return self._state_accessor @staticmethod - def normalize_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: + def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: if isinstance(app_spec, str): spec = json.loads(app_spec) + if "hints" in spec: + spec = ApplicationSpecification.from_json(app_spec) else: spec = app_spec if isinstance(spec, Arc56Contract): return spec + elif isinstance(spec, ApplicationSpecification): # Convert ARC-32 to ARC-56 from algokit_utils.applications.utils import arc32_to_arc56 @@ -85,49 +335,91 @@ def normalize_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) else: raise ValueError("Invalid app spec format") - @property - def app_id(self) -> int: - return self._app_id + @staticmethod + def from_network(params: ResolveAppClientByNetwork) -> AppClient: + network = params.algorand.client.network() + app_spec = AppClient.normalise_app_spec(params.app_spec) + network_names = [network.genesis_hash] + + if network.is_local_net: + network_names.append("localnet") + if network.is_main_net: + network_names.append("mainnet") + if network.is_test_net: + network_names.append("testnet") + + available_app_spec_networks = list(app_spec.networks.keys()) if app_spec.networks else [] + network_index = next((i for i, n in enumerate(available_app_spec_networks) if n in network_names), None) + + if network_index is None: + raise Exception(f"No app ID found for network {json.dumps(network_names)} in the app spec") + + app_id = app_spec.networks[available_app_spec_networks[network_index]]["app_id"] # type: ignore[index] + + input_params = params.__dict__ + input_params["app_id"] = app_id + input_params["app_spec"] = app_spec + + return AppClient(AppClientParams(**input_params)) # type:ignore[arg-type, call-arg] + + # public static async fromNetwork(params: ResolveAppClientByNetwork): Promise { + # const network = await params.algorand.client.network() + # const appSpec = AppClient.normaliseAppSpec(params.appSpec) + # const networkNames = [network.genesisHash] + # if (network.isLocalNet) networkNames.push('localnet') + # if (network.isTestNet) networkNames.push('testnet') + # if (network.isMainNet) networkNames.push('mainnet') + # const availableAppSpecNetworks = Object.keys(appSpec.networks ?? {}) + # const networkIndex = availableAppSpecNetworks.findIndex((n) => networkNames.includes(n)) + + # if (networkIndex === -1) { + # throw new Error(`No app ID found for network ${JSON.stringify(networkNames)} in the app spec`) + # } + + # const appId = BigInt(appSpec.networks![networkIndex].appID) + # return new AppClient({ ...params, appId, appSpec }) + # } + + def clone(self, params: CloneAppClientParams) -> AppClient: + default_params = { + "app_id": self._app_id, + "algorand": self._algorand, + "app_spec": self._app_spec, + "app_name": self._app_name, + "default_sender": self._default_sender, + "default_signer": self._default_signer, + "approval_source_map": self._approval_source_map, + "clear_source_map": self._clear_source_map, + } - @property - def app_address(self) -> str: - return self._app_address + for k, v in params.__dict__.items(): + if k and v: + default_params[k] = v - @property - def app_name(self) -> str: - return self._app_name + return AppClient(AppClientParams(**default_params)) # type: ignore[arg-type] - @property - def app_spec(self) -> Arc56Contract: - return self._app_spec + # public clone(params: CloneAppClientParams) { + # return new AppClient({ + # appId: this._appId, + # appSpec: this._appSpec, + # algorand: this._algorand, + # appName: this._appName, + # defaultSender: this._defaultSender, + # defaultSigner: this._defaultSigner, + # approvalSourceMap: this._approvalSourceMap, + # clearSourceMap: this._clearSourceMap, + # ...params, + # }) + # } - @property - def algorand(self) -> AlgorandClientProtocol: - return self._algorand - - def clone(self, **params: Any) -> AppClient: - return AppClient( - AppClientParams( - app_id=params.get("app_id", self._app_id), - app_spec=self._app_spec, - algorand=self.algorand, - app_name=params.get("app_name", self._app_name), - default_sender=params.get("default_sender", self._default_sender), - default_signer=params.get("default_signer", self._default_signer), - approval_source_map=params.get("approval_source_map", self._approval_source_map), - clear_source_map=params.get("clear_source_map", self._clear_source_map), - ) - ) + def get_local_state(self, address: str) -> dict[str, AppState]: + return self._state_accessor.get_local_state(address) - def new_group(self) -> TransactionComposer: - return self.algorand.new_group() + def get_global_state(self) -> dict[str, AppState]: + return self._state_accessor.get_global_state() - def _get_sender(self, sender: str | None = None) -> str: - if not sender and not self._default_sender: - raise ValueError( - f"No sender provided and no default sender present in app client for call to app {self._app_name}" - ) - return sender or self._default_sender # type: ignore[return-value] + def new_group(self) -> TransactionComposer: + return self._algorand.new_group() def _get_signer( self, @@ -136,110 +428,39 @@ def _get_signer( ) -> TransactionSigner | None: return signer or (None if sender else self._default_signer) - def fund_app_account(self, amount: AlgoAmount, params: AppClientCallParams | None = None) -> None: - sender = self._get_sender(params.sender if params else None) - signer = self._get_signer(sender, params.signer if params else None) - payment_params = PaymentParams( - sender=sender, - signer=signer, - receiver=self.app_address, - amount=amount, - note=params.note if params else None, - **(params.send_params if params and params.send_params else {}), - ) - self._algorand.send.payment(payment_params) - - def get_global_state(self) -> AppState: - return self._algorand.app.get_global_state(self.app_id) - - def get_local_state(self, address: str) -> AppState: - return self._algorand.app.get_local_state(self.app_id, address) - - def get_box_names(self) -> list[BoxName]: - return self._algorand.app.get_box_names(self.app_id) - - def get_box_value(self, name: bytes) -> bytes: - return self._algorand.app.get_box_value(self.app_id, name) - - def get_box_value_from_abi_type(self, name: bytes, abi_type: algosdk.abi.ABIType) -> ABIValue: - return self._algorand.app.get_box_value_from_abi_type(self.app_id, name, abi_type) - - def compile(self, params: AppClientCompilationParams | None = None) -> None: - # Implement compilation logic here - pass - - def create(self, params: AppClientCallParams | None = None) -> TransactionResponse: - # Implement create logic here - pass - - def update(self, params: AppClientCallParams | None = None) -> TransactionResponse: - # Implement update logic here - pass - - def delete(self, params: AppClientCallParams | None = None) -> TransactionResponse: - # Implement delete logic here - pass - - def call(self, params: AppClientCallParams | None = None) -> Any: - # Implement call logic here - pass - - def _handle_call_errors(self, call: Callable[[], Awaitable[Any]]) -> Any: - try: - return call() - except Exception as e: - raise self._expose_logic_error(e) - - def _expose_logic_error(self, e: Exception) -> Exception: - # Implement logic error exposure, possibly augmenting the exception with debugging info - return e - - def get_abi_methods(self) -> list[dict[str, Any]]: - return self._app_spec.contract.methods - - def process_method_call_return(self, result: Any, method: Any) -> Any: - # Process the result of an ABI method call - pass - - def get_state_methods(self): - def get_all(): - state = self.get_global_state() - keys = self._app_spec.state.keys["global"] - result = {} - for key in keys: - result[key] = get_value(key, state) - return result - - def get_value(name: str, state: AppState | None = None): - state = state or self.get_global_state() - key_info = self._app_spec.state.keys["global"][name] - value = state.get(key_info["key"]) - if value is not None: - return value # Decode using ABI if necessary - return None - - def get_map_value(map_name: str, key: Any, state: AppState | None = None): - state = state or self.get_global_state() - map_info = self._app_spec.state.maps["global"][map_name] - full_key = map_info["prefix"] + key - value = state.get(full_key) - if value is not None: - return value # Decode using ABI if necessary - return None + def _get_sender(self, sender: str | None = None) -> str: + if not sender and not self._default_sender: + raise ValueError( + f"No sender provided and no default sender present in app client for call to app {self._app_name}" + ) + return sender or self._default_sender # type: ignore[return-value] - def get_map(map_name: str): - state = self.get_global_state() - map_info = self._app_spec.state.maps["global"][map_name] - result = {} - for key, value in state.items(): - if key.startswith(map_info["prefix"]): - map_key = key[len(map_info["prefix"]) :] - result[map_key] = value # Decode using ABI if necessary - return result - - return { - "get_all": get_all, - "get_value": get_value, - "get_map_value": get_map_value, - "get_map": get_map, - } + # def _handle_call_errors(self, call: Callable[[], Any]) -> Any: + # try: + # return call() + # except Exception as e: + # raise self._expose_logic_error(e) from e + + # def _expose_logic_error(self, e: Exception) -> Exception: + # # Add source map info if available + # if hasattr(e, "program_counter") and self._approval_source_map: + # pc = e.program_counter # type: ignore[attr-defined] + # line = self._approval_source_map.get_line_for_pc(pc) + # e.source_line = line # type: ignore[attr-defined] + # return e + + # def get_abi_method(self, method_name: str) -> dict[str, Any]: + # for method in self._app_spec.contract.methods: + # if method["name"] == method_name: + # return method + # raise ValueError(f"Method {method_name} not found") + + # def process_method_call_return(self, result: Any, method: dict[str, Any]) -> Any: + # if not result or "return" not in result: + # return result + + # return_type = method.get("returns", {}).get("type") + # if not return_type: + # return result + + # return self._decode_abi_value(result["return"], return_type) diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 3217a5e..1888ab4 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -1,25 +1,134 @@ -from base64 import b64encode -from typing import Any, Literal, cast +import base64 +from typing import TYPE_CHECKING, Literal, Union -import algosdk -from algosdk.abi import Method +from algosdk.abi import Method as AlgorandABIMethod from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, - CallConfig, + AppSpecStateDict, DefaultArgumentDict, MethodConfigDict, MethodHints, - OnCompleteActionName, ) +from algokit_utils.models.abi import ABIValue from algokit_utils.models.application import ( Arc56Contract, - Arc56Method, - Arc56MethodArg, - Arc56State, + Arc56ContractState, + ARCType, + CallConfig, + DefaultValue, + Method, + MethodActions, + MethodArg, + MethodReturns, + OnCompleteAction, StorageKey, + StructField, + StructName, ) +if TYPE_CHECKING: + import algosdk + +from typing import Any + +from algosdk.abi import ABIType, TupleType + + +def get_abi_encoded_value(value: Any, type_str: str, structs: dict[str, list[StructField]]) -> bytes: # noqa: ANN401, PLR0911 + if isinstance(value, (bytes | bytearray)): + return value + if type_str == "AVMUint64": + return ABIType.from_string("uint64").encode(value) + if type_str in ("AVMBytes", "AVMString"): + if isinstance(value, str): + return value.encode("utf-8") + if not isinstance(value, (bytes | bytearray)): + raise ValueError(f"Expected bytes value for {type_str}, but got {type(value)}") + return value + if type_str in structs: + tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_str], structs) + if isinstance(value, (list | tuple)): + return tuple_type.encode(value) # type: ignore[arg-type] + else: + tuple_values = get_abi_tuple_from_abi_struct(value, structs[type_str], structs) + return tuple_type.encode(tuple_values) + else: + abi_type = ABIType.from_string(type_str) + return abi_type.encode(value) + + +def get_abi_decoded_value(value: bytes | int | str, type_str: str, structs: dict[str, list[StructField]]) -> ABIValue: + if type_str == "AVMBytes" or not isinstance(value, bytes): + return value + if type_str == "AVMString": + return value.decode("utf-8") + if type_str == "AVMUint64": + return ABIType.from_string("uint64").decode(value) # type: ignore[no-any-return] + if type_str in structs: + tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_str], structs) + decoded_tuple = tuple_type.decode(value) + return get_abi_struct_from_abi_tuple(decoded_tuple, structs[type_str], structs) + return ABIType.from_string(type_str).decode(value) # type: ignore[no-any-return] + + +def get_abi_tuple_from_abi_struct( + struct_value: dict[str, Any], + struct_fields: list[StructField], + structs: dict[str, list[StructField]], +) -> list[Any]: + result = [] + for field in struct_fields: + key = field.name + if key not in struct_value: + raise ValueError(f"Missing value for field '{key}'") + value = struct_value[key] + field_type = field.type_ + if isinstance(field_type, str): + if field_type in structs: + value = get_abi_tuple_from_abi_struct(value, structs[field_type], structs) + elif isinstance(field_type, list): + value = get_abi_tuple_from_abi_struct(value, field_type, structs) + result.append(value) + return result + + +def get_abi_tuple_type_from_abi_struct_definition( + struct_def: list[StructField], structs: dict[str, list[StructField]] +) -> TupleType: + types = [] + for field in struct_def: + field_type = field.type_ + if isinstance(field_type, str): + if field_type in structs: + types.append(get_abi_tuple_type_from_abi_struct_definition(structs[field_type], structs)) + else: + types.append(ABIType.from_string(field_type)) # type: ignore[arg-type] + elif isinstance(field_type, list): + types.append(get_abi_tuple_type_from_abi_struct_definition(field_type, structs)) + else: + raise ValueError(f"Invalid field type: {field_type}") + return TupleType(types) + + +def get_abi_struct_from_abi_tuple( + decoded_tuple: Any, # noqa: ANN401 + struct_fields: list[StructField], + structs: dict[str, list[StructField]], +) -> dict[str, Any]: + result = {} + for i, field in enumerate(struct_fields): + key = field.name + field_type = field.type_ + value = decoded_tuple[i] + if isinstance(field_type, str): + if field_type in structs: + value = get_abi_struct_from_abi_tuple(value, structs[field_type], structs) + elif isinstance(field_type, list): + value = get_abi_struct_from_abi_tuple(value, field_type, structs) + result[key] = value + return result + def arc32_to_arc56(app_spec: ApplicationSpecification) -> Arc56Contract: # noqa: C901 """ @@ -32,66 +141,57 @@ def arc32_to_arc56(app_spec: ApplicationSpecification) -> Arc56Contract: # noqa ARC-56 contract specification """ - def convert_structs() -> dict[str, list[dict[str, str]]]: - structs = {} + def convert_structs() -> dict[StructName, list[StructField]]: + structs: dict[StructName, list[StructField]] = {} for hint in app_spec.hints.values(): if not hint.structs: continue for struct in hint.structs.values(): - fields = [{"name": name, "type": type_} for name, type_ in struct["elements"]] + fields = [ + StructField( + name=name, + type_=type_, + ) + for name, type_ in struct["elements"] + ] structs[struct["name"]] = fields return structs - def get_hint(method: Method) -> MethodHints | None: + def get_hint(method: AlgorandABIMethod) -> MethodHints | None: sig = method.get_signature() return app_spec.hints.get(sig) - def convert_actions(call_config: MethodConfigDict, action_type: Literal["CREATE", "CALL"]) -> list[str]: - actions: list[str] = [] - action_map: dict[OnCompleteActionName, str] = { - "close_out": "CloseOut", - "delete_application": "DeleteApplication", - "no_op": "NoOp", - "opt_in": "OptIn", - "update_application": "UpdateApplication", - } - - for config_key, action_name in action_map.items(): - config_value = call_config.get(config_key, CallConfig.NEVER) - if ( - config_value == CallConfig.ALL - or (config_value == CallConfig.CREATE and action_type == "CREATE") - or (config_value == CallConfig.CALL and action_type == "CALL") - ): - actions.append(action_name) - - return actions - def get_default_value( - type_: str | algosdk.abi.ABIType, default_arg: DefaultArgumentDict - ) -> dict[str, str | int] | None: + type_: Union[str, "algosdk.abi.ABIType"], + default_arg: DefaultArgumentDict, + ) -> DefaultValue | None: if not default_arg or default_arg["source"] == "abi-method": return None - source_map = {"constant": "literal", "global-state": "global", "local-state": "local"} + source_map = { + "constant": "literal", + "global-state": "global", + "local-state": "local", + } data = default_arg["data"] if isinstance(data, str): - data = b64encode(data.encode()).decode() - - return cast( - dict[str, str | int], - { - "source": source_map[default_arg["source"]], - "data": data, - "type": "AVMString" if type_ == "string" else str(type_), - }, + data = base64.b64encode(data.encode()).decode() + elif isinstance(data, bytes): + data = base64.b64encode(data).decode() + else: + data = str(data) + + return DefaultValue( + data=data, + type_="AVMString" if type_ == "string" else str(type_), + source=source_map.get(default_arg["source"], "literal"), # type: ignore[arg-type] ) - def convert_method(method: Method) -> Arc56Method: + def convert_method(method: AlgorandABIMethod) -> Method: hint = get_hint(method) - args: list[Arc56MethodArg] = [] + args: list[MethodArg] = [] for arg in method.args: if not arg.name: continue @@ -101,103 +201,133 @@ def convert_method(method: Method) -> Arc56Method: default_value = None if hint and hint.default_arguments and arg.name in hint.default_arguments: - default_value = get_default_value(arg.type, hint.default_arguments[arg.name]) - - args.append( - cast( - Arc56MethodArg, - { - "name": arg.name, - "type": arg.type, - "desc": arg.desc, - "struct": struct_name, - "defaultValue": default_value, - }, - ) + default_value = get_default_value(str(arg.type), hint.default_arguments[arg.name]) + + method_arg = MethodArg( + type_=str(arg.type), + struct=struct_name, + name=arg.name, + desc=arg.desc, + default_value=default_value, ) + args.append(method_arg) - return { - "name": method.name, - "desc": method.desc, - "args": args, - "returns": { - "type": method.returns.type, - "desc": method.returns.desc, - "struct": hint.structs.get("output", {}).get("name") # type: ignore[call-overload] - if hint and hint.structs - else None, - }, - "events": [], - "readonly": hint.read_only if hint else None, - "actions": { - "create": convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], - "call": convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], - }, - } + method_returns = MethodReturns( + type_=str(method.returns.type), + struct=hint.structs.get("output", {}).get("name") if hint and hint.structs else None, # type: ignore[call-overload] + desc=method.returns.desc, + ) + + method_actions = MethodActions( + create=convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], # type: ignore[arg-type] + call=convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], # type: ignore[arg-type] + ) + + return Method( + name=method.name, + desc=method.desc, + args=args, + returns=method_returns, + actions=method_actions, + readonly=hint.read_only if hint else False, + events=[], + recommendations=None, + ) - def convert_storage_keys(schema_dict: dict[str, Any]) -> dict[str, StorageKey]: + def convert_storage_keys(schema_dict: AppSpecStateDict) -> dict[str, StorageKey]: return { - name: cast( - StorageKey, - { - "key": b64encode(spec["key"].encode()).decode(), - "keyType": "AVMString", - "valueType": "AVMUint64" if spec["type"] == "uint64" else "AVMBytes", - "desc": spec.get("descr"), - }, + name: StorageKey( + desc=spec.get("descr"), + key_type=spec["type"], + value_type="AVMUint64" if spec["type"] == "uint64" else "AVMBytes", + key=base64.b64encode(spec["key"].encode()).decode(), ) - for name, spec in schema_dict["declared"].items() + for name, spec in schema_dict.get("declared", {}).items() } + def convert_actions( + call_config: CallConfig | MethodConfigDict, action_type: Literal["CREATE", "CALL"] + ) -> list[OnCompleteAction | Literal["NoOp", "OptIn", "DeleteApplication"]]: + """ + Converts method configuration into a list of on-complete action literals. + + Args: + call_config (MethodConfigDict): Configuration dictionary for method actions. + action_type (Literal["CREATE", "CALL"]): The type of action to convert. + + Returns: + List[Literal['NoOp', 'OptIn', 'DeleteApplication']]: A list of on-complete action literals. + """ + + config_action_map: dict[str, OnCompleteAction] = { + "no_op": "NoOp", + "opt_in": "OptIn", + "close_out": "CloseOut", + "clear_state": "ClearState", + "update_application": "UpdateApplication", + "delete_application": "DeleteApplication", + } + + return [ + action + for key, action in config_action_map.items() + if hasattr(call_config, key) and getattr(call_config, key) in ("ALL", action_type) + ] + + # Convert structs + structs = convert_structs() + # Get schema information from app_spec global_schema = app_spec.schema.get("global", {}) local_schema = app_spec.schema.get("local", {}) - # TODO: remove cast - state: Arc56State = cast( - Arc56State, - { - "schema": { - "global": { - "ints": app_spec.global_state_schema.num_uints, - "bytes": app_spec.global_state_schema.num_byte_slices, - }, - "local": { - "ints": app_spec.local_state_schema.num_uints, - "bytes": app_spec.local_state_schema.num_byte_slices, - }, + state = Arc56ContractState( + schemas={ + "global": { + "ints": app_spec.global_state_schema.num_uints, # type: ignore[attr-defined] + "bytes": app_spec.global_state_schema.num_byte_slices, # type: ignore[attr-defined] }, - "keys": { - "global": convert_storage_keys(global_schema), - "local": convert_storage_keys(local_schema), - "box": {}, + "local": { + "ints": app_spec.local_state_schema.num_uints, # type: ignore[attr-defined] + "bytes": app_spec.local_state_schema.num_byte_slices, # type: ignore[attr-defined] }, - "maps": {"global": {}, "local": {}, "box": {}}, }, - ) - - bare_actions = cast( - dict[Literal["create", "call"], list[str]], - { - "create": convert_actions(app_spec.bare_call_config, "CREATE"), - "call": convert_actions(app_spec.bare_call_config, "CALL"), + keys={ + "global": convert_storage_keys(global_schema), + "local": convert_storage_keys(local_schema), + "box": {}, + }, + maps={ + "global": {}, + "local": {}, + "box": {}, }, ) + contract_source = { + "approval": app_spec.approval_program, + "clear": app_spec.clear_program, + } + + bare_actions = { + "create": convert_actions(app_spec.bare_call_config, "CREATE"), + "call": convert_actions(app_spec.bare_call_config, "CALL"), + } + return Arc56Contract( - arcs=[], + arcs=[ARCType.ARC56], name=app_spec.contract.name, desc=app_spec.contract.desc, - structs=convert_structs(), + structs=structs, methods=[convert_method(m) for m in app_spec.contract.methods], state=state, - source={"approval": app_spec.approval_program, "clear": app_spec.clear_program}, - bareActions=bare_actions, - byteCode=None, - compilerInfo=None, + source=contract_source, + bare_actions=bare_actions, + byte_code=None, + compiler_info=None, events=None, networks=None, - scratchVariables=None, - sourceInfo=None, - templateVariables=None, + scratch_variables=None, + source_info=None, + template_variables=None, ) diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index f679c95..6eaf850 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -162,6 +162,10 @@ def asset(self) -> AssetManager: """Get or create assets.""" return self._asset_manager + @property + def app(self) -> AppManager: + return self._app_manager + @property def app_deployer(self) -> AppManager: """Get or create applications.""" diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index 1610852..8a4161b 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass + import algosdk from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient @@ -24,6 +26,19 @@ def __init__( self.kmd = kmd +@dataclass(kw_only=True) +class NetworkDetail: + is_test_net: bool + is_main_net: bool + is_local_net: bool + genesis_id: str + genesis_hash: str + + +def genesis_id_is_localnet(genesis_id: str) -> bool: + return genesis_id in ["devnet-v1", "sandnet-v1", "dockernet-v1"] + + class ClientManager: def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): if isinstance(clients_or_configs, AlgoSdkClients): @@ -52,6 +67,10 @@ def indexer(self) -> IndexerClient: raise ValueError("Attempt to use Indexer client in AlgoKit instance with no Indexer configured") return self._indexer + @property + def indexer_if_present(self) -> IndexerClient | None: + return self._indexer + @property def kmd(self) -> KMDClient: """Returns an algosdk KMD API client or raises an error if it's not been provided.""" @@ -59,6 +78,16 @@ def kmd(self) -> KMDClient: raise ValueError("Attempt to use Kmd client in AlgoKit instance with no Kmd configured") return self._kmd + def network(self) -> NetworkDetail: + sp = self.algod.suggested_params() # TODO: cache it + return NetworkDetail( + is_test_net=sp.gen in ["testnet-v1.0", "testnet-v1", "testnet"], + is_main_net=sp.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"], + is_local_net=ClientManager.genesis_id_is_local_net(str(sp.gen)), + genesis_id=str(sp.gen), + genesis_hash=sp.gh, + ) + def get_testnet_dispenser( self, auth_token: str | None = None, request_timeout: int | None = None ) -> TestNetDispenserApiClient: @@ -66,3 +95,7 @@ def get_testnet_dispenser( return TestNetDispenserApiClient(auth_token=auth_token, request_timeout=request_timeout) return TestNetDispenserApiClient(auth_token=auth_token) + + @staticmethod + def genesis_id_is_local_net(genesis_id: str) -> bool: + return genesis_id_is_localnet(genesis_id) diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 1a79e40..f2149b4 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,6 +1,6 @@ -from typing import Literal, TypedDict - -import algosdk +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Literal UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" @@ -11,64 +11,176 @@ # ===== ARCs ===== +# Define type aliases +ABITypeAlias = str +StructName = str +AVMBytes = Literal["AVMBytes"] +AVMString = Literal["AVMString"] +AVMUint64 = Literal["AVMUint64"] +AVMType = AVMBytes | AVMString | AVMUint64 +OnCompleteAction = Literal["NoOp", "OptIn", "CloseOut", "ClearState", "UpdateApplication", "DeleteApplication"] +DefaultValueSource = Literal["box", "global", "local", "literal", "method"] -# Type definitions -class StorageKey(TypedDict): - key: str # base64 encoded - keyType: Literal["AVMString"] - valueType: Literal["AVMUint64", "AVMBytes"] - desc: str | None +@dataclass +class CallConfig: + no_op: str | None = None + opt_in: str | None = None + close_out: str | None = None + clear_state: str | None = None + update_application: str | None = None + delete_application: str | None = None -class SchemaSpec(TypedDict): - num_uints: int - num_byte_slices: int +class ARCType(IntEnum): + ARC56 = 56 + ARC32 = 32 -class Arc56State(TypedDict): - schema: dict[Literal["global", "local"], dict[Literal["ints", "bytes"], int]] - keys: dict[Literal["global", "local", "box"], dict[str, StorageKey]] - maps: dict[Literal["global", "local", "box"], dict] +@dataclass(kw_only=True) +class StructField: + name: str + type_: ABITypeAlias | StructName | list["StructField"] -class Arc56MethodArg(TypedDict): - name: str | None - type: str | algosdk.abi.ABIType + +@dataclass(kw_only=True) +class StorageKey: desc: str | None - struct: str | None - defaultValue: dict[str, str | int] | None + key_type: ABITypeAlias | AVMType | StructName + value_type: ABITypeAlias | AVMType | StructName + key: str # base64 encoded bytes -class Arc56MethodReturn(TypedDict): - type: str | algosdk.abi.ABIType +@dataclass(kw_only=True) +class StorageMap: desc: str | None - struct: str | None + key_type: ABITypeAlias | AVMType | StructName + value_type: ABITypeAlias | AVMType | StructName + prefix: str | None # base64-encoded prefix -class Arc56Method(TypedDict): - name: str | None - desc: str | None - args: list[Arc56MethodArg] - returns: Arc56MethodReturn - events: list[str] # Empty for now as per original - readonly: bool | None - actions: dict[Literal["create", "call"], list[str]] +@dataclass(kw_only=True) +class DefaultValue: + data: str + type_: ABITypeAlias | AVMType | None = None + source: DefaultValueSource + + +@dataclass(kw_only=True) +class MethodArg: + type_: ABITypeAlias + struct: StructName | None = None + name: str | None = None + desc: str | None = None + default_value: DefaultValue | None = None + + +@dataclass +class MethodReturns: + type_: ABITypeAlias + struct: StructName | None = None + desc: str | None = None -class Arc56Contract(TypedDict): - arcs: list[str] # Empty as per original +@dataclass(kw_only=True) +class MethodActions: + create: list[Literal["NoOp", "OptIn", "DeleteApplication"]] + call: list[Literal["NoOp", "OptIn", "CloseOut", "ClearState", "UpdateApplication", "DeleteApplication"]] + + +@dataclass(kw_only=True) +class BoxRecommendation: + app: int | None = None + key: str = "" + read_bytes: int = 0 + write_bytes: int = 0 + + +@dataclass(kw_only=True) +class Recommendations: + inner_transaction_count: int | None = None + boxes: list[BoxRecommendation] | None = None + accounts: list[str] | None = None + apps: list[int] | None = None + assets: list[int] | None = None + + +@dataclass(kw_only=True) +class Method: name: str - desc: str | None - structs: dict[str, list[dict[str, str]]] - methods: list[Arc56Method] - state: Arc56State - source: dict[str, str] - bareActions: dict[Literal["create", "call"], list[str]] - # Following fields are undefined as per original - byteCode: None - compilerInfo: None - events: None - networks: None - scratchVariables: None - sourceInfo: None - templateVariables: None + desc: str | None = None + args: list[MethodArg] = field(default_factory=list) + returns: MethodReturns = field(default_factory=lambda: MethodReturns(type_="void")) + actions: MethodActions = field(default_factory=lambda: MethodActions(create=[], call=[])) + readonly: bool | None = False + events: list["Event"] | None = None + recommendations: Recommendations | None = None + + +@dataclass(kw_only=True) +class EventArg: + type_: ABITypeAlias + name: str | None = None + desc: str | None = None + struct: StructName | None = None + + +@dataclass(kw_only=True) +class Event: + name: str + desc: str | None = None + args: list[EventArg] = field(default_factory=list) + + +@dataclass(kw_only=True) +class CompilerVersion: + major: int + minor: int + patch: int + commit_hash: str | None = None + + +@dataclass(kw_only=True) +class CompilerInfo: + compiler: Literal["algod", "puya"] + compiler_version: CompilerVersion + + +@dataclass +class SourceInfoDetail: + pc: list[int] + error_message: str | None = None + teal: int | None = None + source: str | None = None + + +@dataclass(kw_only=True) +class ProgramSourceInfo: + source_info: list[SourceInfoDetail] + pc_offset_method: Literal["none", "cblocks"] + + +@dataclass(kw_only=True) +class Arc56ContractState: + keys: dict[str, dict[str, StorageKey]] + maps: dict[str, dict[str, StorageMap]] + schemas: dict[str, dict[str, int]] + + +@dataclass(kw_only=True) +class Arc56Contract: + arcs: list[ARCType] + name: str + desc: str | None = None + networks: dict[str, dict[str, int]] | None = None + structs: dict[StructName, list[StructField]] = field(default_factory=dict) + methods: list[Method] = field(default_factory=list) + state: Arc56ContractState + bare_actions: dict[str, list[OnCompleteAction]] = field(default_factory=dict) + source_info: dict[str, ProgramSourceInfo] | None = None + source: dict[str, str] | None = None + byte_code: dict[str, str] | None = None + compiler_info: CompilerInfo | None = None + events: list[Event] | None = None + template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None + scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None diff --git a/src/algokit_utils/models/state.py b/src/algokit_utils/models/state.py new file mode 100644 index 0000000..a7f3053 --- /dev/null +++ b/src/algokit_utils/models/state.py @@ -0,0 +1,16 @@ +# src/algokit_utils/models/state.py +from typing import Any, TypedDict + + +class BoxName(TypedDict): + name: str + name_raw: bytes + name_base64: str + + +class BoxValue(TypedDict): + value: bytes + + +class AppState(dict[str, Any]): + pass diff --git a/tests/applications/test_utils.py b/tests/applications/test_utils.py index 2486043..4806216 100644 --- a/tests/applications/test_utils.py +++ b/tests/applications/test_utils.py @@ -3,7 +3,7 @@ from algokit_utils.applications.utils import arc32_to_arc56 from tests.utils import load_arc32_spec -TEST_ARC32_SPEC_FILE_PATH = Path(__file__).parent / "artifacts" / "arc32_app_spec.json" +TEST_ARC32_SPEC_FILE_PATH = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" def test_arc32_to_arc56() -> None: diff --git a/tests/transactions/artifacts/hello_world/approval.teal b/tests/artifacts/hello_world/approval.teal similarity index 100% rename from tests/transactions/artifacts/hello_world/approval.teal rename to tests/artifacts/hello_world/approval.teal diff --git a/tests/artifacts/hello_world/arc32_app_spec.json b/tests/artifacts/hello_world/arc32_app_spec.json new file mode 100644 index 0000000..d84bc32 --- /dev/null +++ b/tests/artifacts/hello_world/arc32_app_spec.json @@ -0,0 +1,55 @@ +{ + "hints": { + "hello(string)string": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5hcHByb3ZhbF9wcm9ncmFtOgogICAgaW50Y2Jsb2NrIDAgMQogICAgY2FsbHN1YiBfX3B1eWFfYXJjNF9yb3V0ZXJfXwogICAgcmV0dXJuCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkLmNvbnRyYWN0LkhlbGxvV29ybGQuX19wdXlhX2FyYzRfcm91dGVyX18oKSAtPiB1aW50NjQ6Cl9fcHV5YV9hcmM0X3JvdXRlcl9fOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHByb3RvIDAgMQogICAgdHhuIE51bUFwcEFyZ3MKICAgIGJ6IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1CiAgICBwdXNoYnl0ZXMgMHgwMmJlY2UxMSAvLyBtZXRob2QgImhlbGxvKHN0cmluZylzdHJpbmciCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAwCiAgICBtYXRjaCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyCiAgICBpbnRjXzAgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjYKICAgIC8vIEBhYmltZXRob2QoKQogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGlzIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIGV4dHJhY3QgMiAwCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NgogICAgLy8gQGFiaW1ldGhvZCgpCiAgICBjYWxsc3ViIGhlbGxvCiAgICBkdXAKICAgIGxlbgogICAgaXRvYgogICAgZXh0cmFjdCA2IDIKICAgIHN3YXAKICAgIGNvbmNhdAogICAgcHVzaGJ5dGVzIDB4MTUxZjdjNzUKICAgIHN3YXAKICAgIGNvbmNhdAogICAgbG9nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19iYXJlX3JvdXRpbmdANToKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZC9jb250cmFjdC5weTo1CiAgICAvLyBjbGFzcyBIZWxsb1dvcmxkKEFSQzRDb250cmFjdCk6CiAgICB0eG4gT25Db21wbGV0aW9uCiAgICBibnogX19wdXlhX2FyYzRfcm91dGVyX19fYWZ0ZXJfaWZfZWxzZUA5CiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgIQogICAgYXNzZXJ0IC8vIGlzIGNyZWF0aW5nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDk6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgaW50Y18wIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZC5jb250cmFjdC5IZWxsb1dvcmxkLmhlbGxvKG5hbWU6IGJ5dGVzKSAtPiBieXRlczoKaGVsbG86CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6Ni03CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBoZWxsbyhzZWxmLCBuYW1lOiBTdHJpbmcpIC0+IFN0cmluZzoKICAgIHByb3RvIDEgMQogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjgKICAgIC8vIHJldHVybiAiSGVsbG8yLCAiICsgbmFtZQogICAgcHVzaGJ5dGVzICJIZWxsbzIsICIKICAgIGZyYW1lX2RpZyAtMQogICAgY29uY2F0CiAgICByZXRzdWIK", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5jbGVhcl9zdGF0ZV9wcm9ncmFtOgogICAgcHVzaGludCAxIC8vIDEKICAgIHJldHVybgo=" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} + }, + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorld", + "methods": [ + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "readonly": false, + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "no_op": "CREATE" + } +} diff --git a/tests/transactions/artifacts/hello_world/clear.teal b/tests/artifacts/hello_world/clear.teal similarity index 100% rename from tests/transactions/artifacts/hello_world/clear.teal rename to tests/artifacts/hello_world/clear.teal diff --git a/tests/applications/artifacts/arc32_app_spec.json b/tests/artifacts/legacy_hello_world/arc32_app_spec.json similarity index 100% rename from tests/applications/artifacts/arc32_app_spec.json rename to tests/artifacts/legacy_hello_world/arc32_app_spec.json diff --git a/tests/conftest.py b/tests/conftest.py index 519a93d..1031d11 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -157,7 +157,7 @@ def funded_account(algod_client: "AlgodClient") -> Account: @pytest.fixture(scope="session") def app_spec() -> ApplicationSpecification: app_spec = app_client_test.app.build() - path = Path(__file__).parent / "app_client_test.json" + path = Path(__file__).parent.parent / "legacy_hello_world" / "app_client_test.json" path.write_text(app_spec.to_json()) return read_spec("app_client_test.json", deletable=True, updatable=True, template_values={"VERSION": 1}) diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 1c9283f..0d3b75d 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -173,8 +173,8 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco algod=algorand.client.algod, get_signer=lambda _: funded_account.signer, ) - approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() - clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + approval_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "clear.teal").read_text() composer.add_app_create( AppCreateParams( sender=funded_account.address, diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index f6ca31c..e6db07a 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -210,8 +210,8 @@ def test_create_app_create_transaction(algorand: AlgorandClient, funded_account: def test_create_app_call_method_call_transaction(algorand: AlgorandClient, funded_account: Account) -> None: - approval_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "approval.teal").read_text() - clear_state_program = Path(Path(__file__).parent / "artifacts" / "hello_world" / "clear.teal").read_text() + approval_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "approval.teal").read_text() + clear_state_program = Path(Path(__file__).parent.parent / "artifacts" / "hello_world" / "clear.teal").read_text() # First create the app create_result = algorand.send.app_create( From 8a75fb1052976909f9494e1acc043643c282e98e Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 13 Nov 2024 18:56:28 +0800 Subject: [PATCH 04/19] chore: wip --- src/algokit_utils/applications/app_client.py | 508 +++++++++++++++--- src/algokit_utils/applications/app_manager.py | 7 +- src/algokit_utils/applications/utils.py | 90 +++- src/algokit_utils/models/abi.py | 3 + tests/applications/test_app_client.py | 157 ++++++ 5 files changed, 687 insertions(+), 78 deletions(-) create mode 100644 tests/applications/test_app_client.py diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 61ccdd3..c306640 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -5,11 +5,28 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol +import algosdk from algosdk.logic import get_application_address +from algosdk.transaction import Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification -from algokit_utils.applications.utils import get_abi_decoded_value, get_abi_encoded_value +from algokit_utils.applications.app_manager import AppManager, CompiledTeal, TealTemplateParams +from algokit_utils.applications.utils import ( + get_abi_decoded_value, + get_abi_encoded_value, + get_abi_tuple_from_abi_struct, + get_arc56_method, +) +from algokit_utils.models.abi import ABIStruct +from algokit_utils.models.amount import AlgoAmount from algokit_utils.models.application import Arc56Contract, StorageKey, StorageMap +from algokit_utils.transactions.transaction_composer import ( + AppMethodCallTransactionArgument, + AppUpdateMethodCall, + PaymentParams, + SenderParam, +) +from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult if TYPE_CHECKING: from collections.abc import Callable @@ -127,12 +144,21 @@ class CloneAppClientParams(_CommonAppClientParams): @dataclass(kw_only=True) -class ResolveAppClientByNetwork(_CommonAppClientParams): +class AppClientCompilationResult: + approval_program: bytes + clear_state_program: bytes + compiled_approval: CompiledTeal | None = None + compiled_clear: CompiledTeal | None = None + + +@dataclass(kw_only=True) +class CompileAppClientParams: app_spec: Arc56Contract | ApplicationSpecification | str algorand: AlgorandClientProtocol + compilation: AppClientCompilationParams | None = None -class AppClientStateMethodsProtocol(Protocol): +class _AppClientStateMethodsProtocol(Protocol): def get_all(self) -> dict[str, Any]: ... def get_value(self, name: str, app_state: dict[str, AppState] | None = None) -> ABIValue | None: ... @@ -142,7 +168,7 @@ def get_map_value(self, map_name: str, key: bytes | Any, app_state: dict[str, Ap def get_map(self, map_name: str) -> dict[str, ABIValue]: ... -class _AppClientStateMethods(AppClientStateMethodsProtocol): +class _AppClientStateMethods(_AppClientStateMethodsProtocol): def __init__( self, *, @@ -169,14 +195,14 @@ def get_map(self, map_name: str) -> dict[str, ABIValue]: return self._get_map(map_name) -class AppClientStateAccessor: +class _AppClientStateAccessor: def __init__(self, client: AppClient) -> None: self._client = client self._algorand = client._algorand # noqa: SLF001 self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 - def local_state(self, address: str) -> AppClientStateMethodsProtocol: + def local_state(self, address: str) -> _AppClientStateMethodsProtocol: """Methods to access local state for the current app for a given address""" return self._get_state_methods( state_getter=lambda: self._algorand.app.get_local_state(self._app_id, address), @@ -185,7 +211,7 @@ def local_state(self, address: str) -> AppClientStateMethodsProtocol: ) @property - def global_state(self) -> AppClientStateMethodsProtocol: + def global_state(self) -> _AppClientStateMethodsProtocol: """Methods to access global state for the current app""" return self._get_state_methods( state_getter=lambda: self._algorand.app.get_global_state(self._app_id), @@ -207,7 +233,7 @@ def _get_state_methods( # noqa: C901 state_getter: Callable[[], dict[str, AppState]], key_getter: Callable[[], dict[str, StorageKey]], map_getter: Callable[[], dict[str, StorageMap]], - ) -> AppClientStateMethodsProtocol: + ) -> _AppClientStateMethodsProtocol: def get_all() -> dict[str, Any]: state = state_getter() keys = key_getter() @@ -280,6 +306,208 @@ def get_global_state(self) -> dict[str, AppState]: return self._algorand.app.get_global_state(self._app_id) +@dataclass(frozen=True) +class CommonTxnParams: + """ + Common transaction parameters. + + :param signer: The function used to sign transactions. + :param rekey_to: Change the signing key of the sender to the given address. + :param note: Note to attach to the transaction. + :param lease: Prevent multiple transactions with the same lease being included within the validity window. + :param static_fee: The transaction fee. In most cases you want to use `extra_fee` unless setting the fee to 0 to be + covered by another transaction. + :param extra_fee: The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. + :param max_fee: Throw an error if the fee for the transaction is more than this amount. + :param validity_window: How many rounds the transaction should be valid for. + :param first_valid_round: Set the first round this transaction is valid. If left undefined, the value from algod + will be used. Only set this when you intentionally want this to be some time in the future. + :param last_valid_round: The last round this transaction is valid. It is recommended to use validity_window instead. + """ + + sender: str + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + + +@dataclass(frozen=True) +class AppCallParams(CommonTxnParams, SenderParam): + """ + Application call parameters. + + :param on_complete: The OnComplete action. + :param app_id: ID of the application. + :param approval_program: The program to execute for all OnCompletes other than ClearState. + :param clear_state_program: The program to execute for ClearState OnComplete. + :param schema: The state schema for the app. This is immutable. + :param args: Application arguments. + :param account_references: Account references. + :param app_references: App references. + :param asset_references: Asset references. + :param extra_pages: Number of extra pages required for the programs. + :param box_references: Box references. + """ + + on_complete: OnComplete | None = None + app_id: int | None = None + approval_program: str | bytes | None = None + clear_state_program: str | bytes | None = None + schema: dict[str, int] | None = None + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + extra_pages: int | None = None + box_references: list[BoxReference] | None = None + + +@dataclass(kw_only=True) +class FundAppAccountParams: + sender: str | None = None + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + amount: AlgoAmount + close_remainder_to: str | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None + + +@dataclass(kw_only=True) +class AppClientMethodCallParams: + method: str + sender: str | None = None + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + + +@dataclass(kw_only=True) +class AppClientCompilationParams: + deploy_time_params: TealTemplateParams | None = None + updatable: bool | None = None + deletable: bool | None = None + + +@dataclass(kw_only=True) +class ResolveAppClientByNetwork(_CommonAppClientParams): + app_spec: Arc56Contract | ApplicationSpecification | str + algorand: AlgorandClientProtocol + + +class _AppClientMethodCallParamsAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: + return PaymentParams( + sender=self._client._get_sender(params.sender), + signer=self._client._get_signer(params.sender, params.signer), + receiver=self._client.app_address, + amount=params.amount, + rekey_to=params.rekey_to, + note=params.note, + lease=params.lease, + static_fee=params.static_fee, + extra_fee=params.extra_fee, + max_fee=params.max_fee, + validity_window=params.validity_window, + first_valid_round=params.first_valid_round, + last_valid_round=params.last_valid_round, + close_remainder_to=params.close_remainder_to, + ) + + def update(self, params: AppClientMethodCallParams | AppClientCompilationParams) -> AppUpdateMethodCall: + abi_params = get_abi_params + return AppUpdateMethodCall( + sender=self._client._get_sender(params.sender), + app_id=self._app_id, + approval_program=params.approval_program, + clear_state_program=params.clear_state_program, + ) + + +class _AppClientTransactionCreator: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + def fund_app_account(self, params: FundAppAccountParams) -> Transaction: + return self._algorand.create_transaction.payment(self._client.params.fund_app_account(params)) + + def update(self, params: AppClientMethodCallParams & AppClientCompilationParams) -> Transaction: + return self._algorand.create_transaction.app_update_method_call() + + +# update: async (params: AppClientMethodCallParams & AppClientCompilationParams) => { +# return this._algorand.createTransaction.appUpdateMethodCall(await this.params.update(params)) +# }, +# /** +# * Return transactions for an opt-in ABI call +# */ +# optIn: async (params: AppClientMethodCallParams) => { +# return this._algorand.createTransaction.appCallMethodCall(await this.params.optIn(params)) +# }, +# /** +# * Return transactions for a delete ABI call +# */ +# delete: async (params: AppClientMethodCallParams) => { +# return this._algorand.createTransaction.appDeleteMethodCall(await this.params.delete(params)) +# }, +# /** +# * Return transactions for a close out ABI call +# */ +# closeOut: async (params: AppClientMethodCallParams) => { +# return this._algorand.createTransaction.appCallMethodCall(await this.params.closeOut(params)) +# }, +# /** +# * Return transactions for an ABI call (defaults to no-op) +# */ +# call: async (params: AppClientMethodCallParams & CallOnComplete) => { +# return this._algorand.createTransaction.appCallMethodCall(await this.params.call(params)) +# }, + + +class _AppClientSendAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: + return self._algorand.send.payment(self._client.params.fund_app_account(params)) + + class AppClient: def __init__(self, params: AppClientParams) -> None: self._app_id = params.app_id @@ -291,7 +519,9 @@ def __init__(self, params: AppClientParams) -> None: self._default_signer = params.default_signer self._approval_source_map = params.approval_source_map self._clear_source_map = params.clear_source_map - self._state_accessor = AppClientStateAccessor(self) + self._state_accessor = _AppClientStateAccessor(self) + self._params_accessor = _AppClientParamsAccessor(self) + self._send_accessor = _AppClientSendAccessor(self) @property def app_id(self) -> int: @@ -310,9 +540,17 @@ def app_spec(self) -> Arc56Contract: return self._app_spec @property - def state(self) -> AppClientStateAccessor: + def state(self) -> _AppClientStateAccessor: return self._state_accessor + @property + def params(self) -> _AppClientParamsAccessor: + return self._params_accessor + + @property + def send(self) -> _AppClientSendAccessor: + return self._send_accessor + @staticmethod def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: if isinstance(app_spec, str): @@ -362,23 +600,58 @@ def from_network(params: ResolveAppClientByNetwork) -> AppClient: return AppClient(AppClientParams(**input_params)) # type:ignore[arg-type, call-arg] - # public static async fromNetwork(params: ResolveAppClientByNetwork): Promise { - # const network = await params.algorand.client.network() - # const appSpec = AppClient.normaliseAppSpec(params.appSpec) - # const networkNames = [network.genesisHash] - # if (network.isLocalNet) networkNames.push('localnet') - # if (network.isTestNet) networkNames.push('testnet') - # if (network.isMainNet) networkNames.push('mainnet') - # const availableAppSpecNetworks = Object.keys(appSpec.networks ?? {}) - # const networkIndex = availableAppSpecNetworks.findIndex((n) => networkNames.includes(n)) + @staticmethod + def compile( + app_spec: Arc56Contract, app_manager: AppManager, compilation: AppClientCompilationParams | None = None + ) -> AppClientCompilationResult: + if not app_spec.source: + if not app_spec.byte_code or not app_spec.byte_code.get("approval") or not app_spec.byte_code.get("clear"): + raise ValueError(f"Attempt to compile app {app_spec.name} without source or byte_code") + + return AppClientCompilationResult( + approval_program=base64.b64decode(app_spec.byte_code.get("approval", "")), + clear_state_program=base64.b64decode(app_spec.byte_code.get("clear", "")), + ) - # if (networkIndex === -1) { - # throw new Error(`No app ID found for network ${JSON.stringify(networkNames)} in the app spec`) - # } + approval_template: str = base64.b64decode(app_spec.source.get("approval", "")).decode("utf-8") # type: ignore[assignment] + deployment_metadata = ( + {"updatable": compilation.updatable or False, "deletable": compilation.deletable or False} + if compilation + else None + ) + compiled_approval = app_manager.compile_teal_template( + approval_template, + template_params=compilation.deploy_time_params if compilation else None, + deployment_metadata=deployment_metadata, + ) + + clear_template: str = base64.b64decode(app_spec.source.get("clear", "")).decode("utf-8") # type: ignore[assignment] + compiled_clear = app_manager.compile_teal_template( + clear_template, + template_params=compilation.deploy_time_params if compilation else None, + ) + + # TODO: Add invocation of persisting sourcemaps - # const appId = BigInt(appSpec.networks![networkIndex].appID) - # return new AppClient({ ...params, appId, appSpec }) - # } + return AppClientCompilationResult( + approval_program=compiled_approval.compiled_base64_to_bytes, + compiled_approval=compiled_approval, + clear_state_program=compiled_clear.compiled_base64_to_bytes, + compiled_clear=compiled_clear, + ) + + # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' + def compile_and_persist_sourcemaps( + self, compilation: AppClientCompilationParams | None = None + ) -> AppClientCompilationResult: + result = AppClient.compile(self._app_spec, self._algorand.app, compilation) + + if result.compiled_approval: + self._approval_source_map = result.compiled_approval.source_map + if result.compiled_clear: + self._clear_source_map = result.compiled_clear.source_map + + return result def clone(self, params: CloneAppClientParams) -> AppClient: default_params = { @@ -398,20 +671,6 @@ def clone(self, params: CloneAppClientParams) -> AppClient: return AppClient(AppClientParams(**default_params)) # type: ignore[arg-type] - # public clone(params: CloneAppClientParams) { - # return new AppClient({ - # appId: this._appId, - # appSpec: this._appSpec, - # algorand: this._algorand, - # appName: this._appName, - # defaultSender: this._defaultSender, - # defaultSigner: this._defaultSigner, - # approvalSourceMap: this._approvalSourceMap, - # clearSourceMap: this._clearSourceMap, - # ...params, - # }) - # } - def get_local_state(self, address: str) -> dict[str, AppState]: return self._state_accessor.get_local_state(address) @@ -421,46 +680,143 @@ def get_global_state(self) -> dict[str, AppState]: def new_group(self) -> TransactionComposer: return self._algorand.new_group() - def _get_signer( + def fund_app_account( self, - sender: str | None, - signer: TransactionSigner | None, - ) -> TransactionSigner | None: - return signer or (None if sender else self._default_signer) + ) -> Any: + pass - def _get_sender(self, sender: str | None = None) -> str: + def _get_sender(self, sender: str | None) -> str: if not sender and not self._default_sender: - raise ValueError( - f"No sender provided and no default sender present in app client for call to app {self._app_name}" + raise Exception( + f"No sender provided and no default sender present in app client for call to app {self.app_name}" ) return sender or self._default_sender # type: ignore[return-value] - # def _handle_call_errors(self, call: Callable[[], Any]) -> Any: - # try: - # return call() - # except Exception as e: - # raise self._expose_logic_error(e) from e - - # def _expose_logic_error(self, e: Exception) -> Exception: - # # Add source map info if available - # if hasattr(e, "program_counter") and self._approval_source_map: - # pc = e.program_counter # type: ignore[attr-defined] - # line = self._approval_source_map.get_line_for_pc(pc) - # e.source_line = line # type: ignore[attr-defined] - # return e - - # def get_abi_method(self, method_name: str) -> dict[str, Any]: - # for method in self._app_spec.contract.methods: - # if method["name"] == method_name: - # return method - # raise ValueError(f"Method {method_name} not found") - - # def process_method_call_return(self, result: Any, method: dict[str, Any]) -> Any: - # if not result or "return" not in result: - # return result - - # return_type = method.get("returns", {}).get("type") - # if not return_type: - # return result - - # return self._decode_abi_value(result["return"], return_type) + def _get_signer(self, sender: str | None, signer: TransactionSigner | None) -> TransactionSigner | None: + return signer or self._default_signer if sender else None + + def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + return { + **params, + "app_id": self._app_id, + "sender": self._get_sender(params.get("sender")), + "signer": self._get_signer(params.get("sender"), params.get("signer")), + "on_complete": on_complete, + } + + def _get_abi_args_with_default_values( + self, + method_name_or_signature: str, + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None, + sender: str, + ) -> list[Any]: + """Get ABI args with default values filled in. + + Args: + method_name_or_signature: Method name or ABI signature + args: Optional list of argument values + sender: Sender address + + Returns: + List of argument values with defaults filled in + + Raises: + ValueError: If required argument is missing or default value lookup fails + """ + method = get_arc56_method(method_name_or_signature, self._app_spec) + result = [] + + for i, method_arg in enumerate(method.args): + # Get provided arg value if any + arg_value = args[i] if args and i < len(args) else None + + if arg_value is not None: + # Convert struct to tuple if needed + if method_arg.struct and isinstance(arg_value, dict): + arg_value = get_abi_tuple_from_abi_struct( + arg_value, self._app_spec.structs[method_arg.struct], self._app_spec.structs + ) + result.append(arg_value) + continue + + # Handle default value if arg not provided + default_value = method_arg.default_value + if default_value: + match default_value.source: + case "literal": + value_raw = base64.b64decode(default_value.data) + value_type = default_value.type_ or method_arg.type_ + result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) + + case "method": + # Get method return value + default_method = get_arc56_method(default_value.data, self._app_spec) + empty_args = [None] * len(default_method.args) + call_result = self.send.app_call_method_call( + {"method": default_value.data, "args": empty_args, "sender": sender} + ) + + if not call_result.return_value: + raise ValueError("Default value method call did not return a value") + + if isinstance(call_result.return_value, dict): + # Convert struct return value to tuple + result.append( + get_abi_tuple_from_abi_struct( + call_result.return_value, + self._app_spec.structs[default_method.returns.struct], + self._app_spec.structs, + ) + ) + else: + result.append(call_result.return_value) + + case "local" | "global": + # Get state value + state = ( + self.get_global_state() + if default_value.source == "global" + else self.get_local_state(sender) + ) + value = next((s for s in state.values() if s.key_base64 == default_value.data), None) + if not value: + raise ValueError( + f"Key '{default_value.data}' not found in {default_value.source} " + f"storage for argument {method_arg.name or f'arg{i+1}'}" + ) + + if value.value_raw: + value_type = default_value.type_ or method_arg.type_ + result.append(get_abi_decoded_value(value.value_raw, value_type, self._app_spec.structs)) + else: + result.append(value.value) + + case "box": + # Get box value + box_name = base64.b64decode(default_value.data) + box_value = self._algorand.app.get_box_value(self._app_id, box_name) + value_type = default_value.type_ or method_arg.type_ + result.append(get_abi_decoded_value(box_value, value_type, self._app_spec.structs)) + + elif not algosdk.abi.is_abi_transaction_type(method_arg.type_): + # Error if required non-txn arg missing + raise ValueError( + f"No value provided for required argument " + f"{method_arg.name or f'arg{i+1}'} in call to method {method.name}" + ) + + return result + + def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + sender = self._get_sender(params.get("sender")) + method = get_arc56_method(params["method"], self._app_spec) + args = self._get_abi_args_with_default_values(params["method"], params.get("args"), sender) + return { + **params, + "appId": self._app_id, + "sender": sender, + "signer": self._get_signer(params.get("sender"), params.get("signer")), + "method": method, + "onComplete": on_complete, + "args": args, + } diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 307d5e0..4f27310 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -60,7 +60,12 @@ class CompiledTeal: compiled: bytes compiled_hash: str compiled_base64_to_bytes: bytes - source_map: dict | None + source_map: algosdk.source_map.SourceMap | None + + +class AppCompilationResult: + compiled_approval: CompiledTeal + compiled_clear: CompiledTeal BoxIdentifier = str | bytes | AccountTransactionSigner diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 1888ab4..8ffc16e 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -30,10 +30,98 @@ if TYPE_CHECKING: import algosdk -from typing import Any +from typing import Any, TypeVar +import algosdk from algosdk.abi import ABIType, TupleType +from algokit_utils.models.abi import ABIStruct + +T = TypeVar("T", bound=ABIValue | bytes | ABIStruct | None) + + +def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> AlgorandABIMethod: + if "(" not in method_name_or_signature: + # Filter by method name + methods = [m for m in app_spec.methods if m.name == method_name_or_signature] + if not methods: + raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") + if len(methods) > 1: + signatures = [AlgorandABIMethod.undictify(m.__dict__).get_signature() for m in app_spec.methods] + raise ValueError( + f"Received a call to method {method_name_or_signature} in contract {app_spec.name}, " + f"but this resolved to multiple methods; please pass in an ABI signature instead: " + f"{', '.join(signatures)}" + ) + method = methods[0] + else: + # Find by signature + method = None + for m in app_spec.methods: + abi_method = AlgorandABIMethod.undictify(m.__dict__) + if abi_method.get_signature() == method_name_or_signature: + method = m + break + if method is None: + raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") + + return AlgorandABIMethod.undictify(method.__dict__) + + +def get_arc56_return_value( + return_value: dict[str, Any] | None, + method: Method | AlgorandABIMethod, + structs: dict[str, list[StructField]], +) -> Any: # noqa: ANN401 + """Checks for decode errors on the return value and maps it to the specified type. + + Args: + return_value: The smart contract response + method: The method that was called + structs: The struct fields from the app spec + + Returns: + The smart contract response with an updated return value + + Raises: + ValueError: If there is a decode error + """ + + # Get method returns info + if isinstance(method, AlgorandABIMethod): + type_str = method.returns.type + struct = None # AlgorandABIMethod doesn't have struct info + else: + type_str = method.returns.type_ + struct = method.returns.struct + + # Handle void/undefined returns + if type_str == "void" or return_value is None: + return None + + # Handle decode errors + if return_value.get("decode_error"): + raise ValueError(return_value["decode_error"]) + + # Get raw return value + raw_value = return_value.get("raw_return_value") + + # Handle AVM types + if type_str == "AVMBytes": + return raw_value + if type_str == "AVMString" and raw_value: + return raw_value.decode("utf-8") + if type_str == "AVMUint64" and raw_value: + return algosdk.abi.ABIType.from_string("uint64").decode(raw_value) + + # Handle structs + if struct and struct in structs: + return_tuple = return_value.get("return_value") + return get_abi_struct_from_abi_tuple(return_tuple, structs[struct], structs) + + # Return as-is + return return_value.get("return_value") + def get_abi_encoded_value(value: Any, type_str: str, structs: dict[str, list[StructField]]) -> bytes: # noqa: ANN401, PLR0911 if isinstance(value, (bytes | bytearray)): diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py index 767eed0..016d8af 100644 --- a/src/algokit_utils/models/abi.py +++ b/src/algokit_utils/models/abi.py @@ -1,4 +1,7 @@ +from algokit_utils.models.application import StructField + ABIPrimitiveValue = bool | int | str | bytes | bytearray # NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk ABIValue = ABIPrimitiveValue | list["ABIValue"] | dict[str, "ABIValue"] +ABIStruct = dict[str, list[StructField]] diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py new file mode 100644 index 0000000..ebad141 --- /dev/null +++ b/tests/applications/test_app_client.py @@ -0,0 +1,157 @@ +from pathlib import Path + +import pytest + +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_client import ( + AppClient, + AppClientParams, + CloneAppClientParams, + ResolveAppClientByNetwork, +) +from algokit_utils.applications.utils import arc32_to_arc56 +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.application import Arc56Contract +from algokit_utils.transactions.transaction_composer import AppCreateParams + + +@pytest.fixture +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture +def raw_hello_world_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def test_hello_world_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def test_hello_world_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, test_hello_world_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = test_hello_world_arc32_app_spec.global_state_schema + local_schema = test_hello_world_arc32_app_spec.local_state_schema + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=test_hello_world_arc32_app_spec.approval_program, + clear_state_program=test_hello_world_arc32_app_spec.clear_program, + schema={ + "global_ints": global_schema.num_uints, + "global_bytes": global_schema.num_byte_slices, + "local_ints": local_schema.num_uints, + "local_bytes": local_schema.num_byte_slices, + }, # type: ignore[arg-type] + ) + ) + return response.app_id + + +# TODO: add variations around arc 56 contracts too + + +def test_clone_overriding_default_sender_and_inheriting_app_name( + algorand: AlgorandClient, + funded_account: Account, + test_hello_world_arc32_app_spec: ApplicationSpecification, + test_hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=test_hello_world_arc32_app_id, + algorand=algorand, + app_spec=test_hello_world_arc32_app_spec, + ) + ) + + cloned_default_sender = "ABC" * 55 + cloned_app_client = app_client.clone(CloneAppClientParams(default_sender=cloned_default_sender)) + + assert app_client.app_name == "HelloWorld" + assert cloned_app_client.app_id == app_client.app_id + assert cloned_app_client.app_name == app_client.app_name + assert cloned_app_client._default_sender == cloned_default_sender # noqa: SLF001 + assert app_client._default_sender == funded_account.address # noqa: SLF001 + + +def test_clone_overriding_app_name( + algorand: AlgorandClient, + funded_account: Account, + test_hello_world_arc32_app_spec: ApplicationSpecification, + test_hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=test_hello_world_arc32_app_id, + algorand=algorand, + app_spec=test_hello_world_arc32_app_spec, + ) + ) + + cloned_app_name = "George CLONEy" + cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) + assert app_client.app_name == test_hello_world_arc32_app_spec.contract.name == "HelloWorld" + assert cloned_app_client.app_name == cloned_app_name + + +def test_clone_inheriting_app_name_based_on_default_handling( + algorand: AlgorandClient, + funded_account: Account, + test_hello_world_arc32_app_spec: ApplicationSpecification, + test_hello_world_arc32_app_id: int, +) -> None: + app_client = AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=test_hello_world_arc32_app_id, + algorand=algorand, + app_spec=test_hello_world_arc32_app_spec, + ) + ) + + cloned_app_name = None + cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) + assert cloned_app_client.app_name == test_hello_world_arc32_app_spec.contract.name == app_client.app_name + + +def test_normalise_app_spec( + raw_hello_world_arc32_app_spec: str, + test_hello_world_arc32_app_spec: ApplicationSpecification, +) -> None: + normalized_app_spec_from_arc32 = AppClient.normalise_app_spec(test_hello_world_arc32_app_spec) + assert isinstance(normalized_app_spec_from_arc32, Arc56Contract) + + normalize_app_spec_from_raw_arc32 = AppClient.normalise_app_spec(raw_hello_world_arc32_app_spec) + assert isinstance(normalize_app_spec_from_raw_arc32, Arc56Contract) + + +def test_resolve_from_network( + algorand: AlgorandClient, + test_hello_world_arc32_app_id: int, + test_hello_world_arc32_app_spec: ApplicationSpecification, +) -> None: + arc56_app_spec = arc32_to_arc56(test_hello_world_arc32_app_spec) + arc56_app_spec.networks = {"localnet": {"app_id": test_hello_world_arc32_app_id}} + app_client = AppClient.from_network( + ResolveAppClientByNetwork( + algorand=algorand, + app_spec=arc56_app_spec, + ) + ) + + assert app_client From 20bc5a505dab8cada9d66b3af9b5621799983a39 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 22 Nov 2024 17:48:37 +0500 Subject: [PATCH 05/19] chore: wip --- src/algokit_utils/applications/app_client.py | 144 +++++-- src/algokit_utils/applications/utils.py | 27 +- src/algokit_utils/models/application.py | 40 +- .../transactions/transaction_composer.py | 8 +- tests/applications/test_app_client.py | 127 ++++-- .../artifacts/testing_app/arc32_app_spec.json | 400 ++++++++++++++++++ tests/artifacts/testing_app/contract.py | 174 ++++++++ tests/transactions/test_transaction_sender.py | 79 +++- 8 files changed, 919 insertions(+), 80 deletions(-) create mode 100644 tests/artifacts/testing_app/arc32_app_spec.json create mode 100644 tests/artifacts/testing_app/contract.py diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index c306640..93dd116 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -1,13 +1,15 @@ from __future__ import annotations import base64 +import copy import json from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Protocol import algosdk +from algosdk.box_reference import BoxReference from algosdk.logic import get_application_address -from algosdk.transaction import Transaction +from algosdk.transaction import OnComplete, Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_manager import AppManager, CompiledTeal, TealTemplateParams @@ -21,12 +23,12 @@ from algokit_utils.models.amount import AlgoAmount from algokit_utils.models.application import Arc56Contract, StorageKey, StorageMap from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, AppMethodCallTransactionArgument, - AppUpdateMethodCall, PaymentParams, SenderParam, ) -from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult +from algokit_utils.transactions.transaction_sender import SendAppTransactionResult, SendSingleTransactionResult if TYPE_CHECKING: from collections.abc import Callable @@ -387,6 +389,21 @@ class FundAppAccountParams: max_rounds_to_wait: int | None = None suppress_log: bool | None = None populate_app_call_resources: bool | None = None + on_complete: algosdk.transaction.OnComplete | None = None + + +@dataclass(kw_only=True) +class AppClientCallParams: + method: str | None = None # If calling ABI method, name or signature + args: list | None = None # Arguments to pass to the method + boxes: list | None = None # Box references to load + accounts: list[str] | None = None # Account addresses to load + apps: list[int] | None = None # App IDs to load + assets: list[int] | None = None # Asset IDs to load + lease: (str | bytes) | None = None # Optional lease + sender: str | None = None # Optional sender account + note: (bytes | dict | str) | None = None # Transaction note + send_params: dict | None = None # Parameters to control transaction sending @dataclass(kw_only=True) @@ -404,6 +421,12 @@ class AppClientMethodCallParams: validity_window: int | None = None first_valid_round: int | None = None last_valid_round: int | None = None + # OnComplete + on_complete: algosdk.transaction.OnComplete | None = None + # # SendParams + # max_rounds_to_wait: int | None = None + # suppress_log: bool | None = None + # populate_app_call_resources: bool | None = None @dataclass(kw_only=True) @@ -419,12 +442,25 @@ class ResolveAppClientByNetwork(_CommonAppClientParams): algorand: AlgorandClientProtocol +class _AppClientBareParamsAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + class _AppClientMethodCallParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client self._algorand = client._algorand # noqa: SLF001 self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 + self._bare_params_accessor = _AppClientBareParamsAccessor(client) + + @property + def bare(self) -> _AppClientBareParamsAccessor: + return self._bare_params_accessor def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: return PaymentParams( @@ -444,14 +480,29 @@ def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: close_remainder_to=params.close_remainder_to, ) - def update(self, params: AppClientMethodCallParams | AppClientCompilationParams) -> AppUpdateMethodCall: - abi_params = get_abi_params - return AppUpdateMethodCall( - sender=self._client._get_sender(params.sender), - app_id=self._app_id, - approval_program=params.approval_program, - clear_state_program=params.clear_state_program, - ) + def call(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.NoOpOC) + return AppCallMethodCall(**input_params) + + def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + input_params = copy.deepcopy(params) + + input_params["app_id"] = self._app_id + input_params["on_complete"] = on_complete + + input_params["sender"] = self._client._get_sender(params["sender"]) # noqa: SLF001 + input_params["signer"] = self._client._get_signer(params["sender"], params["signer"]) # noqa: SLF001 + + if params.get("method"): + input_params["method"] = get_arc56_method(params["method"], self._app_spec) + if params.get("args"): + input_params["args"] = self._client._get_abi_args_with_default_values( # noqa: SLF001 + method_name_or_signature=params["method"], + args=params["args"], + sender=self._client._get_sender(input_params["sender"]), # noqa: SLF001 + ) + + return input_params class _AppClientTransactionCreator: @@ -464,8 +515,8 @@ def __init__(self, client: AppClient) -> None: def fund_app_account(self, params: FundAppAccountParams) -> Transaction: return self._algorand.create_transaction.payment(self._client.params.fund_app_account(params)) - def update(self, params: AppClientMethodCallParams & AppClientCompilationParams) -> Transaction: - return self._algorand.create_transaction.app_update_method_call() + # def update(self, params: AppClientMethodCallParams | AppClientCompilationParams) -> Transaction: + # return self._algorand.create_transaction.app_update_method_call() # update: async (params: AppClientMethodCallParams & AppClientCompilationParams) => { @@ -507,6 +558,52 @@ def __init__(self, client: AppClient) -> None: def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: return self._algorand.send.payment(self._client.params.fund_app_account(params)) + def call(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + is_read_only_call = ( + params.on_complete == algosdk.transaction.OnComplete.NoOpOC + or not params.on_complete + and get_arc56_method(params.method, self._app_spec).method.readonly + ) + + if is_read_only_call: + return self._algorand.new_group().add_app_call_method_call(self._client.params.call(params)).simulate() + + return self._algorand.send.app_call_method_call(self._client.params.call(params)) + + # call: async (params: AppClientMethodCallParams & CallOnComplete & SendParams) => { + # // Read-only call - do it via simulate + # if ( + # (params.onComplete === OnApplicationComplete.NoOpOC || !params.onComplete) && + # getArc56Method(params.method, this._appSpec).method.readonly + # ) { + # const result = await this._algorand + # .newGroup() + # .addAppCallMethodCall(await this.params.call(params)) + # .simulate({ + # allowUnnamedResources: params.populateAppCallResources ?? true, + # // Simulate calls for a readonly method shouldn't invoke signing + # skipSignatures: true, + # }) + # return this.processMethodCallReturn( + # { + # ...result, + # transaction: result.transactions.at(-1)!, + # confirmation: result.confirmations.at(-1)!, + # // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + # return: result.returns?.length ?? 0 > 0 ? result.returns?.at(-1)! : undefined, + # } satisfies SendAppTransactionResult, + # getArc56Method(params.method, this._appSpec), + # ) + # } + + # return this.handleCallErrors(async () => + # this.processMethodCallReturn( + # this._algorand.send.appCallMethodCall(await this.params.call(params)), + # getArc56Method(params.method, this._appSpec), + # ), + # ) + # }, + class AppClient: def __init__(self, params: AppClientParams) -> None: @@ -520,7 +617,7 @@ def __init__(self, params: AppClientParams) -> None: self._approval_source_map = params.approval_source_map self._clear_source_map = params.clear_source_map self._state_accessor = _AppClientStateAccessor(self) - self._params_accessor = _AppClientParamsAccessor(self) + self._params_accessor = _AppClientMethodCallParamsAccessor(self) self._send_accessor = _AppClientSendAccessor(self) @property @@ -544,7 +641,7 @@ def state(self) -> _AppClientStateAccessor: return self._state_accessor @property - def params(self) -> _AppClientParamsAccessor: + def params(self) -> _AppClientMethodCallParamsAccessor: return self._params_accessor @property @@ -680,10 +777,8 @@ def get_global_state(self) -> dict[str, AppState]: def new_group(self) -> TransactionComposer: return self._algorand.new_group() - def fund_app_account( - self, - ) -> Any: - pass + def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: + return self.send.fund_app_account(params) def _get_sender(self, sender: str | None) -> str: if not sender and not self._default_sender: @@ -704,8 +799,9 @@ def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transact "on_complete": on_complete, } - def _get_abi_args_with_default_values( + def _get_abi_args_with_default_values( # noqa: C901, PLR0912 self, + *, method_name_or_signature: str, args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None, sender: str, @@ -745,7 +841,7 @@ def _get_abi_args_with_default_values( match default_value.source: case "literal": value_raw = base64.b64decode(default_value.data) - value_type = default_value.type_ or method_arg.type_ + value_type = default_value.type or method_arg.type result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) case "method": @@ -786,7 +882,7 @@ def _get_abi_args_with_default_values( ) if value.value_raw: - value_type = default_value.type_ or method_arg.type_ + value_type = default_value.type or method_arg.type result.append(get_abi_decoded_value(value.value_raw, value_type, self._app_spec.structs)) else: result.append(value.value) @@ -795,10 +891,10 @@ def _get_abi_args_with_default_values( # Get box value box_name = base64.b64decode(default_value.data) box_value = self._algorand.app.get_box_value(self._app_id, box_name) - value_type = default_value.type_ or method_arg.type_ + value_type = default_value.type or method_arg.type result.append(get_abi_decoded_value(box_value, value_type, self._app_spec.structs)) - elif not algosdk.abi.is_abi_transaction_type(method_arg.type_): + elif not algosdk.abi.is_abi_transaction_type(method_arg.type): # Error if required non-txn arg missing raise ValueError( f"No value provided for required argument " diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 8ffc16e..29dae7a 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -14,6 +14,7 @@ from algokit_utils.models.application import ( Arc56Contract, Arc56ContractState, + Arc56Method, ARCType, CallConfig, DefaultValue, @@ -40,7 +41,7 @@ T = TypeVar("T", bound=ABIValue | bytes | ABIStruct | None) -def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> AlgorandABIMethod: +def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> Arc56Method: if "(" not in method_name_or_signature: # Filter by method name methods = [m for m in app_spec.methods if m.name == method_name_or_signature] @@ -58,14 +59,14 @@ def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> # Find by signature method = None for m in app_spec.methods: - abi_method = AlgorandABIMethod.undictify(m.__dict__) + abi_method = AlgorandABIMethod.undictify(m.dictify()) if abi_method.get_signature() == method_name_or_signature: method = m break if method is None: raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") - return AlgorandABIMethod.undictify(method.__dict__) + return Arc56Method(method) def get_arc56_return_value( @@ -92,7 +93,7 @@ def get_arc56_return_value( type_str = method.returns.type struct = None # AlgorandABIMethod doesn't have struct info else: - type_str = method.returns.type_ + type_str = method.returns.type struct = method.returns.struct # Handle void/undefined returns @@ -171,7 +172,7 @@ def get_abi_tuple_from_abi_struct( if key not in struct_value: raise ValueError(f"Missing value for field '{key}'") value = struct_value[key] - field_type = field.type_ + field_type = field.type if isinstance(field_type, str): if field_type in structs: value = get_abi_tuple_from_abi_struct(value, structs[field_type], structs) @@ -186,7 +187,7 @@ def get_abi_tuple_type_from_abi_struct_definition( ) -> TupleType: types = [] for field in struct_def: - field_type = field.type_ + field_type = field.type if isinstance(field_type, str): if field_type in structs: types.append(get_abi_tuple_type_from_abi_struct_definition(structs[field_type], structs)) @@ -207,7 +208,7 @@ def get_abi_struct_from_abi_tuple( result = {} for i, field in enumerate(struct_fields): key = field.name - field_type = field.type_ + field_type = field.type value = decoded_tuple[i] if isinstance(field_type, str): if field_type in structs: @@ -238,9 +239,9 @@ def convert_structs() -> dict[StructName, list[StructField]]: fields = [ StructField( name=name, - type_=type_, + type=type, ) - for name, type_ in struct["elements"] + for name, type in struct["elements"] ] structs[struct["name"]] = fields return structs @@ -250,7 +251,7 @@ def get_hint(method: AlgorandABIMethod) -> MethodHints | None: return app_spec.hints.get(sig) def get_default_value( - type_: Union[str, "algosdk.abi.ABIType"], + type: Union[str, "algosdk.abi.ABIType"], default_arg: DefaultArgumentDict, ) -> DefaultValue | None: if not default_arg or default_arg["source"] == "abi-method": @@ -272,7 +273,7 @@ def get_default_value( return DefaultValue( data=data, - type_="AVMString" if type_ == "string" else str(type_), + type="AVMString" if type == "string" else str(type), source=source_map.get(default_arg["source"], "literal"), # type: ignore[arg-type] ) @@ -292,7 +293,7 @@ def convert_method(method: AlgorandABIMethod) -> Method: default_value = get_default_value(str(arg.type), hint.default_arguments[arg.name]) method_arg = MethodArg( - type_=str(arg.type), + type=arg.type, # type: ignore[arg-type] struct=struct_name, name=arg.name, desc=arg.desc, @@ -301,7 +302,7 @@ def convert_method(method: AlgorandABIMethod) -> Method: args.append(method_arg) method_returns = MethodReturns( - type_=str(method.returns.type), + type=method.returns.type, struct=hint.structs.get("output", {}).get("name") if hint and hint.structs else None, # type: ignore[call-overload] desc=method.returns.desc, ) diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index f2149b4..79ab1ab 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,6 +1,8 @@ -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field, is_dataclass from enum import IntEnum -from typing import Literal +from typing import Any, Literal + +import algosdk UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" @@ -40,7 +42,7 @@ class ARCType(IntEnum): @dataclass(kw_only=True) class StructField: name: str - type_: ABITypeAlias | StructName | list["StructField"] + type: ABITypeAlias | StructName | list["StructField"] @dataclass(kw_only=True) @@ -62,13 +64,13 @@ class StorageMap: @dataclass(kw_only=True) class DefaultValue: data: str - type_: ABITypeAlias | AVMType | None = None + type: ABITypeAlias | AVMType | None = None source: DefaultValueSource @dataclass(kw_only=True) class MethodArg: - type_: ABITypeAlias + type: algosdk.abi.ABIType struct: StructName | None = None name: str | None = None desc: str | None = None @@ -77,7 +79,7 @@ class MethodArg: @dataclass class MethodReturns: - type_: ABITypeAlias + type: algosdk.abi.ABIType struct: StructName | None = None desc: str | None = None @@ -110,16 +112,31 @@ class Method: name: str desc: str | None = None args: list[MethodArg] = field(default_factory=list) - returns: MethodReturns = field(default_factory=lambda: MethodReturns(type_="void")) + returns: MethodReturns = field(default_factory=lambda: MethodReturns(type="void")) actions: MethodActions = field(default_factory=lambda: MethodActions(create=[], call=[])) readonly: bool | None = False events: list["Event"] | None = None recommendations: Recommendations | None = None + def dictify(self) -> dict[str, Any]: + def serialize(obj: Any) -> Any: + if is_dataclass(obj): + return {k: serialize(v) for k, v in asdict(obj).items()} + elif isinstance(obj, list): + return [serialize(item) for item in obj] + elif isinstance(obj, dict): + return {k: serialize(v) for k, v in obj.items()} + elif isinstance(obj, algosdk.abi.ABIType): + return str(obj) + else: + return obj + + return serialize(self) # type: ignore[no-any-return] + @dataclass(kw_only=True) class EventArg: - type_: ABITypeAlias + type: ABITypeAlias name: str | None = None desc: str | None = None struct: StructName | None = None @@ -167,6 +184,13 @@ class Arc56ContractState: schemas: dict[str, dict[str, int]] +# Wraps algosdk.abi.Method +class Arc56Method(algosdk.abi.Method): + def __init__(self, method: Method): + super().__init__(name=method.name, args=method.args, returns=method.returns, desc=method.desc) # type: ignore[arg-type] + self.method = method + + @dataclass(kw_only=True) class Arc56Contract: arcs: list[ARCType] diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index af4dbc2..b3f5ea4 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1148,12 +1148,12 @@ def _build_app_call( txn = algosdk.transaction.ApplicationCreateTxn( **sdk_params, global_schema=algosdk.transaction.StateSchema( - num_uints=params.schema.get("global_uints", 0), - num_byte_slices=params.schema.get("global_byte_slices", 0), + num_uints=params.schema.get("global_ints", 0), + num_byte_slices=params.schema.get("global_bytes", 0), ), local_schema=algosdk.transaction.StateSchema( - num_uints=params.schema.get("local_uints", 0), - num_byte_slices=params.schema.get("local_byte_slices", 0), + num_uints=params.schema.get("local_ints", 0), + num_byte_slices=params.schema.get("local_bytes", 0), ), extra_pages=params.extra_program_pages or math.floor((approval_program_len + clear_program_len) / algosdk.constants.APP_PAGE_MAX_SIZE) diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index ebad141..4b250fc 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -5,10 +5,12 @@ from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_client import ( AppClient, + AppClientMethodCallParams, AppClientParams, CloneAppClientParams, ResolveAppClientByNetwork, ) +from algokit_utils.applications.app_manager import AppManager from algokit_utils.applications.utils import arc32_to_arc56 from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account @@ -30,22 +32,22 @@ def raw_hello_world_arc32_app_spec() -> str: @pytest.fixture -def test_hello_world_arc32_app_spec() -> ApplicationSpecification: +def hello_world_arc32_app_spec() -> ApplicationSpecification: raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" return ApplicationSpecification.from_json(raw_json_spec.read_text()) @pytest.fixture -def test_hello_world_arc32_app_id( - algorand: AlgorandClient, funded_account: Account, test_hello_world_arc32_app_spec: ApplicationSpecification +def hello_world_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, hello_world_arc32_app_spec: ApplicationSpecification ) -> int: - global_schema = test_hello_world_arc32_app_spec.global_state_schema - local_schema = test_hello_world_arc32_app_spec.local_state_schema + global_schema = hello_world_arc32_app_spec.global_state_schema + local_schema = hello_world_arc32_app_spec.local_state_schema response = algorand.send.app_create( AppCreateParams( sender=funded_account.address, - approval_program=test_hello_world_arc32_app_spec.approval_program, - clear_state_program=test_hello_world_arc32_app_spec.clear_program, + approval_program=hello_world_arc32_app_spec.approval_program, + clear_state_program=hello_world_arc32_app_spec.clear_program, schema={ "global_ints": global_schema.num_uints, "global_bytes": global_schema.num_byte_slices, @@ -57,22 +59,64 @@ def test_hello_world_arc32_app_id( return response.app_id +@pytest.fixture +def raw_testing_app_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def testing_app_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def testing_app_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, testing_app_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = testing_app_arc32_app_spec.global_state_schema + local_schema = testing_app_arc32_app_spec.local_state_schema + approval = AppManager.replace_template_variables( + testing_app_arc32_app_spec.approval_program, + { + "VALUE": 1, + "UPDATABLE": 0, + "DELETABLE": 0, + }, + ) + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=approval, + clear_state_program=testing_app_arc32_app_spec.clear_program, + schema={ + "global_bytes": global_schema.num_byte_slices, + "global_ints": global_schema.num_uints, + "local_bytes": local_schema.num_byte_slices, + "local_ints": local_schema.num_uints, + }, # type: ignore[arg-type] + ) + ) + return response.app_id + + # TODO: add variations around arc 56 contracts too def test_clone_overriding_default_sender_and_inheriting_app_name( algorand: AlgorandClient, funded_account: Account, - test_hello_world_arc32_app_spec: ApplicationSpecification, - test_hello_world_arc32_app_id: int, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, ) -> None: app_client = AppClient( AppClientParams( default_sender=funded_account.address, default_signer=funded_account.signer, - app_id=test_hello_world_arc32_app_id, + app_id=hello_world_arc32_app_id, algorand=algorand, - app_spec=test_hello_world_arc32_app_spec, + app_spec=hello_world_arc32_app_spec, ) ) @@ -89,51 +133,51 @@ def test_clone_overriding_default_sender_and_inheriting_app_name( def test_clone_overriding_app_name( algorand: AlgorandClient, funded_account: Account, - test_hello_world_arc32_app_spec: ApplicationSpecification, - test_hello_world_arc32_app_id: int, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, ) -> None: app_client = AppClient( AppClientParams( default_sender=funded_account.address, default_signer=funded_account.signer, - app_id=test_hello_world_arc32_app_id, + app_id=hello_world_arc32_app_id, algorand=algorand, - app_spec=test_hello_world_arc32_app_spec, + app_spec=hello_world_arc32_app_spec, ) ) cloned_app_name = "George CLONEy" cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) - assert app_client.app_name == test_hello_world_arc32_app_spec.contract.name == "HelloWorld" + assert app_client.app_name == hello_world_arc32_app_spec.contract.name == "HelloWorld" assert cloned_app_client.app_name == cloned_app_name def test_clone_inheriting_app_name_based_on_default_handling( algorand: AlgorandClient, funded_account: Account, - test_hello_world_arc32_app_spec: ApplicationSpecification, - test_hello_world_arc32_app_id: int, + hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, ) -> None: app_client = AppClient( AppClientParams( default_sender=funded_account.address, default_signer=funded_account.signer, - app_id=test_hello_world_arc32_app_id, + app_id=hello_world_arc32_app_id, algorand=algorand, - app_spec=test_hello_world_arc32_app_spec, + app_spec=hello_world_arc32_app_spec, ) ) cloned_app_name = None cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) - assert cloned_app_client.app_name == test_hello_world_arc32_app_spec.contract.name == app_client.app_name + assert cloned_app_client.app_name == hello_world_arc32_app_spec.contract.name == app_client.app_name def test_normalise_app_spec( raw_hello_world_arc32_app_spec: str, - test_hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_spec: ApplicationSpecification, ) -> None: - normalized_app_spec_from_arc32 = AppClient.normalise_app_spec(test_hello_world_arc32_app_spec) + normalized_app_spec_from_arc32 = AppClient.normalise_app_spec(hello_world_arc32_app_spec) assert isinstance(normalized_app_spec_from_arc32, Arc56Contract) normalize_app_spec_from_raw_arc32 = AppClient.normalise_app_spec(raw_hello_world_arc32_app_spec) @@ -142,11 +186,11 @@ def test_normalise_app_spec( def test_resolve_from_network( algorand: AlgorandClient, - test_hello_world_arc32_app_id: int, - test_hello_world_arc32_app_spec: ApplicationSpecification, + hello_world_arc32_app_id: int, + hello_world_arc32_app_spec: ApplicationSpecification, ) -> None: - arc56_app_spec = arc32_to_arc56(test_hello_world_arc32_app_spec) - arc56_app_spec.networks = {"localnet": {"app_id": test_hello_world_arc32_app_id}} + arc56_app_spec = arc32_to_arc56(hello_world_arc32_app_spec) + arc56_app_spec.networks = {"localnet": {"app_id": hello_world_arc32_app_id}} app_client = AppClient.from_network( ResolveAppClientByNetwork( algorand=algorand, @@ -155,3 +199,32 @@ def test_resolve_from_network( ) assert app_client + + +# TODO: see if needs moving into app factory tests file +def test_abi_with_default_arg_method( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_id: int, + testing_app_arc32_app_spec: ApplicationSpecification, +) -> None: + arc56_app_spec = arc32_to_arc56(testing_app_arc32_app_spec) + arc56_app_spec.networks = {"localnet": {"app_id": testing_app_arc32_app_id}} + app_client = AppClient.from_network( + ResolveAppClientByNetwork( + algorand=algorand, + app_spec=arc56_app_spec, + default_sender=funded_account.address, + default_signer=funded_account.signer, + ) + ) + + # Test with defined value + defined_value_result = app_client.send.call( + AppClientMethodCallParams(method="default_value_from_local_state(string)string", args=["defined value"]) + ) + assert defined_value_result.return_value == "Local state, defined value" + + # Test with default value + default_value_result = app_client.send.call(AppClientMethodCallParams(method="hello(string)string", args=[None])) + assert default_value_result.return_value == "Hello, default" diff --git a/tests/artifacts/testing_app/arc32_app_spec.json b/tests/artifacts/testing_app/arc32_app_spec.json new file mode 100644 index 0000000..c308fc1 --- /dev/null +++ b/tests/artifacts/testing_app/arc32_app_spec.json @@ -0,0 +1,400 @@ +{ + "hints": { + "call_abi(string)string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "call_abi_txn(pay,string)string": { + "call_config": { + "no_op": "CALL" + } + }, + "call_abi_foreign_refs()string": { + "read_only": true, + "call_config": { + "no_op": "CALL" + } + }, + "set_global(uint64,uint64,string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_local(uint64,uint64,string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box(byte[4],string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "error()void": { + "call_config": { + "no_op": "CALL" + } + }, + "create_abi(string)string": { + "call_config": { + "no_op": "CREATE" + } + }, + "update_abi(string)string": { + "call_config": { + "update_application": "CALL" + } + }, + "delete_abi(string)string": { + "call_config": { + "delete_application": "CALL" + } + }, + "opt_in()void": { + "call_config": { + "opt_in": "CALL" + } + }, + "default_value(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "constant", + "data": "default value" + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_abi(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "abi-method", + "data": { + "name": "default_value", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + } + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_global_state(uint64)uint64": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "global-state", + "data": "int1" + } + }, + "call_config": { + "no_op": "CALL" + } + }, + "default_value_from_local_state(string)string": { + "read_only": true, + "default_arguments": { + "arg_with_default": { + "source": "local-state", + "data": "local_bytes1" + } + }, + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMSAxMCA1IFRNUExfVVBEQVRBQkxFIFRNUExfREVMRVRBQkxFCmJ5dGVjYmxvY2sgMHggMHgxNTFmN2M3NQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sMzIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhmMTdlODBhNSAvLyAiY2FsbF9hYmkoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDMxCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4MGE5MmE4MWUgLy8gImNhbGxfYWJpX3R4bihwYXksc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDMwCnR4bmEgQXBwbGljYXRpb25BcmdzIDAKcHVzaGJ5dGVzIDB4YWQ3NTYwMmMgLy8gImNhbGxfYWJpX2ZvcmVpZ25fcmVmcygpc3RyaW5nIgo9PQpibnogbWFpbl9sMjkKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhNGNmOGRlYSAvLyAic2V0X2dsb2JhbCh1aW50NjQsdWludDY0LHN0cmluZyxieXRlWzRdKXZvaWQiCj09CmJueiBtYWluX2wyOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGNlYzI4MzRhIC8vICJzZXRfbG9jYWwodWludDY0LHVpbnQ2NCxzdHJpbmcsYnl0ZVs0XSl2b2lkIgo9PQpibnogbWFpbl9sMjcKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHhhNGI0YTIzMCAvLyAic2V0X2JveChieXRlWzRdLHN0cmluZyl2b2lkIgo9PQpibnogbWFpbl9sMjYKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMApwdXNoYnl0ZXMgMHg0NGQwZGEwZCAvLyAiZXJyb3IoKXZvaWQiCj09CmJueiBtYWluX2wyNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDlkNTIzMDQwIC8vICJjcmVhdGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyNAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDNjYTVjZWI3IC8vICJ1cGRhdGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMwp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDI3MWI0ZWU5IC8vICJkZWxldGVfYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDMwYzZkNThhIC8vICJvcHRfaW4oKXZvaWQiCj09CmJueiBtYWluX2wyMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDU3NGI1NWM4IC8vICJkZWZhdWx0X3ZhbHVlKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wyMAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDQ2ZDIxMWEzIC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fYWJpKHN0cmluZylzdHJpbmciCj09CmJueiBtYWluX2wxOQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDBjZmNiYjAwIC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fZ2xvYmFsX3N0YXRlKHVpbnQ2NCl1aW50NjQiCj09CmJueiBtYWluX2wxOAp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGQwZjBiYWY4IC8vICJkZWZhdWx0X3ZhbHVlX2Zyb21fbG9jYWxfc3RhdGUoc3RyaW5nKXN0cmluZyIKPT0KYm56IG1haW5fbDE3CmVycgptYWluX2wxNzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tbG9jYWxzdGF0ZWNhc3Rlcl8zMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTg6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWdsb2JhbHN0YXRlY2FzdGVyXzMyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wxOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tYWJpY2FzdGVyXzMxCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyMDoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBkZWZhdWx0dmFsdWVjYXN0ZXJfMzAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDIxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBvcHRpbmNhc3Rlcl8yOQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjI6CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGRlbGV0ZWFiaWNhc3Rlcl8yOAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjM6CnR4biBPbkNvbXBsZXRpb24KcHVzaGludCA0IC8vIFVwZGF0ZUFwcGxpY2F0aW9uCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIHVwZGF0ZWFiaWNhc3Rlcl8yNwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KJiYKYXNzZXJ0CmNhbGxzdWIgY3JlYXRlYWJpY2FzdGVyXzI2CmludGNfMSAvLyAxCnJldHVybgptYWluX2wyNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBlcnJvcmNhc3Rlcl8yNQppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjY6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgc2V0Ym94Y2FzdGVyXzI0CmludGNfMSAvLyAxCnJldHVybgptYWluX2wyNzoKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBzZXRsb2NhbGNhc3Rlcl8yMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMjg6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CmNhbGxzdWIgc2V0Z2xvYmFsY2FzdGVyXzIyCmludGNfMSAvLyAxCnJldHVybgptYWluX2wyOToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKY2FsbHN1YiBjYWxsYWJpZm9yZWlnbnJlZnNjYXN0ZXJfMjEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMwOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGNhbGxhYml0eG5jYXN0ZXJfMjAKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMxOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CiYmCmFzc2VydApjYWxsc3ViIGNhbGxhYmljYXN0ZXJfMTkKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDMyOgp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2w0MAp0eG4gT25Db21wbGV0aW9uCmludGNfMSAvLyBPcHRJbgo9PQpibnogbWFpbl9sMzkKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDQgLy8gVXBkYXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDM4CnR4biBPbkNvbXBsZXRpb24KaW50Y18zIC8vIERlbGV0ZUFwcGxpY2F0aW9uCj09CmJueiBtYWluX2wzNwplcnIKbWFpbl9sMzc6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CmFzc2VydApjYWxsc3ViIGRlbGV0ZV8xMgppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzg6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCiE9CmFzc2VydApjYWxsc3ViIHVwZGF0ZV8xMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMzk6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydApjYWxsc3ViIGNyZWF0ZV84CmludGNfMSAvLyAxCnJldHVybgptYWluX2w0MDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKPT0KYXNzZXJ0CmNhbGxzdWIgY3JlYXRlXzgKaW50Y18xIC8vIDEKcmV0dXJuCgovLyBjYWxsX2FiaQpjYWxsYWJpXzA6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnB1c2hieXRlcyAweDQ4NjU2YzZjNmYyYzIwIC8vICJIZWxsbywgIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKY29uY2F0CmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gaXRvYQppdG9hXzE6CnByb3RvIDEgMQpmcmFtZV9kaWcgLTEKaW50Y18wIC8vIDAKPT0KYm56IGl0b2FfMV9sNQpmcmFtZV9kaWcgLTEKaW50Y18yIC8vIDEwCi8KaW50Y18wIC8vIDAKPgpibnogaXRvYV8xX2w0CmJ5dGVjXzAgLy8gIiIKaXRvYV8xX2wzOgpwdXNoYnl0ZXMgMHgzMDMxMzIzMzM0MzUzNjM3MzgzOSAvLyAiMDEyMzQ1Njc4OSIKZnJhbWVfZGlnIC0xCmludGNfMiAvLyAxMAolCmludGNfMSAvLyAxCmV4dHJhY3QzCmNvbmNhdApiIGl0b2FfMV9sNgppdG9hXzFfbDQ6CmZyYW1lX2RpZyAtMQppbnRjXzIgLy8gMTAKLwpjYWxsc3ViIGl0b2FfMQpiIGl0b2FfMV9sMwppdG9hXzFfbDU6CnB1c2hieXRlcyAweDMwIC8vICIwIgppdG9hXzFfbDY6CnJldHN1YgoKLy8gY2FsbF9hYmlfdHhuCmNhbGxhYml0eG5fMjoKcHJvdG8gMiAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NTM2NTZlNzQyMCAvLyAiU2VudCAiCmZyYW1lX2RpZyAtMgpndHhucyBBbW91bnQKY2FsbHN1YiBpdG9hXzEKY29uY2F0CnB1c2hieXRlcyAweDJlMjAgLy8gIi4gIgpjb25jYXQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmNvbmNhdApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGNhbGxfYWJpX2ZvcmVpZ25fcmVmcwpjYWxsYWJpZm9yZWlnbnJlZnNfMzoKcHJvdG8gMCAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NDE3MDcwM2EyMCAvLyAiQXBwOiAiCnR4bmEgQXBwbGljYXRpb25zIDEKY2FsbHN1YiBpdG9hXzEKY29uY2F0CnB1c2hieXRlcyAweDJjMjA0MTczNzM2NTc0M2EyMCAvLyAiLCBBc3NldDogIgpjb25jYXQKdHhuYSBBc3NldHMgMApjYWxsc3ViIGl0b2FfMQpjb25jYXQKcHVzaGJ5dGVzIDB4MmMyMDQxNjM2MzZmNzU2ZTc0M2EyMCAvLyAiLCBBY2NvdW50OiAiCmNvbmNhdAp0eG5hIEFjY291bnRzIDAKaW50Y18wIC8vIDAKZ2V0Ynl0ZQpjYWxsc3ViIGl0b2FfMQpjb25jYXQKcHVzaGJ5dGVzIDB4M2EgLy8gIjoiCmNvbmNhdAp0eG5hIEFjY291bnRzIDAKaW50Y18xIC8vIDEKZ2V0Ynl0ZQpjYWxsc3ViIGl0b2FfMQpjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBzZXRfZ2xvYmFsCnNldGdsb2JhbF80Ogpwcm90byA0IDAKcHVzaGJ5dGVzIDB4Njk2ZTc0MzEgLy8gImludDEiCmZyYW1lX2RpZyAtNAphcHBfZ2xvYmFsX3B1dApwdXNoYnl0ZXMgMHg2OTZlNzQzMiAvLyAiaW50MiIKZnJhbWVfZGlnIC0zCmFwcF9nbG9iYWxfcHV0CnB1c2hieXRlcyAweDYyNzk3NDY1NzMzMSAvLyAiYnl0ZXMxIgpmcmFtZV9kaWcgLTIKZXh0cmFjdCAyIDAKYXBwX2dsb2JhbF9wdXQKcHVzaGJ5dGVzIDB4NjI3OTc0NjU3MzMyIC8vICJieXRlczIiCmZyYW1lX2RpZyAtMQphcHBfZ2xvYmFsX3B1dApyZXRzdWIKCi8vIHNldF9sb2NhbApzZXRsb2NhbF81Ogpwcm90byA0IDAKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2OTZlNzQzMSAvLyAibG9jYWxfaW50MSIKZnJhbWVfZGlnIC00CmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2OTZlNzQzMiAvLyAibG9jYWxfaW50MiIKZnJhbWVfZGlnIC0zCmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2Mjc5NzQ2NTczMzEgLy8gImxvY2FsX2J5dGVzMSIKZnJhbWVfZGlnIC0yCmV4dHJhY3QgMiAwCmFwcF9sb2NhbF9wdXQKdHhuIFNlbmRlcgpwdXNoYnl0ZXMgMHg2YzZmNjM2MTZjNWY2Mjc5NzQ2NTczMzIgLy8gImxvY2FsX2J5dGVzMiIKZnJhbWVfZGlnIC0xCmFwcF9sb2NhbF9wdXQKcmV0c3ViCgovLyBzZXRfYm94CnNldGJveF82Ogpwcm90byAyIDAKZnJhbWVfZGlnIC0yCmJveF9kZWwKcG9wCmZyYW1lX2RpZyAtMgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKYm94X3B1dApyZXRzdWIKCi8vIGVycm9yCmVycm9yXzc6CnByb3RvIDAgMAppbnRjXzAgLy8gMAovLyBEZWxpYmVyYXRlIGVycm9yCmFzc2VydApyZXRzdWIKCi8vIGNyZWF0ZQpjcmVhdGVfODoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGJ5dGVzIDB4NzY2MTZjNzU2NSAvLyAidmFsdWUiCnB1c2hpbnQgVE1QTF9WQUxVRSAvLyBUTVBMX1ZBTFVFCmFwcF9nbG9iYWxfcHV0CnJldHN1YgoKLy8gY3JlYXRlX2FiaQpjcmVhdGVhYmlfOToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydApmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyB1cGRhdGUKdXBkYXRlXzEwOgpwcm90byAwIDAKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydAppbnRjIDQgLy8gVE1QTF9VUERBVEFCTEUKLy8gQ2hlY2sgYXBwIGlzIHVwZGF0YWJsZQphc3NlcnQKcmV0c3ViCgovLyB1cGRhdGVfYWJpCnVwZGF0ZWFiaV8xMToKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKdHhuIFNlbmRlcgpnbG9iYWwgQ3JlYXRvckFkZHJlc3MKPT0KLy8gdW5hdXRob3JpemVkCmFzc2VydAppbnRjIDQgLy8gVE1QTF9VUERBVEFCTEUKLy8gQ2hlY2sgYXBwIGlzIHVwZGF0YWJsZQphc3NlcnQKZnJhbWVfZGlnIC0xCmV4dHJhY3QgMiAwCmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gZGVsZXRlCmRlbGV0ZV8xMjoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIENoZWNrIGFwcCBpcyBkZWxldGFibGUKYXNzZXJ0CnJldHN1YgoKLy8gZGVsZXRlX2FiaQpkZWxldGVhYmlfMTM6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKaW50YyA1IC8vIFRNUExfREVMRVRBQkxFCi8vIENoZWNrIGFwcCBpcyBkZWxldGFibGUKYXNzZXJ0CmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIG9wdF9pbgpvcHRpbl8xNDoKcHJvdG8gMCAwCmludGNfMSAvLyAxCnJldHVybgoKLy8gZGVmYXVsdF92YWx1ZQpkZWZhdWx0dmFsdWVfMTU6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApmcmFtZV9idXJ5IDAKZnJhbWVfZGlnIDAKbGVuCml0b2IKZXh0cmFjdCA2IDAKZnJhbWVfZGlnIDAKY29uY2F0CmZyYW1lX2J1cnkgMApyZXRzdWIKCi8vIGRlZmF1bHRfdmFsdWVfZnJvbV9hYmkKZGVmYXVsdHZhbHVlZnJvbWFiaV8xNjoKcHJvdG8gMSAxCmJ5dGVjXzAgLy8gIiIKcHVzaGJ5dGVzIDB4NDE0MjQ5MmMyMCAvLyAiQUJJLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fZ2xvYmFsX3N0YXRlCmRlZmF1bHR2YWx1ZWZyb21nbG9iYWxzdGF0ZV8xNzoKcHJvdG8gMSAxCmludGNfMCAvLyAwCmZyYW1lX2RpZyAtMQpmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fbG9jYWxfc3RhdGUKZGVmYXVsdHZhbHVlZnJvbWxvY2Fsc3RhdGVfMTg6CnByb3RvIDEgMQpieXRlY18wIC8vICIiCnB1c2hieXRlcyAweDRjNmY2MzYxNmMyMDczNzQ2MTc0NjUyYzIwIC8vICJMb2NhbCBzdGF0ZSwgIgpmcmFtZV9kaWcgLTEKZXh0cmFjdCAyIDAKY29uY2F0CmZyYW1lX2J1cnkgMApmcmFtZV9kaWcgMApsZW4KaXRvYgpleHRyYWN0IDYgMApmcmFtZV9kaWcgMApjb25jYXQKZnJhbWVfYnVyeSAwCnJldHN1YgoKLy8gY2FsbF9hYmlfY2FzdGVyCmNhbGxhYmljYXN0ZXJfMTk6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGNhbGxhYmlfMApmcmFtZV9idXJ5IDAKYnl0ZWNfMSAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3ViCgovLyBjYWxsX2FiaV90eG5fY2FzdGVyCmNhbGxhYml0eG5jYXN0ZXJfMjA6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmludGNfMCAvLyAwCmJ5dGVjXzAgLy8gIiIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDIKdHhuIEdyb3VwSW5kZXgKaW50Y18xIC8vIDEKLQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKZ3R4bnMgVHlwZUVudW0KaW50Y18xIC8vIHBheQo9PQphc3NlcnQKZnJhbWVfZGlnIDEKZnJhbWVfZGlnIDIKY2FsbHN1YiBjYWxsYWJpdHhuXzIKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gY2FsbF9hYmlfZm9yZWlnbl9yZWZzX2Nhc3RlcgpjYWxsYWJpZm9yZWlnbnJlZnNjYXN0ZXJfMjE6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmNhbGxzdWIgY2FsbGFiaWZvcmVpZ25yZWZzXzMKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gc2V0X2dsb2JhbF9jYXN0ZXIKc2V0Z2xvYmFsY2FzdGVyXzIyOgpwcm90byAwIDAKaW50Y18wIC8vIDAKZHVwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKYnRvaQpmcmFtZV9idXJ5IDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgpidG9pCmZyYW1lX2J1cnkgMQp0eG5hIEFwcGxpY2F0aW9uQXJncyAzCmZyYW1lX2J1cnkgMgp0eG5hIEFwcGxpY2F0aW9uQXJncyA0CmZyYW1lX2J1cnkgMwpmcmFtZV9kaWcgMApmcmFtZV9kaWcgMQpmcmFtZV9kaWcgMgpmcmFtZV9kaWcgMwpjYWxsc3ViIHNldGdsb2JhbF80CnJldHN1YgoKLy8gc2V0X2xvY2FsX2Nhc3RlcgpzZXRsb2NhbGNhc3Rlcl8yMzoKcHJvdG8gMCAwCmludGNfMCAvLyAwCmR1cApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAwCnR4bmEgQXBwbGljYXRpb25BcmdzIDIKYnRvaQpmcmFtZV9idXJ5IDEKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMwpmcmFtZV9idXJ5IDIKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgNApmcmFtZV9idXJ5IDMKZnJhbWVfZGlnIDAKZnJhbWVfZGlnIDEKZnJhbWVfZGlnIDIKZnJhbWVfZGlnIDMKY2FsbHN1YiBzZXRsb2NhbF81CnJldHN1YgoKLy8gc2V0X2JveF9jYXN0ZXIKc2V0Ym94Y2FzdGVyXzI0Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDAKZnJhbWVfZGlnIDEKY2FsbHN1YiBzZXRib3hfNgpyZXRzdWIKCi8vIGVycm9yX2Nhc3RlcgplcnJvcmNhc3Rlcl8yNToKcHJvdG8gMCAwCmNhbGxzdWIgZXJyb3JfNwpyZXRzdWIKCi8vIGNyZWF0ZV9hYmlfY2FzdGVyCmNyZWF0ZWFiaWNhc3Rlcl8yNjoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgY3JlYXRlYWJpXzkKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gdXBkYXRlX2FiaV9jYXN0ZXIKdXBkYXRlYWJpY2FzdGVyXzI3Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiB1cGRhdGVhYmlfMTEKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gZGVsZXRlX2FiaV9jYXN0ZXIKZGVsZXRlYWJpY2FzdGVyXzI4Ogpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBkZWxldGVhYmlfMTMKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1YgoKLy8gb3B0X2luX2Nhc3RlcgpvcHRpbmNhc3Rlcl8yOToKcHJvdG8gMCAwCmNhbGxzdWIgb3B0aW5fMTQKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Nhc3RlcgpkZWZhdWx0dmFsdWVjYXN0ZXJfMzA6CnByb3RvIDAgMApieXRlY18wIC8vICIiCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmZyYW1lX2J1cnkgMQpmcmFtZV9kaWcgMQpjYWxsc3ViIGRlZmF1bHR2YWx1ZV8xNQpmcmFtZV9idXJ5IDAKYnl0ZWNfMSAvLyAweDE1MWY3Yzc1CmZyYW1lX2RpZyAwCmNvbmNhdApsb2cKcmV0c3ViCgovLyBkZWZhdWx0X3ZhbHVlX2Zyb21fYWJpX2Nhc3RlcgpkZWZhdWx0dmFsdWVmcm9tYWJpY2FzdGVyXzMxOgpwcm90byAwIDAKYnl0ZWNfMCAvLyAiIgpkdXAKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpmcmFtZV9idXJ5IDEKZnJhbWVfZGlnIDEKY2FsbHN1YiBkZWZhdWx0dmFsdWVmcm9tYWJpXzE2CmZyYW1lX2J1cnkgMApieXRlY18xIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKY29uY2F0CmxvZwpyZXRzdWIKCi8vIGRlZmF1bHRfdmFsdWVfZnJvbV9nbG9iYWxfc3RhdGVfY2FzdGVyCmRlZmF1bHR2YWx1ZWZyb21nbG9iYWxzdGF0ZWNhc3Rlcl8zMjoKcHJvdG8gMCAwCmludGNfMCAvLyAwCmR1cAp0eG5hIEFwcGxpY2F0aW9uQXJncyAxCmJ0b2kKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWdsb2JhbHN0YXRlXzE3CmZyYW1lX2J1cnkgMApieXRlY18xIC8vIDB4MTUxZjdjNzUKZnJhbWVfZGlnIDAKaXRvYgpjb25jYXQKbG9nCnJldHN1YgoKLy8gZGVmYXVsdF92YWx1ZV9mcm9tX2xvY2FsX3N0YXRlX2Nhc3RlcgpkZWZhdWx0dmFsdWVmcm9tbG9jYWxzdGF0ZWNhc3Rlcl8zMzoKcHJvdG8gMCAwCmJ5dGVjXzAgLy8gIiIKZHVwCnR4bmEgQXBwbGljYXRpb25BcmdzIDEKZnJhbWVfYnVyeSAxCmZyYW1lX2RpZyAxCmNhbGxzdWIgZGVmYXVsdHZhbHVlZnJvbWxvY2Fsc3RhdGVfMTgKZnJhbWVfYnVyeSAwCmJ5dGVjXzEgLy8gMHgxNTFmN2M3NQpmcmFtZV9kaWcgMApjb25jYXQKbG9nCnJldHN1Yg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + }, + "state": { + "global": { + "num_byte_slices": 2, + "num_uints": 3 + }, + "local": { + "num_byte_slices": 2, + "num_uints": 2 + } + }, + "schema": { + "global": { + "declared": { + "bytes1": { + "type": "bytes", + "key": "bytes1", + "descr": "" + }, + "bytes2": { + "type": "bytes", + "key": "bytes2", + "descr": "" + }, + "int1": { + "type": "uint64", + "key": "int1", + "descr": "" + }, + "int2": { + "type": "uint64", + "key": "int2", + "descr": "" + }, + "value": { + "type": "uint64", + "key": "value", + "descr": "" + } + }, + "reserved": {} + }, + "local": { + "declared": { + "local_bytes1": { + "type": "bytes", + "key": "local_bytes1", + "descr": "" + }, + "local_bytes2": { + "type": "bytes", + "key": "local_bytes2", + "descr": "" + }, + "local_int1": { + "type": "uint64", + "key": "local_int1", + "descr": "" + }, + "local_int2": { + "type": "uint64", + "key": "local_int2", + "descr": "" + } + }, + "reserved": {} + } + }, + "contract": { + "name": "TestingApp", + "methods": [ + { + "name": "call_abi", + "args": [ + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "call_abi_txn", + "args": [ + { + "type": "pay", + "name": "txn" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "call_abi_foreign_refs", + "args": [], + "returns": { + "type": "string" + } + }, + { + "name": "set_global", + "args": [ + { + "type": "uint64", + "name": "int1" + }, + { + "type": "uint64", + "name": "int2" + }, + { + "type": "string", + "name": "bytes1" + }, + { + "type": "byte[4]", + "name": "bytes2" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_local", + "args": [ + { + "type": "uint64", + "name": "int1" + }, + { + "type": "uint64", + "name": "int2" + }, + { + "type": "string", + "name": "bytes1" + }, + { + "type": "byte[4]", + "name": "bytes2" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "set_box", + "args": [ + { + "type": "byte[4]", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "returns": { + "type": "void" + } + }, + { + "name": "error", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "create_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "update_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "delete_abi", + "args": [ + { + "type": "string", + "name": "input" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "opt_in", + "args": [], + "returns": { + "type": "void" + } + }, + { + "name": "default_value", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "default_value_from_abi", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + }, + { + "name": "default_value_from_global_state", + "args": [ + { + "type": "uint64", + "name": "arg_with_default" + } + ], + "returns": { + "type": "uint64" + } + }, + { + "name": "default_value_from_local_state", + "args": [ + { + "type": "string", + "name": "arg_with_default" + } + ], + "returns": { + "type": "string" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "delete_application": "CALL", + "no_op": "CREATE", + "opt_in": "CREATE", + "update_application": "CALL" + } +} diff --git a/tests/artifacts/testing_app/contract.py b/tests/artifacts/testing_app/contract.py new file mode 100644 index 0000000..24d3e07 --- /dev/null +++ b/tests/artifacts/testing_app/contract.py @@ -0,0 +1,174 @@ +from typing import Literal + +import beaker +import pyteal as pt +from beaker.lib.storage import BoxMapping +from pyteal.ast import CallConfig, MethodConfig + +UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" +DELETABLE_TEMPLATE_NAME = "TMPL_DELETABLE" + + +class BareCallAppState: + value = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + bytes1 = beaker.GlobalStateValue(stack_type=pt.TealType.bytes) + bytes2 = beaker.GlobalStateValue(stack_type=pt.TealType.bytes) + int1 = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + int2 = beaker.GlobalStateValue(stack_type=pt.TealType.uint64) + local_bytes1 = beaker.LocalStateValue(stack_type=pt.TealType.bytes) + local_bytes2 = beaker.LocalStateValue(stack_type=pt.TealType.bytes) + local_int1 = beaker.LocalStateValue(stack_type=pt.TealType.uint64) + local_int2 = beaker.LocalStateValue(stack_type=pt.TealType.uint64) + box = BoxMapping(pt.abi.StaticBytes[Literal[4]], pt.abi.String) + + +app = beaker.Application("TestingApp", state=BareCallAppState) + + +@app.external(read_only=True) +def call_abi(value: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("Hello, "), value.get())) + + +# https://github.com/algorand/pyteal-utils/blob/main/pytealutils/strings/string.py#L63 +@pt.Subroutine(pt.TealType.bytes) +def itoa(i: pt.Expr) -> pt.Expr: + """itoa converts an integer to the ascii byte string it represents""" + return pt.If( + i == pt.Int(0), + pt.Bytes("0"), + pt.Concat( + pt.If(i / pt.Int(10) > pt.Int(0), itoa(i / pt.Int(10)), pt.Bytes("")), + pt.Extract(pt.Bytes("0123456789"), i % pt.Int(10), pt.Int(1)), + ), + ) + + +@app.external() +def call_abi_txn(txn: pt.abi.PaymentTransaction, value: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set( + pt.Concat( + pt.Bytes("Sent "), + itoa(txn.get().amount()), + pt.Bytes(". "), + value.get(), + ) + ) + +@app.external(read_only=True) +def call_abi_foreign_refs(*, output: pt.abi.String) -> pt.Expr: + return output.set( + pt.Concat( + pt.Bytes("App: "), + itoa(pt.Txn.applications[1]), + pt.Bytes(", Asset: "), + itoa(pt.Txn.assets[0]), + pt.Bytes(", Account: "), + itoa(pt.GetByte(pt.Txn.accounts[0], pt.Int(0))), + pt.Bytes(":"), + itoa(pt.GetByte(pt.Txn.accounts[0], pt.Int(1))), + ) + ) + + +@app.external() +def set_global( + int1: pt.abi.Uint64, int2: pt.abi.Uint64, bytes1: pt.abi.String, bytes2: pt.abi.StaticBytes[Literal[4]] +) -> pt.Expr: + return pt.Seq( + app.state.int1.set(int1.get()), + app.state.int2.set(int2.get()), + app.state.bytes1.set(bytes1.get()), + app.state.bytes2.set(bytes2.get()), + ) + + +@app.external() +def set_local( + int1: pt.abi.Uint64, int2: pt.abi.Uint64, bytes1: pt.abi.String, bytes2: pt.abi.StaticBytes[Literal[4]] +) -> pt.Expr: + return pt.Seq( + app.state.local_int1.set(int1.get()), + app.state.local_int2.set(int2.get()), + app.state.local_bytes1.set(bytes1.get()), + app.state.local_bytes2.set(bytes2.get()), + ) + + +@app.external() +def set_box(name: pt.abi.StaticBytes[Literal[4]], value: pt.abi.String) -> pt.Expr: + return app.state.box[name.get()].set(value.get()) + + +@app.external() +def error() -> pt.Expr: + return pt.Assert(pt.Int(0), comment="Deliberate error") + + +@app.external( + authorize=beaker.Authorize.only_creator(), + bare=True, + method_config=MethodConfig(no_op=CallConfig.CREATE, opt_in=CallConfig.CREATE), +) +def create() -> pt.Expr: + return app.state.value.set(pt.Tmpl.Int("TMPL_VALUE")) + + +@app.create(authorize=beaker.Authorize.only_creator()) +def create_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return output.set(input.get()) + + +@app.update(authorize=beaker.Authorize.only_creator(), bare=True) +def update() -> pt.Expr: + return pt.Assert(pt.Tmpl.Int(UPDATABLE_TEMPLATE_NAME), comment="Check app is updatable") + + +@app.update(authorize=beaker.Authorize.only_creator()) +def update_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return pt.Seq( + pt.Assert(pt.Tmpl.Int(UPDATABLE_TEMPLATE_NAME), comment="Check app is updatable"), output.set(input.get()) + ) + + +@app.delete(authorize=beaker.Authorize.only_creator(), bare=True) +def delete() -> pt.Expr: + return pt.Assert(pt.Tmpl.Int(DELETABLE_TEMPLATE_NAME), comment="Check app is deletable") + + +@app.delete(authorize=beaker.Authorize.only_creator()) +def delete_abi(input: pt.abi.String, *, output: pt.abi.String) -> pt.Expr: + return pt.Seq( + pt.Assert(pt.Tmpl.Int(DELETABLE_TEMPLATE_NAME), comment="Check app is deletable"), output.set(input.get()) + ) + + +@app.opt_in +def opt_in() -> pt.Expr: + return pt.Approve() + + +@app.external(read_only=True) +def default_value( + arg_with_default: pt.abi.String = "default value", *, output: pt.abi.String # type: ignore[assignment] +) -> pt.Expr: + return output.set(arg_with_default.get()) + + +@app.external(read_only=True) +def default_value_from_abi( + arg_with_default: pt.abi.String = default_value, *, output: pt.abi.String # type: ignore[assignment] +) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("ABI, "), arg_with_default.get())) + +@app.external(read_only=True) +def default_value_from_global_state( + arg_with_default: pt.abi.Uint64 = BareCallAppState.int1, *, output: pt.abi.Uint64 # type: ignore[assignment] +) -> pt.Expr: + return output.set(arg_with_default.get()) + +@app.external(read_only=True) +def default_value_from_local_state( + arg_with_default: pt.abi.String = BareCallAppState.local_bytes1, *, output: pt.abi.String # type: ignore[assignment] +) -> pt.Expr: + return output.set(pt.Concat(pt.Bytes("Local state, "), arg_with_default.get())) diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index 4a24a8c..5c4ea01 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -1,6 +1,8 @@ -from typing import TYPE_CHECKING, cast +from pathlib import Path +from typing import cast from unittest.mock import MagicMock, patch +import algosdk import pytest from algosdk.transaction import ( ApplicationCreateTxn, @@ -16,10 +18,14 @@ Account, get_account, ) +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import ( + AppCallMethodCall, + AppCallParams, AppCreateParams, AssetConfigParams, AssetCreateParams, @@ -35,8 +41,12 @@ from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender from tests.conftest import get_unique_name -if TYPE_CHECKING: - import algosdk + +@pytest.fixture +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client @pytest.fixture @@ -49,6 +59,40 @@ def receiver(algod_client: "algosdk.v2client.algod.AlgodClient") -> Account: return get_account(algod_client, get_unique_name()) +@pytest.fixture +def raw_hello_world_arc32_app_spec() -> str: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return raw_json_spec.read_text() + + +@pytest.fixture +def test_hello_world_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def test_hello_world_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, test_hello_world_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = test_hello_world_arc32_app_spec.global_state_schema + local_schema = test_hello_world_arc32_app_spec.local_state_schema + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=test_hello_world_arc32_app_spec.approval_program, + clear_state_program=test_hello_world_arc32_app_spec.clear_program, + schema={ + "global_ints": global_schema.num_uints, + "global_bytes": global_schema.num_byte_slices, + "local_ints": local_schema.num_uints, + "local_bytes": local_schema.num_byte_slices, + }, # type: ignore[arg-type] + ) + ) + return response.app_id + + @pytest.fixture def transaction_sender( algod_client: "algosdk.v2client.algod.AlgodClient", sender: Account @@ -323,6 +367,9 @@ def test_asset_opt_out(transaction_sender: AlgorandClientTransactionSender, send assert txn.close_assets_to == sender.address +# TODO: add remaining tests for app_update, app_delete, app_create_method_call, app_update method call, app_delete method call + + def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: approval_program = "#pragma version 6\nint 1" clear_state_program = "#pragma version 6\nint 1" @@ -342,7 +389,31 @@ def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: assert txn.clear_program == b"\x06\x81\x01" -# TODO: add remaining app call and app method call tests +def test_app_call( + test_hello_world_arc32_app_id: int, transaction_sender: AlgorandClientTransactionSender, sender: Account +) -> None: + params = AppCallParams( + app_id=test_hello_world_arc32_app_id, + sender=sender.address, + args=[b"\x02\xbe\xce\x11", b"test"], + ) + + result = transaction_sender.app_call(params) + assert not result.return_value # TODO: improve checks + + +def test_app_call_method_call( + test_hello_world_arc32_app_id: int, transaction_sender: AlgorandClientTransactionSender, sender: Account +) -> None: + params = AppCallMethodCall( + app_id=test_hello_world_arc32_app_id, + sender=sender.address, + method=algosdk.abi.Method.from_signature("hello(string)string"), + args=["test"], + ) + + result = transaction_sender.app_call_method_call(params) + assert result.return_value == "Hello2, test" @patch("logging.Logger.debug") From 1e3fa627f2ef06407f0e5763254c8d146cd46847 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 22 Nov 2024 18:27:21 +0500 Subject: [PATCH 06/19] chore: wip --- src/algokit_utils/applications/app_client.py | 12 ++++++---- src/algokit_utils/models/state.py | 16 -------------- .../transactions/transaction_composer.py | 22 +++++++++++++++++-- tests/artifacts/testing_app/contract.py | 19 ++++++++++++---- 4 files changed, 43 insertions(+), 26 deletions(-) delete mode 100644 src/algokit_utils/models/state.py diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 93dd116..84a3ac5 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -424,9 +424,9 @@ class AppClientMethodCallParams: # OnComplete on_complete: algosdk.transaction.OnComplete | None = None # # SendParams - # max_rounds_to_wait: int | None = None - # suppress_log: bool | None = None - # populate_app_call_resources: bool | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None @dataclass(kw_only=True) @@ -566,7 +566,11 @@ def call(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: ) if is_read_only_call: - return self._algorand.new_group().add_app_call_method_call(self._client.params.call(params)).simulate() + return ( + self._algorand.new_group() + .add_app_call_method_call(self._client.params.call(params)) + .simulate(allow_unnamed_resources=params.populate_app_call_resources or True, skip_signature=True) + ) return self._algorand.send.app_call_method_call(self._client.params.call(params)) diff --git a/src/algokit_utils/models/state.py b/src/algokit_utils/models/state.py deleted file mode 100644 index a7f3053..0000000 --- a/src/algokit_utils/models/state.py +++ /dev/null @@ -1,16 +0,0 @@ -# src/algokit_utils/models/state.py -from typing import Any, TypedDict - - -class BoxName(TypedDict): - name: str - name_raw: bytes - name_base64: str - - -class BoxValue(TypedDict): - value: bytes - - -class AppState(dict[str, Any]): - pass diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index b3f5ea4..ed0bec2 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -2,11 +2,13 @@ import logging import math +import typing from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Union +from typing import TYPE_CHECKING, Any, TypedDict, Union import algosdk import algosdk.atomic_transaction_composer +import algosdk.v2client.models from algosdk.atomic_transaction_composer import ( AtomicTransactionComposer, TransactionSigner, @@ -15,6 +17,7 @@ from algosdk.error import AlgodHTTPError from algosdk.transaction import OnComplete, Transaction from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.models.simulate_request import SimulateTraceConfig from deprecated import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response @@ -700,6 +703,16 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 raise Exception(f"Transaction failed: {e}") from e +class TransactionComposerSimulateOptions(TypedDict): + allow_more_logs: bool | None + allow_empty_signatures: bool | None + allow_unnamed_resources: bool | None + extra_opcode_budget: int | None + exec_trace_config: SimulateTraceConfig | None + round: int | None + skip_signature: int | None + + class TransactionComposer: """ A class for composing and managing Algorand transactions using the Algosdk library. @@ -920,7 +933,12 @@ def send( except algosdk.error.AlgodHTTPError as e: raise Exception(f"Transaction failed: {e}") from e - def simulate(self) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: + def simulate( + self, + **params: typing.Unpack[TransactionComposerSimulateOptions], + ) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: + # TODO: propagate simulation options to the underlying algosdk.atomic_transaction_composer.AtomicTransactionComposer + if config.debug and config.project_root and config.trace_all: return simulate_and_persist_response( self.atc, diff --git a/tests/artifacts/testing_app/contract.py b/tests/artifacts/testing_app/contract.py index 24d3e07..95159cb 100644 --- a/tests/artifacts/testing_app/contract.py +++ b/tests/artifacts/testing_app/contract.py @@ -55,6 +55,7 @@ def call_abi_txn(txn: pt.abi.PaymentTransaction, value: pt.abi.String, *, output ) ) + @app.external(read_only=True) def call_abi_foreign_refs(*, output: pt.abi.String) -> pt.Expr: return output.set( @@ -150,25 +151,35 @@ def opt_in() -> pt.Expr: @app.external(read_only=True) def default_value( - arg_with_default: pt.abi.String = "default value", *, output: pt.abi.String # type: ignore[assignment] + arg_with_default: pt.abi.String = "default value", + *, + output: pt.abi.String, # type: ignore[assignment] ) -> pt.Expr: return output.set(arg_with_default.get()) @app.external(read_only=True) def default_value_from_abi( - arg_with_default: pt.abi.String = default_value, *, output: pt.abi.String # type: ignore[assignment] + arg_with_default: pt.abi.String = default_value, + *, + output: pt.abi.String, # type: ignore[assignment] ) -> pt.Expr: return output.set(pt.Concat(pt.Bytes("ABI, "), arg_with_default.get())) + @app.external(read_only=True) def default_value_from_global_state( - arg_with_default: pt.abi.Uint64 = BareCallAppState.int1, *, output: pt.abi.Uint64 # type: ignore[assignment] + arg_with_default: pt.abi.Uint64 = BareCallAppState.int1, + *, + output: pt.abi.Uint64, # type: ignore[assignment] ) -> pt.Expr: return output.set(arg_with_default.get()) + @app.external(read_only=True) def default_value_from_local_state( - arg_with_default: pt.abi.String = BareCallAppState.local_bytes1, *, output: pt.abi.String # type: ignore[assignment] + arg_with_default: pt.abi.String = BareCallAppState.local_bytes1, + *, + output: pt.abi.String, # type: ignore[assignment] ) -> pt.Expr: return output.set(pt.Concat(pt.Bytes("Local state, "), arg_with_default.get())) From 85173583b2723763f68c1f4901b12310386c65e9 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 27 Nov 2024 19:18:43 +0300 Subject: [PATCH 07/19] chore: finalizing initial tests around call --- src/algokit_utils/_debugging.py | 25 ++++- src/algokit_utils/_legacy_v2/deploy.py | 6 +- src/algokit_utils/applications/app_client.py | 96 +++++++++---------- src/algokit_utils/transactions/models.py | 13 +++ .../transactions/transaction_composer.py | 63 +++++++----- .../transactions/transaction_sender.py | 33 ++++--- tests/applications/test_app_client.py | 17 +++- 7 files changed, 160 insertions(+), 93 deletions(-) diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index de5ed18..d054878 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -15,6 +15,7 @@ from algosdk.v2client.models import SimulateRequest, SimulateRequestTransactionGroup, SimulateTraceConfig from algokit_utils._legacy_v2.common import Program +from algokit_utils.transactions.models import SimulateOptions if typing.TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -201,7 +202,11 @@ def persist_sourcemaps( _upsert_debug_sourcemaps(sourcemaps, project_root) -def simulate_response(atc: AtomicTransactionComposer, algod_client: "AlgodClient") -> SimulateAtomicTransactionResponse: +def simulate_response( + atc: AtomicTransactionComposer, + algod_client: "AlgodClient", + simulate_options: SimulateOptions | dict[str, typing.Any] | None = None, +) -> SimulateAtomicTransactionResponse: """ Simulate and fetch response for the given AtomicTransactionComposer and AlgodClient. @@ -219,15 +224,27 @@ def simulate_response(atc: AtomicTransactionComposer, algod_client: "AlgodClient fake_signed_transactions = empty_signer.sign_transactions(txn_list, []) txn_group = [SimulateRequestTransactionGroup(txns=fake_signed_transactions)] trace_config = SimulateTraceConfig(enable=True, stack_change=True, scratch_change=True, state_change=True) + simulate_params: SimulateOptions = simulate_options or {} # type: ignore[assignment] simulate_request = SimulateRequest( - txn_groups=txn_group, allow_more_logs=True, allow_empty_signatures=True, exec_trace_config=trace_config + txn_groups=txn_group, + allow_more_logs=True, + round=simulate_params.get("round") or None, + extra_opcode_budget=simulate_params.get("extra_opcode_budget") or 0, + allow_unnamed_resources=simulate_params.get("allow_unnamed_resources") or True, + allow_empty_signatures=simulate_params.get("allow_empty_signatures") or True, + exec_trace_config=simulate_params.get("exec_trace_config") or trace_config, ) + return atc.simulate(algod_client, simulate_request) def simulate_and_persist_response( - atc: AtomicTransactionComposer, project_root: Path, algod_client: "AlgodClient", buffer_size_mb: float = 256 + atc: AtomicTransactionComposer, + project_root: Path, + algod_client: "AlgodClient", + buffer_size_mb: float = 256, + simulate_options: SimulateOptions | dict[str, typing.Any] | None = None, ) -> SimulateAtomicTransactionResponse: """ Simulates the atomic transactions using the provided `AtomicTransactionComposer` object and `AlgodClient` object, @@ -252,7 +269,7 @@ def simulate_and_persist_response( txn_with_sign.txn.last_valid_round = sp.last txn_with_sign.txn.genesis_hash = sp.gh - response = simulate_response(atc_to_simulate, algod_client) + response = simulate_response(atc_to_simulate, algod_client, simulate_options) txn_results = response.simulate_response["txn-groups"] txn_types = [txn_result["txn-results"][0]["txn-result"]["txn"]["txn"]["type"] for txn_result in txn_results] diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index ed0bd0e..799fe08 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -7,9 +7,9 @@ from enum import Enum from typing import TYPE_CHECKING, TypeAlias, TypedDict +import algosdk from algosdk import transaction from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner -from algosdk.logic import get_application_address from algosdk.transaction import StateSchema from deprecated import deprecated @@ -222,7 +222,7 @@ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) - if create_metadata and create_metadata.name: apps[create_metadata.name] = AppMetaData( app_id=app_id, - app_address=get_application_address(app_id), + app_address=algosdk.logic.get_application_address(app_id), created_metadata=create_metadata, created_round=app_created_at_round, **(update_metadata or create_metadata).__dict__, @@ -809,7 +809,7 @@ def _create_metadata( ) -> AppMetaData: return AppMetaData( app_id=app_id, - app_address=get_application_address(app_id), + app_address=algosdk.logic.get_application_address(app_id), created_metadata=original_metadata or app_spec_note, created_round=created_round, updated_round=updated_round or created_round, diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 84a3ac5..63a7a7b 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -3,12 +3,12 @@ import base64 import copy import json +import typing from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, TypedDict import algosdk from algosdk.box_reference import BoxReference -from algosdk.logic import get_application_address from algosdk.transaction import OnComplete, Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification @@ -16,7 +16,6 @@ from algokit_utils.applications.utils import ( get_abi_decoded_value, get_abi_encoded_value, - get_abi_tuple_from_abi_struct, get_arc56_method, ) from algokit_utils.models.abi import ABIStruct @@ -410,7 +409,7 @@ class AppClientCallParams: class AppClientMethodCallParams: method: str sender: str | None = None - args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None = None signer: TransactionSigner | None = None rekey_to: str | None = None note: bytes | None = None @@ -423,10 +422,12 @@ class AppClientMethodCallParams: last_valid_round: int | None = None # OnComplete on_complete: algosdk.transaction.OnComplete | None = None - # # SendParams - max_rounds_to_wait: int | None = None - suppress_log: bool | None = None - populate_app_call_resources: bool | None = None + + +class SendParams(TypedDict, total=False): + max_rounds_to_wait: int | None + suppress_log: bool | None + populate_app_call_resources: bool | None @dataclass(kw_only=True) @@ -480,6 +481,10 @@ def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: close_remainder_to=params.close_remainder_to, ) + def opt_in(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.OptInOC) + return AppCallMethodCall(**input_params) + def call(self, params: AppClientMethodCallParams) -> AppCallMethodCall: input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.NoOpOC) return AppCallMethodCall(**input_params) @@ -558,7 +563,12 @@ def __init__(self, client: AppClient) -> None: def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: return self._algorand.send.payment(self._client.params.fund_app_account(params)) - def call(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + def opt_in(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + + def call( + self, params: AppClientMethodCallParams, **send_params: typing.Unpack[SendParams] + ) -> SendAppTransactionResult: is_read_only_call = ( params.on_complete == algosdk.transaction.OnComplete.NoOpOC or not params.on_complete @@ -566,47 +576,34 @@ def call(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: ) if is_read_only_call: - return ( - self._algorand.new_group() - .add_app_call_method_call(self._client.params.call(params)) - .simulate(allow_unnamed_resources=params.populate_app_call_resources or True, skip_signature=True) + method_call_to_simulate = self._algorand.new_group().add_app_call_method_call( + self._client.params.call(params) ) - return self._algorand.send.app_call_method_call(self._client.params.call(params)) + simulate_response = method_call_to_simulate.simulate( + allow_unnamed_resources=send_params["populate_app_call_resources"] if send_params else True, + skip_signatures=True, + allow_more_logs=True, + allow_empty_signatures=True, + extra_opcode_budget=None, + exec_trace_config=None, + round=None, + fix_signers=None, # TODO: double check on whether algosdk py even has this param + ) - # call: async (params: AppClientMethodCallParams & CallOnComplete & SendParams) => { - # // Read-only call - do it via simulate - # if ( - # (params.onComplete === OnApplicationComplete.NoOpOC || !params.onComplete) && - # getArc56Method(params.method, this._appSpec).method.readonly - # ) { - # const result = await this._algorand - # .newGroup() - # .addAppCallMethodCall(await this.params.call(params)) - # .simulate({ - # allowUnnamedResources: params.populateAppCallResources ?? true, - # // Simulate calls for a readonly method shouldn't invoke signing - # skipSignatures: true, - # }) - # return this.processMethodCallReturn( - # { - # ...result, - # transaction: result.transactions.at(-1)!, - # confirmation: result.confirmations.at(-1)!, - # // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain - # return: result.returns?.length ?? 0 > 0 ? result.returns?.at(-1)! : undefined, - # } satisfies SendAppTransactionResult, - # getArc56Method(params.method, this._appSpec), - # ) - # } - - # return this.handleCallErrors(async () => - # this.processMethodCallReturn( - # this._algorand.send.appCallMethodCall(await this.params.call(params)), - # getArc56Method(params.method, this._appSpec), - # ), - # ) - # }, + return SendAppTransactionResult( + tx_id=simulate_response.tx_ids[-1], + tx_ids=simulate_response.tx_ids, + transactions=simulate_response.transactions, + transaction=simulate_response.transactions[-1], + confirmation=simulate_response.confirmations[-1] if simulate_response.confirmations else b"", + confirmations=simulate_response.confirmations, + group_id=simulate_response.group_id or "", + returns=simulate_response.returns, + return_value=simulate_response.returns[-1].return_value, + ) + + return self._algorand.send.app_call_method_call(self._client.params.call(params)) class AppClient: @@ -614,7 +611,7 @@ def __init__(self, params: AppClientParams) -> None: self._app_id = params.app_id self._app_spec = self.normalise_app_spec(params.app_spec) self._algorand = params.algorand - self._app_address = get_application_address(self._app_id) + self._app_address = algosdk.logic.get_application_address(self._app_id) self._app_name = params.app_name or self._app_spec.name self._default_sender = params.default_sender self._default_signer = params.default_signer @@ -741,6 +738,9 @@ def compile( compiled_clear=compiled_clear, ) + def process_method_call_return(): + pass + # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' def compile_and_persist_sourcemaps( self, compilation: AppClientCompilationParams | None = None diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index 251bbf9..cd61f5a 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -1,5 +1,7 @@ from typing import Any, Literal, TypedDict +from algosdk.v2client.models.simulate_request import SimulateTraceConfig + # Define specific types for different formats class BaseArc2Note(TypedDict): @@ -22,6 +24,17 @@ class JsonFormatArc2Note(BaseArc2Note): data: str | dict[str, Any] | list[Any] | int | None +class SimulateOptions(TypedDict): + allow_more_logs: bool | None + allow_empty_signatures: bool | None + allow_unnamed_resources: bool | None + extra_opcode_budget: int | None + exec_trace_config: SimulateTraceConfig | None + round: int | None + skip_signatures: int | None + fix_signers: bool | None + + # Combined type for all valid ARC-0002 notes # See: https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md Arc2TransactionNote = StringFormatArc2Note | JsonFormatArc2Note diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index ed0bec2..218f94b 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,10 +1,8 @@ from __future__ import annotations import logging -import math -import typing from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, TypedDict, Union +from typing import TYPE_CHECKING, Any, Union, Unpack import algosdk import algosdk.atomic_transaction_composer @@ -17,12 +15,12 @@ from algosdk.error import AlgodHTTPError from algosdk.transaction import OnComplete, Transaction from algosdk.v2client.algod import AlgodClient -from algosdk.v2client.models.simulate_request import SimulateTraceConfig from deprecated import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response from algokit_utils.applications.app_manager import AppManager from algokit_utils.config import config +from algokit_utils.transactions.models import SimulateOptions if TYPE_CHECKING: from collections.abc import Callable @@ -585,8 +583,9 @@ class SendAtomicTransactionComposerResults: """The transaction IDs that were sent""" transactions: list[Transaction] """The transactions that were sent""" - returns: list[Any] + returns: list[Any] | list[algosdk.atomic_transaction_composer.ABIResult] """The ABI return values from any ABI method calls""" + simulate_response: dict[str, Any] | None = None def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 @@ -703,16 +702,6 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 raise Exception(f"Transaction failed: {e}") from e -class TransactionComposerSimulateOptions(TypedDict): - allow_more_logs: bool | None - allow_empty_signatures: bool | None - allow_unnamed_resources: bool | None - extra_opcode_budget: int | None - exec_trace_config: SimulateTraceConfig | None - round: int | None - skip_signature: int | None - - class TransactionComposer: """ A class for composing and managing Algorand transactions using the Algosdk library. @@ -935,21 +924,47 @@ def send( def simulate( self, - **params: typing.Unpack[TransactionComposerSimulateOptions], - ) -> algosdk.atomic_transaction_composer.SimulateAtomicTransactionResponse: - # TODO: propagate simulation options to the underlying algosdk.atomic_transaction_composer.AtomicTransactionComposer + **simulate_options: Unpack[SimulateOptions], + ) -> SendAtomicTransactionComposerResults: + atc = AtomicTransactionComposer() if simulate_options["skip_signatures"] else self.atc + + if simulate_options["skip_signatures"]: + simulate_options["allow_empty_signatures"] = True + simulate_options["fix_signers"] = True + transactions = self.build_transactions() + for txn in transactions.transactions: + atc.add_transaction(TransactionWithSigner(txn=txn, signer=TransactionComposer.NULL_SIGNER)) + atc.method_dict = transactions.method_calls + else: + self.build() if config.debug and config.project_root and config.trace_all: - return simulate_and_persist_response( - self.atc, + response = simulate_and_persist_response( + atc, config.project_root, self.algod, config.trace_buffer_size_mb, + simulate_options, + ) + + return SendAtomicTransactionComposerResults( + confirmations=[], # TODO: extract confirmations, + transactions=[txn.txn for txn in atc.txn_list], + tx_ids=response.tx_ids, + group_id=atc.txn_list[-1].txn.group or "", + simulate_response=response.simulate_response, + returns=response.abi_results, ) - return simulate_response( - self.atc, - self.algod, + response = simulate_response(atc, self.algod, simulate_options) + + return SendAtomicTransactionComposerResults( + confirmations=[], # TODO: extract confirmations, + transactions=[txn.txn for txn in atc.txn_list], + tx_ids=response.tx_ids, + group_id=atc.txn_list[-1].txn.group or "", + simulate_response=response.simulate_response, + returns=response.abi_results, ) @staticmethod @@ -1077,7 +1092,7 @@ def _build_method_call( # noqa: C901, PLR0912 sp=suggested_params, signer=params.signer or self.get_signer(params.sender), method_args=method_args, - on_complete=algosdk.transaction.OnComplete.NoOpOC, + on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, note=params.note, lease=params.lease, boxes=[(ref.app_index, ref.name) for ref in params.box_references] if params.box_references else None, diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index 3050dcb..fae7b75 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -1,6 +1,7 @@ from collections.abc import Callable from dataclasses import dataclass from logging import getLogger +from pathlib import Path from typing import Any, TypedDict, TypeVar import algosdk @@ -8,6 +9,7 @@ from algosdk.atomic_transaction_composer import AtomicTransactionResponse from algosdk.transaction import Transaction +from algokit_utils._debugging import simulate_and_persist_response from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.models.abi import ABIValue @@ -115,17 +117,26 @@ def send_transaction(params: T) -> SendSingleTransactionResult: transaction = composer.build().transactions[-1].txn logger.debug(pre_log(params, transaction)) - raw_result = composer.send() - - result = SendSingleTransactionResult( - **raw_result.__dict__, - confirmation=raw_result.confirmations[-1], - transaction=raw_result.transactions[-1], - tx_id=raw_result.tx_ids[-1], - ) - - if post_log: - logger.debug(post_log(params, result)) + try: + raw_result = composer.send() + raw_result_dict = raw_result.__dict__.copy() + del raw_result_dict["simulate_response"] + + result = SendSingleTransactionResult( + **raw_result_dict, + confirmation=raw_result.confirmations[-1], + transaction=raw_result.transactions[-1], + tx_id=raw_result.tx_ids[-1], + ) + + if post_log: + logger.debug(post_log(params, result)) + except Exception: + simulate_and_persist_response( + composer.atc, + Path("/Users/aorumbayev/MakerX/projects/algokit/algokit-utils/utils/algokit-utils-py"), + self._algod, + ) return result diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 4b250fc..7df50c7 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -218,13 +218,24 @@ def test_abi_with_default_arg_method( default_signer=funded_account.signer, ) ) + # app_client.send. + app_client.send.opt_in(AppClientMethodCallParams(method="opt_in")) + app_client.send.call( + AppClientMethodCallParams( + method="set_local", + args=[1, 2, "banana", [1, 2, 3, 4]], + ) + ) + + method_signature = "default_value_from_local_state(string)string" + defined_value = "defined value" # Test with defined value defined_value_result = app_client.send.call( - AppClientMethodCallParams(method="default_value_from_local_state(string)string", args=["defined value"]) + AppClientMethodCallParams(method=method_signature, args=[defined_value]) ) assert defined_value_result.return_value == "Local state, defined value" # Test with default value - default_value_result = app_client.send.call(AppClientMethodCallParams(method="hello(string)string", args=[None])) - assert default_value_result.return_value == "Hello, default" + default_value_result = app_client.send.call(AppClientMethodCallParams(method=method_signature, args=[None])) + assert default_value_result.return_value == "Local state, banana" From af48a12e7ef14ccd1583b363b27ef240d1992059 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 29 Nov 2024 12:17:09 +0100 Subject: [PATCH 08/19] chore: wip --- src/algokit_utils/applications/app_client.py | 318 +++++++++++++------ 1 file changed, 226 insertions(+), 92 deletions(-) diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 63a7a7b..50d38c6 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -8,24 +8,25 @@ from typing import TYPE_CHECKING, Any, Protocol, TypedDict import algosdk -from algosdk.box_reference import BoxReference from algosdk.transaction import OnComplete, Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification -from algokit_utils.applications.app_manager import AppManager, CompiledTeal, TealTemplateParams from algokit_utils.applications.utils import ( get_abi_decoded_value, get_abi_encoded_value, + get_abi_tuple_from_abi_struct, get_arc56_method, ) -from algokit_utils.models.abi import ABIStruct -from algokit_utils.models.amount import AlgoAmount from algokit_utils.models.application import Arc56Contract, StorageKey, StorageMap from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, + AppCallParams, + AppDeleteMethodCall, AppMethodCallTransactionArgument, + AppUpdateMethodCall, + AppUpdateParams, + BuiltTransactions, PaymentParams, - SenderParam, ) from algokit_utils.transactions.transaction_sender import SendAppTransactionResult, SendSingleTransactionResult @@ -33,10 +34,18 @@ from collections.abc import Callable from algosdk.atomic_transaction_composer import TransactionSigner + from algosdk.box_reference import BoxReference from algosdk.source_map import SourceMap - from algokit_utils.applications.app_manager import AppState - from algokit_utils.models.abi import ABIValue + from algokit_utils.applications.app_manager import ( + AppManager, + AppState, + BoxIdentifier, + CompiledTeal, + TealTemplateParams, + ) + from algokit_utils.models.abi import ABIStruct, ABIValue + from algokit_utils.models.amount import AlgoAmount from algokit_utils.protocols.application import AlgorandClientProtocol from algokit_utils.transactions.transaction_composer import TransactionComposer @@ -309,23 +318,6 @@ def get_global_state(self) -> dict[str, AppState]: @dataclass(frozen=True) class CommonTxnParams: - """ - Common transaction parameters. - - :param signer: The function used to sign transactions. - :param rekey_to: Change the signing key of the sender to the given address. - :param note: Note to attach to the transaction. - :param lease: Prevent multiple transactions with the same lease being included within the validity window. - :param static_fee: The transaction fee. In most cases you want to use `extra_fee` unless setting the fee to 0 to be - covered by another transaction. - :param extra_fee: The fee to pay IN ADDITION to the suggested fee. Useful for covering inner transaction fees. - :param max_fee: Throw an error if the fee for the transaction is more than this amount. - :param validity_window: How many rounds the transaction should be valid for. - :param first_valid_round: Set the first round this transaction is valid. If left undefined, the value from algod - will be used. Only set this when you intentionally want this to be some time in the future. - :param last_valid_round: The last round this transaction is valid. It is recommended to use validity_window instead. - """ - sender: str signer: TransactionSigner | None = None rekey_to: str | None = None @@ -339,37 +331,6 @@ class CommonTxnParams: last_valid_round: int | None = None -@dataclass(frozen=True) -class AppCallParams(CommonTxnParams, SenderParam): - """ - Application call parameters. - - :param on_complete: The OnComplete action. - :param app_id: ID of the application. - :param approval_program: The program to execute for all OnCompletes other than ClearState. - :param clear_state_program: The program to execute for ClearState OnComplete. - :param schema: The state schema for the app. This is immutable. - :param args: Application arguments. - :param account_references: Account references. - :param app_references: App references. - :param asset_references: Asset references. - :param extra_pages: Number of extra pages required for the programs. - :param box_references: Box references. - """ - - on_complete: OnComplete | None = None - app_id: int | None = None - approval_program: str | bytes | None = None - clear_state_program: str | bytes | None = None - schema: dict[str, int] | None = None - args: list[bytes] | None = None - account_references: list[str] | None = None - app_references: list[int] | None = None - asset_references: list[int] | None = None - extra_pages: int | None = None - box_references: list[BoxReference] | None = None - - @dataclass(kw_only=True) class FundAppAccountParams: sender: str | None = None @@ -424,6 +385,25 @@ class AppClientMethodCallParams: on_complete: algosdk.transaction.OnComplete | None = None +class AppClientBareCallParams(TypedDict, total=False): + signer: TransactionSigner | None + rekey_to: str | None + lease: bytes | None + static_fee: AlgoAmount | None + extra_fee: AlgoAmount | None + max_fee: AlgoAmount | None + validity_window: int | None + first_valid_round: int | None + last_valid_round: int | None + sender: str | None + note: bytes | None + args: list[bytes] | None + account_references: list[str] | None + app_references: list[int] | None + asset_references: list[int] | None + box_references: list[BoxReference | BoxIdentifier] | None + + class SendParams(TypedDict, total=False): max_rounds_to_wait: int | None suppress_log: bool | None @@ -450,6 +430,55 @@ def __init__(self, client: AppClient) -> None: self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 + def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + """Get bare parameters for application calls. + + Args: + params: The parameters to process + on_complete: The OnComplete value for the transaction + + Returns: + The processed parameters with defaults filled in + """ + sender = self._client._get_sender(params.get("sender")) + return { + **params, + "app_id": self._app_id, + "sender": sender, + "signer": self._client._get_signer(params.get("sender"), params.get("signer")), + "on_complete": on_complete, + } + + def update(self, params: AppClientBareCallParams | None = None) -> AppUpdateParams: + params = params or {} + call_params: AppUpdateParams = AppUpdateParams( + **self._get_bare_params(params.__dict__, OnComplete.UpdateApplicationOC) + ) + return call_params + + def opt_in(self, params: AppClientBareCallParams | None = None) -> AppCallParams: + params = params or {} + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.OptInOC)) + return call_params + + def delete(self, params: AppClientBareCallParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams( + **self._get_bare_params(params.__dict__, OnComplete.DeleteApplicationOC) + ) + return call_params + + def clear_state(self, params: AppClientBareCallParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.ClearStateOC)) + return call_params + + def close_out(self, params: AppClientBareCallParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.CloseOutOC)) + return call_params + + def call(self, params: AppClientBareCallParams) -> AppCallParams: + call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.NoOpOC)) + return call_params + class _AppClientMethodCallParamsAccessor: def __init__(self, client: AppClient) -> None: @@ -489,6 +518,22 @@ def call(self, params: AppClientMethodCallParams) -> AppCallMethodCall: input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.NoOpOC) return AppCallMethodCall(**input_params) + def delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: + input_params = self._get_abi_params( + params.__dict__, on_complete=algosdk.transaction.OnComplete.DeleteApplicationOC + ) + return AppDeleteMethodCall(**input_params) + + def update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: + input_params = self._get_abi_params( + params.__dict__, on_complete=algosdk.transaction.OnComplete.UpdateApplicationOC + ) + return AppUpdateMethodCall(**input_params) + + def close_out(self, params: AppClientMethodCallParams) -> AppCallMethodCall: + input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.CloseOutOC) + return AppCallMethodCall(**input_params) + def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: input_params = copy.deepcopy(params) @@ -510,47 +555,110 @@ def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transacti return input_params -class _AppClientTransactionCreator: +class _AppClientBareCreateTransactionMethods: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + + def update(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_update(self._client.params.bare.update(params)) + + def opt_in(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.opt_in(params)) + + def delete(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.delete(params)) + + def clear_state(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.clear_state(params)) + + def close_out(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.close_out(params)) + + def call(self, params: AppClientBareCallParams) -> Transaction: + return self._algorand.create_transaction.app_call(self._client.params.bare.call(params)) + + +class _AppClientMethodCallTransactionCreator: def __init__(self, client: AppClient) -> None: self._client = client self._algorand = client._algorand # noqa: SLF001 self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 + self._bare_create_transaction_methods = _AppClientBareCreateTransactionMethods(client) + + @property + def bare(self) -> _AppClientBareCreateTransactionMethods: + return self._bare_create_transaction_methods def fund_app_account(self, params: FundAppAccountParams) -> Transaction: return self._algorand.create_transaction.payment(self._client.params.fund_app_account(params)) - # def update(self, params: AppClientMethodCallParams | AppClientCompilationParams) -> Transaction: - # return self._algorand.create_transaction.app_update_method_call() - - -# update: async (params: AppClientMethodCallParams & AppClientCompilationParams) => { -# return this._algorand.createTransaction.appUpdateMethodCall(await this.params.update(params)) -# }, -# /** -# * Return transactions for an opt-in ABI call -# */ -# optIn: async (params: AppClientMethodCallParams) => { -# return this._algorand.createTransaction.appCallMethodCall(await this.params.optIn(params)) -# }, -# /** -# * Return transactions for a delete ABI call -# */ -# delete: async (params: AppClientMethodCallParams) => { -# return this._algorand.createTransaction.appDeleteMethodCall(await this.params.delete(params)) -# }, -# /** -# * Return transactions for a close out ABI call -# */ -# closeOut: async (params: AppClientMethodCallParams) => { -# return this._algorand.createTransaction.appCallMethodCall(await this.params.closeOut(params)) -# }, -# /** -# * Return transactions for an ABI call (defaults to no-op) -# */ -# call: async (params: AppClientMethodCallParams & CallOnComplete) => { -# return this._algorand.createTransaction.appCallMethodCall(await this.params.call(params)) -# }, + def opt_in(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.opt_in(params)) + + def update(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_update_method_call(self._client.params.update(params)) + + def delete(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_delete_method_call(self._client.params.delete(params)) + + def close_out(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.close_out(params)) + + def call(self, params: AppClientMethodCallParams) -> BuiltTransactions: + return self._algorand.create_transaction.app_call_method_call(self._client.params.call(params)) + + +class _AppClientBareSendAccessor: + def __init__(self, client: AppClient) -> None: + self._client = client + self._algorand = client._algorand # noqa: SLF001 + self._app_id = client._app_id # noqa: SLF001 + self._app_spec = client._app_spec # noqa: SLF001 + + def update( + self, + params: AppClientBareCallParams | None = None, + *, + compilation: AppClientCompilationParams | None = None, + # max_rounds_to_wait: int | None = None, # TODO: revisit + # suppress_log: bool | None = None, + # populate_app_call_resources: bool | None = None, + ) -> SendAppTransactionResult: + """Send an application update transaction. + + Args: + params: The parameters for the update call + compilation: Optional compilation parameters + max_rounds_to_wait: The maximum number of rounds to wait for confirmation + suppress_log: Whether to suppress log output + populate_app_call_resources: Whether to populate app call resources + + Returns: + The result of sending the transaction + """ + compiled = self._client.compile_and_persist_sourcemaps(compilation) + bare_params = self._client.params.bare.update(params) + bare_params.__setattr__("approval_program", bare_params.approval_program or compiled.compiled_approval) + bare_params.__setattr__("clear_state_program", bare_params.clear_state_program or compiled.compiled_clear) + call_result = self._algorand.send.app_update(bare_params) + return SendAppTransactionResult(**{**call_result.__dict__, **(compiled.__dict__ if compiled else {})}) + + def opt_in(self, params: AppClientBareCallParams | None = None) -> SendAppTransactionResult: + return self._algorand.send.app_call(self._client.params.bare.opt_in(params)) + + def delete(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call(self._client.params.bare.delete(params)) + + def clear_state(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call(self._client.params.bare.clear_state(params)) + + def close_out(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call(self._client.params.bare.close_out(params)) + + def call(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call(self._client.params.bare.call(params)) class _AppClientSendAccessor: @@ -559,6 +667,11 @@ def __init__(self, client: AppClient) -> None: self._algorand = client._algorand # noqa: SLF001 self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 + self._bare_send_accessor = _AppClientBareSendAccessor(client) + + @property + def bare(self) -> _AppClientBareSendAccessor: + return self._bare_send_accessor def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: return self._algorand.send.payment(self._client.params.fund_app_account(params)) @@ -566,6 +679,15 @@ def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactio def opt_in(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: return self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + def delete(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_delete_method_call(self._client.params.delete(params)) + + def update(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_update_method_call(self._client.params.update(params)) + + def close_out(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + return self._algorand.send.app_call_method_call(self._client.params.close_out(params)) + def call( self, params: AppClientMethodCallParams, **send_params: typing.Unpack[SendParams] ) -> SendAppTransactionResult: @@ -581,7 +703,7 @@ def call( ) simulate_response = method_call_to_simulate.simulate( - allow_unnamed_resources=send_params["populate_app_call_resources"] if send_params else True, + allow_unnamed_resources=send_params.get("allow_unnamed_resources", False), skip_signatures=True, allow_more_logs=True, allow_empty_signatures=True, @@ -620,6 +742,7 @@ def __init__(self, params: AppClientParams) -> None: self._state_accessor = _AppClientStateAccessor(self) self._params_accessor = _AppClientMethodCallParamsAccessor(self) self._send_accessor = _AppClientSendAccessor(self) + self._create_transaction_accessor = _AppClientMethodCallTransactionCreator(self) @property def app_id(self) -> int: @@ -649,6 +772,10 @@ def params(self) -> _AppClientMethodCallParamsAccessor: def send(self) -> _AppClientSendAccessor: return self._send_accessor + @property + def create_transaction(self) -> _AppClientMethodCallTransactionCreator: + return self._create_transaction_accessor + @staticmethod def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) -> Arc56Contract: if isinstance(app_spec, str): @@ -738,9 +865,6 @@ def compile( compiled_clear=compiled_clear, ) - def process_method_call_return(): - pass - # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' def compile_and_persist_sourcemaps( self, compilation: AppClientCompilationParams | None = None @@ -795,10 +919,20 @@ def _get_signer(self, sender: str | None, signer: TransactionSigner | None) -> T return signer or self._default_signer if sender else None def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + """Get bare parameters for application calls. + + Args: + params: The parameters to process + on_complete: The OnComplete value for the transaction + + Returns: + The processed parameters with defaults filled in + """ + sender = self._get_sender(params.get("sender")) return { **params, "app_id": self._app_id, - "sender": self._get_sender(params.get("sender")), + "sender": sender, "signer": self._get_signer(params.get("sender"), params.get("signer")), "on_complete": on_complete, } From 95613b94cc9253afc41c5378f7fee1970d22b313 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 3 Dec 2024 17:24:36 +0100 Subject: [PATCH 09/19] chore: extra tests; wip --- .vscode/launch.json | 16 + pyproject.toml | 3 + src/algokit_utils/_debugging.py | 47 +- src/algokit_utils/applications/app_client.py | 507 +++++++------ .../applications/app_deployer.py | 116 +++ src/algokit_utils/applications/app_factory.py | 85 +++ src/algokit_utils/applications/app_manager.py | 107 ++- src/algokit_utils/applications/utils.py | 19 +- src/algokit_utils/assets/asset_manager.py | 6 +- src/algokit_utils/clients/algorand_client.py | 2 +- src/algokit_utils/clients/client_manager.py | 32 +- src/algokit_utils/models/abi.py | 9 +- src/algokit_utils/models/application.py | 48 +- src/algokit_utils/models/transaction.py | 8 + src/algokit_utils/transactions/models.py | 11 - .../transactions/transaction_composer.py | 383 ++++++---- .../transactions/transaction_sender.py | 35 +- tests/applications/test_app_client.py | 356 ++++++++- tests/applications/test_app_factory.py | 72 ++ .../templates_arc56_draft.json | 681 ++++++++++++++++++ .../testing_app_puya/arc32_app_spec.json | 184 +++++ tests/artifacts/testing_app_puya/contract.py | 43 ++ 22 files changed, 2274 insertions(+), 496 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 src/algokit_utils/applications/app_deployer.py create mode 100644 src/algokit_utils/applications/app_factory.py create mode 100644 src/algokit_utils/models/transaction.py create mode 100644 tests/applications/test_app_factory.py create mode 100644 tests/artifacts/arc_56_templates/templates_arc56_draft.json create mode 100644 tests/artifacts/testing_app_puya/arc32_app_spec.json create mode 100644 tests/artifacts/testing_app_puya/contract.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6b9d594 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Debug Tests", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "purpose": [ + "debug-test" + ], + "console": "integratedTerminal", + "justMyCode": false + } + ] +} diff --git a/pyproject.toml b/pyproject.toml index 0283ac9..e22173c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,9 @@ extend-exclude = [ # Assume Python 3.10. target-version = "py310" +[tool.ruff.lint.pylint] +max-args = 10 + [tool.ruff.lint.flake8-annotations] allow-star-arg-any = true suppress-none-returning = true diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index d054878..2ab2db5 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -15,7 +15,6 @@ from algosdk.v2client.models import SimulateRequest, SimulateRequestTransactionGroup, SimulateTraceConfig from algokit_utils._legacy_v2.common import Program -from algokit_utils.transactions.models import SimulateOptions if typing.TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -144,7 +143,7 @@ def _write_to_file(path: Path, content: str) -> None: path.write_text(content) -def _build_avm_sourcemap( # noqa: PLR0913 +def _build_avm_sourcemap( *, app_name: str, file_name: str, @@ -205,7 +204,14 @@ def persist_sourcemaps( def simulate_response( atc: AtomicTransactionComposer, algod_client: "AlgodClient", - simulate_options: SimulateOptions | dict[str, typing.Any] | None = None, + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, + skip_signatures: int | None = None, + fix_signers: bool | None = None, ) -> SimulateAtomicTransactionResponse: """ Simulate and fetch response for the given AtomicTransactionComposer and AlgodClient. @@ -224,16 +230,15 @@ def simulate_response( fake_signed_transactions = empty_signer.sign_transactions(txn_list, []) txn_group = [SimulateRequestTransactionGroup(txns=fake_signed_transactions)] trace_config = SimulateTraceConfig(enable=True, stack_change=True, scratch_change=True, state_change=True) - simulate_params: SimulateOptions = simulate_options or {} # type: ignore[assignment] simulate_request = SimulateRequest( txn_groups=txn_group, - allow_more_logs=True, - round=simulate_params.get("round") or None, - extra_opcode_budget=simulate_params.get("extra_opcode_budget") or 0, - allow_unnamed_resources=simulate_params.get("allow_unnamed_resources") or True, - allow_empty_signatures=simulate_params.get("allow_empty_signatures") or True, - exec_trace_config=simulate_params.get("exec_trace_config") or trace_config, + allow_more_logs=allow_more_logs or True, + round=round, + extra_opcode_budget=extra_opcode_budget or 0, + allow_unnamed_resources=allow_unnamed_resources or True, + allow_empty_signatures=allow_empty_signatures or True, + exec_trace_config=exec_trace_config or trace_config, ) return atc.simulate(algod_client, simulate_request) @@ -244,7 +249,14 @@ def simulate_and_persist_response( project_root: Path, algod_client: "AlgodClient", buffer_size_mb: float = 256, - simulate_options: SimulateOptions | dict[str, typing.Any] | None = None, + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, + skip_signatures: int | None = None, + fix_signers: bool | None = None, ) -> SimulateAtomicTransactionResponse: """ Simulates the atomic transactions using the provided `AtomicTransactionComposer` object and `AlgodClient` object, @@ -269,7 +281,18 @@ def simulate_and_persist_response( txn_with_sign.txn.last_valid_round = sp.last txn_with_sign.txn.genesis_hash = sp.gh - response = simulate_response(atc_to_simulate, algod_client, simulate_options) + response = simulate_response( + atc_to_simulate, + algod_client, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, + ) txn_results = response.simulate_response["txn-groups"] txn_types = [txn_result["txn-results"][0]["txn-result"]["txn"]["txn"]["type"] for txn_result in txn_results] diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 50d38c6..69fc31a 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -3,21 +3,23 @@ import base64 import copy import json -import typing +import os from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol, TypedDict +from typing import TYPE_CHECKING, Any, Protocol import algosdk from algosdk.transaction import OnComplete, Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_manager import BoxABIValue, BoxName, BoxValue from algokit_utils.applications.utils import ( get_abi_decoded_value, get_abi_encoded_value, get_abi_tuple_from_abi_struct, get_arc56_method, ) -from algokit_utils.models.application import Arc56Contract, StorageKey, StorageMap +from algokit_utils.models.application import AppState, Arc56Contract, CompiledTeal, StorageKey, StorageMap +from algokit_utils.models.transaction import SendParams from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, AppCallParams, @@ -34,17 +36,15 @@ from collections.abc import Callable from algosdk.atomic_transaction_composer import TransactionSigner - from algosdk.box_reference import BoxReference from algosdk.source_map import SourceMap from algokit_utils.applications.app_manager import ( AppManager, - AppState, BoxIdentifier, - CompiledTeal, + BoxReference, TealTemplateParams, ) - from algokit_utils.models.abi import ABIStruct, ABIValue + from algokit_utils.models.abi import ABIStruct, ABIType, ABIValue from algokit_utils.models.amount import AlgoAmount from algokit_utils.protocols.application import AlgorandClientProtocol from algokit_utils.transactions.transaction_composer import TransactionComposer @@ -116,13 +116,20 @@ def get_constant_block_offset(program: bytes) -> int: # noqa: C901 return max(bytecblock_offset or 0, intcblock_offset or 0) -@dataclass +@dataclass(frozen=True, kw_only=True) +class AppClientCompilationParams: + deploy_time_params: TealTemplateParams | None = None + updatable: bool | None = None + deletable: bool | None = None + + +@dataclass(frozen=True, kw_only=True) class ProgramSourceInfo: pc_offset_method: str | None source_info: list[dict[str, Any]] -@dataclass +@dataclass(frozen=True, kw_only=True) class ExposedLogicErrorDetails: is_clear_state_program: bool = False approval_source_map: SourceMap | None = None @@ -132,28 +139,29 @@ class ExposedLogicErrorDetails: clear_source_info: ProgramSourceInfo | None = None -@dataclass(kw_only=True) -class _CommonAppClientParams: - app_name: str | None = None - default_sender: str | None = None - default_signer: TransactionSigner | None = None - approval_source_map: SourceMap | None = None - clear_source_map: SourceMap | None = None - +@dataclass(kw_only=True, frozen=True) +class _AppClientParamsBase: + """Base parameters for creating an app client""" -@dataclass(kw_only=True) -class AppClientParams(_CommonAppClientParams): app_id: int - app_spec: Arc56Contract | ApplicationSpecification | str - algorand: AlgorandClientProtocol + app_spec: ( + Arc56Contract | ApplicationSpecification | str + ) # Using string quotes since these types may be defined elsewhere + algorand: AlgorandClientProtocol # Using string quotes since this type may be defined elsewhere -@dataclass(kw_only=True) -class CloneAppClientParams(_CommonAppClientParams): - app_id: int | None = None +@dataclass(kw_only=True, frozen=True) +class AppClientParams(_AppClientParamsBase): + """Full parameters for creating an app client""" + + app_name: str | None = None + default_sender: str | bytes | None = None # Address can be string or bytes + default_signer: TransactionSigner | None = None + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None -@dataclass(kw_only=True) +@dataclass(frozen=True, kw_only=True) class AppClientCompilationResult: approval_program: bytes clear_state_program: bytes @@ -161,11 +169,149 @@ class AppClientCompilationResult: compiled_clear: CompiledTeal | None = None -@dataclass(kw_only=True) -class CompileAppClientParams: +@dataclass(frozen=True, kw_only=True) +class CommonTxnParams: + sender: str + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class FundAppAccountParams: + sender: str | None = None + signer: TransactionSigner | None = None + rekey_to: str | None = None + note: bytes | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + amount: AlgoAmount + close_remainder_to: str | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None + on_complete: algosdk.transaction.OnComplete | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppClientCallParams: + method: str | None = None # If calling ABI method, name or signature + args: list | None = None # Arguments to pass to the method + boxes: list | None = None # Box references to load + accounts: list[str] | None = None # Account addresses to load + apps: list[int] | None = None # App IDs to load + assets: list[int] | None = None # Asset IDs to load + lease: (str | bytes) | None = None # Optional lease + sender: str | None = None # Optional sender account + note: (bytes | dict | str) | None = None # Transaction note + send_params: dict | None = None # Parameters to control transaction sending + + +@dataclass(frozen=True, kw_only=True) +class AppClientMethodCallParams: + method: str + args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference | BoxIdentifier] | None = None + extra_fee: AlgoAmount | None = None + first_valid_round: int | None = None + lease: bytes | None = None + max_fee: AlgoAmount | None = None + note: bytes | None = None + rekey_to: str | None = None + sender: str | None = None + signer: TransactionSigner | None = None + static_fee: AlgoAmount | None = None + validity_window: int | None = None + last_valid_round: int | None = None + on_complete: algosdk.transaction.OnComplete | None = None + + +@dataclass(frozen=True, kw_only=True) +class AppClientMethodCallWithCompilationParams(AppClientMethodCallParams, AppClientCompilationParams): + """Combined parameters for method calls with compilation""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientMethodCallWithSendParams(AppClientMethodCallParams, SendParams): + """Combined parameters for method calls with send options""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientMethodCallWithCompilationAndSendParams( + AppClientMethodCallParams, AppClientCompilationParams, SendParams +): + """Combined parameters for method calls with compilation and send options""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientBareCallParams: + signer: TransactionSigner | None + rekey_to: str | None + lease: bytes | None + static_fee: AlgoAmount | None + extra_fee: AlgoAmount | None + max_fee: AlgoAmount | None + validity_window: int | None + first_valid_round: int | None + last_valid_round: int | None + sender: str | None + note: bytes | None + args: list[bytes] | None + account_references: list[str] | None + app_references: list[int] | None + asset_references: list[int] | None + box_references: list[BoxReference | BoxIdentifier] | None + + +@dataclass(frozen=True, kw_only=True) +class CallOnComplete: + on_complete: algosdk.transaction.OnComplete + + +@dataclass(frozen=True, kw_only=True) +class AppClientBareCallWithCompilationParams(AppClientBareCallParams, AppClientCompilationParams): + """Combined parameters for bare calls with compilation""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientBareCallWithSendParams(AppClientBareCallParams, SendParams): + """Combined parameters for bare calls with send options""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientBareCallWithCompilationAndSendParams(AppClientBareCallParams, AppClientCompilationParams, SendParams): + """Combined parameters for bare calls with compilation and send options""" + + +@dataclass(frozen=True, kw_only=True) +class AppClientBareCallWithCallOnCompleteParams(AppClientBareCallParams, CallOnComplete): + """Combined parameters for bare calls with an OnComplete value""" + + +@dataclass(frozen=True, kw_only=True) +class ResolveAppClientByNetwork: app_spec: Arc56Contract | ApplicationSpecification | str algorand: AlgorandClientProtocol - compilation: AppClientCompilationParams | None = None + app_name: str | None = None + default_sender: str | bytes | None = None + default_signer: TransactionSigner | None = None + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None class _AppClientStateMethodsProtocol(Protocol): @@ -316,113 +462,6 @@ def get_global_state(self) -> dict[str, AppState]: return self._algorand.app.get_global_state(self._app_id) -@dataclass(frozen=True) -class CommonTxnParams: - sender: str - signer: TransactionSigner | None = None - rekey_to: str | None = None - note: bytes | None = None - lease: bytes | None = None - static_fee: AlgoAmount | None = None - extra_fee: AlgoAmount | None = None - max_fee: AlgoAmount | None = None - validity_window: int | None = None - first_valid_round: int | None = None - last_valid_round: int | None = None - - -@dataclass(kw_only=True) -class FundAppAccountParams: - sender: str | None = None - signer: TransactionSigner | None = None - rekey_to: str | None = None - note: bytes | None = None - lease: bytes | None = None - static_fee: AlgoAmount | None = None - extra_fee: AlgoAmount | None = None - max_fee: AlgoAmount | None = None - validity_window: int | None = None - first_valid_round: int | None = None - last_valid_round: int | None = None - amount: AlgoAmount - close_remainder_to: str | None = None - max_rounds_to_wait: int | None = None - suppress_log: bool | None = None - populate_app_call_resources: bool | None = None - on_complete: algosdk.transaction.OnComplete | None = None - - -@dataclass(kw_only=True) -class AppClientCallParams: - method: str | None = None # If calling ABI method, name or signature - args: list | None = None # Arguments to pass to the method - boxes: list | None = None # Box references to load - accounts: list[str] | None = None # Account addresses to load - apps: list[int] | None = None # App IDs to load - assets: list[int] | None = None # Asset IDs to load - lease: (str | bytes) | None = None # Optional lease - sender: str | None = None # Optional sender account - note: (bytes | dict | str) | None = None # Transaction note - send_params: dict | None = None # Parameters to control transaction sending - - -@dataclass(kw_only=True) -class AppClientMethodCallParams: - method: str - sender: str | None = None - args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None = None - signer: TransactionSigner | None = None - rekey_to: str | None = None - note: bytes | None = None - lease: bytes | None = None - static_fee: AlgoAmount | None = None - extra_fee: AlgoAmount | None = None - max_fee: AlgoAmount | None = None - validity_window: int | None = None - first_valid_round: int | None = None - last_valid_round: int | None = None - # OnComplete - on_complete: algosdk.transaction.OnComplete | None = None - - -class AppClientBareCallParams(TypedDict, total=False): - signer: TransactionSigner | None - rekey_to: str | None - lease: bytes | None - static_fee: AlgoAmount | None - extra_fee: AlgoAmount | None - max_fee: AlgoAmount | None - validity_window: int | None - first_valid_round: int | None - last_valid_round: int | None - sender: str | None - note: bytes | None - args: list[bytes] | None - account_references: list[str] | None - app_references: list[int] | None - asset_references: list[int] | None - box_references: list[BoxReference | BoxIdentifier] | None - - -class SendParams(TypedDict, total=False): - max_rounds_to_wait: int | None - suppress_log: bool | None - populate_app_call_resources: bool | None - - -@dataclass(kw_only=True) -class AppClientCompilationParams: - deploy_time_params: TealTemplateParams | None = None - updatable: bool | None = None - deletable: bool | None = None - - -@dataclass(kw_only=True) -class ResolveAppClientByNetwork(_CommonAppClientParams): - app_spec: Arc56Contract | ApplicationSpecification | str - algorand: AlgorandClientProtocol - - class _AppClientBareParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client @@ -430,7 +469,9 @@ def __init__(self, client: AppClient) -> None: self._app_id = client._app_id # noqa: SLF001 self._app_spec = client._app_spec # noqa: SLF001 - def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: + def _get_bare_params( + self, params: dict[str, Any] | None, on_complete: algosdk.transaction.OnComplete + ) -> dict[str, Any]: """Get bare parameters for application calls. Args: @@ -440,6 +481,7 @@ def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transact Returns: The processed parameters with defaults filled in """ + params = params or {} sender = self._client._get_sender(params.get("sender")) return { **params, @@ -449,33 +491,31 @@ def _get_bare_params(self, params: dict[str, Any], on_complete: algosdk.transact "on_complete": on_complete, } - def update(self, params: AppClientBareCallParams | None = None) -> AppUpdateParams: - params = params or {} + def update(self, params: AppClientBareCallWithCompilationAndSendParams | None = None) -> AppUpdateParams: call_params: AppUpdateParams = AppUpdateParams( - **self._get_bare_params(params.__dict__, OnComplete.UpdateApplicationOC) + **self._get_bare_params(params.__dict__ if params else {}, OnComplete.UpdateApplicationOC) ) return call_params - def opt_in(self, params: AppClientBareCallParams | None = None) -> AppCallParams: - params = params or {} + def opt_in(self, params: AppClientBareCallWithSendParams | None = None) -> AppCallParams: call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.OptInOC)) return call_params - def delete(self, params: AppClientBareCallParams) -> AppCallParams: + def delete(self, params: AppClientBareCallWithSendParams) -> AppCallParams: call_params: AppCallParams = AppCallParams( **self._get_bare_params(params.__dict__, OnComplete.DeleteApplicationOC) ) return call_params - def clear_state(self, params: AppClientBareCallParams) -> AppCallParams: + def clear_state(self, params: AppClientBareCallWithSendParams) -> AppCallParams: call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.ClearStateOC)) return call_params - def close_out(self, params: AppClientBareCallParams) -> AppCallParams: + def close_out(self, params: AppClientBareCallWithSendParams) -> AppCallParams: call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.CloseOutOC)) return call_params - def call(self, params: AppClientBareCallParams) -> AppCallParams: + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> AppCallParams: call_params: AppCallParams = AppCallParams(**self._get_bare_params(params.__dict__, OnComplete.NoOpOC)) return call_params @@ -493,13 +533,16 @@ def bare(self) -> _AppClientBareParamsAccessor: return self._bare_params_accessor def fund_app_account(self, params: FundAppAccountParams) -> PaymentParams: + def random_note() -> bytes: + return base64.b64encode(os.urandom(16)) + return PaymentParams( sender=self._client._get_sender(params.sender), signer=self._client._get_signer(params.sender, params.signer), receiver=self._client.app_address, amount=params.amount, rekey_to=params.rekey_to, - note=params.note, + note=params.note or random_note(), lease=params.lease, static_fee=params.static_fee, extra_fee=params.extra_fee, @@ -560,22 +603,22 @@ def __init__(self, client: AppClient) -> None: self._client = client self._algorand = client._algorand # noqa: SLF001 - def update(self, params: AppClientBareCallParams) -> Transaction: + def update(self, params: AppClientBareCallWithCompilationAndSendParams) -> Transaction: return self._algorand.create_transaction.app_update(self._client.params.bare.update(params)) - def opt_in(self, params: AppClientBareCallParams) -> Transaction: + def opt_in(self, params: AppClientBareCallWithSendParams) -> Transaction: return self._algorand.create_transaction.app_call(self._client.params.bare.opt_in(params)) - def delete(self, params: AppClientBareCallParams) -> Transaction: + def delete(self, params: AppClientBareCallWithSendParams) -> Transaction: return self._algorand.create_transaction.app_call(self._client.params.bare.delete(params)) - def clear_state(self, params: AppClientBareCallParams) -> Transaction: + def clear_state(self, params: AppClientBareCallWithSendParams) -> Transaction: return self._algorand.create_transaction.app_call(self._client.params.bare.clear_state(params)) - def close_out(self, params: AppClientBareCallParams) -> Transaction: + def close_out(self, params: AppClientBareCallWithSendParams) -> Transaction: return self._algorand.create_transaction.app_call(self._client.params.bare.close_out(params)) - def call(self, params: AppClientBareCallParams) -> Transaction: + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> Transaction: return self._algorand.create_transaction.app_call(self._client.params.bare.call(params)) @@ -619,12 +662,7 @@ def __init__(self, client: AppClient) -> None: def update( self, - params: AppClientBareCallParams | None = None, - *, - compilation: AppClientCompilationParams | None = None, - # max_rounds_to_wait: int | None = None, # TODO: revisit - # suppress_log: bool | None = None, - # populate_app_call_resources: bool | None = None, + params: AppClientBareCallWithCompilationAndSendParams, ) -> SendAppTransactionResult: """Send an application update transaction. @@ -638,26 +676,28 @@ def update( Returns: The result of sending the transaction """ - compiled = self._client.compile_and_persist_sourcemaps(compilation) + compiled = self._client.compile_and_persist_sourcemaps( + params.deploy_time_params, params.updatable, params.deletable + ) bare_params = self._client.params.bare.update(params) bare_params.__setattr__("approval_program", bare_params.approval_program or compiled.compiled_approval) bare_params.__setattr__("clear_state_program", bare_params.clear_state_program or compiled.compiled_clear) call_result = self._algorand.send.app_update(bare_params) return SendAppTransactionResult(**{**call_result.__dict__, **(compiled.__dict__ if compiled else {})}) - def opt_in(self, params: AppClientBareCallParams | None = None) -> SendAppTransactionResult: + def opt_in(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call(self._client.params.bare.opt_in(params)) - def delete(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + def delete(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call(self._client.params.bare.delete(params)) - def clear_state(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + def clear_state(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call(self._client.params.bare.clear_state(params)) - def close_out(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + def close_out(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call(self._client.params.bare.close_out(params)) - def call(self, params: AppClientBareCallParams) -> SendAppTransactionResult: + def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> SendAppTransactionResult: return self._algorand.send.app_call(self._client.params.bare.call(params)) @@ -676,21 +716,19 @@ def bare(self) -> _AppClientBareSendAccessor: def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: return self._algorand.send.payment(self._client.params.fund_app_account(params)) - def opt_in(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + def opt_in(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) - def delete(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + def delete(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_delete_method_call(self._client.params.delete(params)) - def update(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + def update(self, params: AppClientMethodCallWithCompilationAndSendParams) -> SendAppTransactionResult: return self._algorand.send.app_update_method_call(self._client.params.update(params)) - def close_out(self, params: AppClientMethodCallParams) -> SendAppTransactionResult: + def close_out(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: return self._algorand.send.app_call_method_call(self._client.params.close_out(params)) - def call( - self, params: AppClientMethodCallParams, **send_params: typing.Unpack[SendParams] - ) -> SendAppTransactionResult: + def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: is_read_only_call = ( params.on_complete == algosdk.transaction.OnComplete.NoOpOC or not params.on_complete @@ -703,7 +741,7 @@ def call( ) simulate_response = method_call_to_simulate.simulate( - allow_unnamed_resources=send_params.get("allow_unnamed_resources", False), + allow_unnamed_resources=params.populate_app_call_resources or True, skip_signatures=True, allow_more_logs=True, allow_empty_signatures=True, @@ -799,9 +837,17 @@ def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) raise ValueError("Invalid app spec format") @staticmethod - def from_network(params: ResolveAppClientByNetwork) -> AppClient: - network = params.algorand.client.network() - app_spec = AppClient.normalise_app_spec(params.app_spec) + def from_network( + app_spec: Arc56Contract | ApplicationSpecification | str, + algorand: AlgorandClientProtocol, + app_name: str | None = None, + default_sender: str | bytes | None = None, + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + network = algorand.client.network() + app_spec = AppClient.normalise_app_spec(app_spec) network_names = [network.genesis_hash] if network.is_local_net: @@ -819,15 +865,26 @@ def from_network(params: ResolveAppClientByNetwork) -> AppClient: app_id = app_spec.networks[available_app_spec_networks[network_index]]["app_id"] # type: ignore[index] - input_params = params.__dict__ - input_params["app_id"] = app_id - input_params["app_spec"] = app_spec - - return AppClient(AppClientParams(**input_params)) # type:ignore[arg-type, call-arg] + return AppClient( + AppClientParams( + app_id=app_id, + app_spec=app_spec, + algorand=algorand, + app_name=app_name, + default_sender=default_sender, + default_signer=default_signer, + approval_source_map=approval_source_map, + clear_source_map=clear_source_map, + ) + ) @staticmethod def compile( - app_spec: Arc56Contract, app_manager: AppManager, compilation: AppClientCompilationParams | None = None + app_spec: Arc56Contract, + app_manager: AppManager, + deploy_time_params: TealTemplateParams | None = None, + updatable: bool | None = None, + deletable: bool | None = None, ) -> AppClientCompilationResult: if not app_spec.source: if not app_spec.byte_code or not app_spec.byte_code.get("approval") or not app_spec.byte_code.get("clear"): @@ -840,20 +897,20 @@ def compile( approval_template: str = base64.b64decode(app_spec.source.get("approval", "")).decode("utf-8") # type: ignore[assignment] deployment_metadata = ( - {"updatable": compilation.updatable or False, "deletable": compilation.deletable or False} - if compilation + {"updatable": updatable or False, "deletable": deletable or False} + if updatable is not None or deletable is not None else None ) compiled_approval = app_manager.compile_teal_template( approval_template, - template_params=compilation.deploy_time_params if compilation else None, + template_params=deploy_time_params, deployment_metadata=deployment_metadata, ) clear_template: str = base64.b64decode(app_spec.source.get("clear", "")).decode("utf-8") # type: ignore[assignment] compiled_clear = app_manager.compile_teal_template( clear_template, - template_params=compilation.deploy_time_params if compilation else None, + template_params=deploy_time_params, ) # TODO: Add invocation of persisting sourcemaps @@ -867,9 +924,12 @@ def compile( # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' def compile_and_persist_sourcemaps( - self, compilation: AppClientCompilationParams | None = None + self, + deploy_time_params: TealTemplateParams | None = None, + updatable: bool | None = None, + deletable: bool | None = None, ) -> AppClientCompilationResult: - result = AppClient.compile(self._app_spec, self._algorand.app, compilation) + result = AppClient.compile(self._app_spec, self._algorand.app, deploy_time_params, updatable, deletable) if result.compiled_approval: self._approval_source_map = result.compiled_approval.source_map @@ -878,23 +938,26 @@ def compile_and_persist_sourcemaps( return result - def clone(self, params: CloneAppClientParams) -> AppClient: - default_params = { - "app_id": self._app_id, - "algorand": self._algorand, - "app_spec": self._app_spec, - "app_name": self._app_name, - "default_sender": self._default_sender, - "default_signer": self._default_signer, - "approval_source_map": self._approval_source_map, - "clear_source_map": self._clear_source_map, - } - - for k, v in params.__dict__.items(): - if k and v: - default_params[k] = v - - return AppClient(AppClientParams(**default_params)) # type: ignore[arg-type] + def clone( + self, + app_name: str | None = None, + default_sender: str | bytes | None = None, + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + return AppClient( + AppClientParams( + app_id=self._app_id, + algorand=self._algorand, + app_spec=self._app_spec, + app_name=app_name or self._app_name, + default_sender=default_sender or self._default_sender, + default_signer=default_signer or self._default_signer, + approval_source_map=approval_source_map or self._approval_source_map, + clear_source_map=clear_source_map or self._clear_source_map, + ) + ) def get_local_state(self, address: str) -> dict[str, AppState]: return self._state_accessor.get_local_state(address) @@ -902,6 +965,42 @@ def get_local_state(self, address: str) -> dict[str, AppState]: def get_global_state(self) -> dict[str, AppState]: return self._state_accessor.get_global_state() + def get_box_names(self) -> list[BoxName]: + return self._algorand.app.get_box_names(self._app_id) + + def get_box_value(self, name: BoxIdentifier) -> bytes: + return self._algorand.app.get_box_value(self._app_id, name) + + def get_box_value_from_abi_type(self, name: BoxIdentifier, abi_type: ABIType) -> Any: + return self._algorand.app.get_box_value_from_abi_type(self._app_id, name, abi_type) + + def get_box_values(self, filter_func: Callable[[BoxName], bool] | None = None) -> list[BoxValue]: + names = self.get_box_names() + if filter_func: + names = [name for name in names if filter_func(name)] + + # Get values for filtered names + values = self._algorand.app.get_box_values(self.app_id, [name.name_raw for name in names]) + + # Return list of BoxValue objects + return [BoxValue(name=name, value=values[i]) for i, name in enumerate(names)] + + def get_box_values_from_abi_type( + self, abi_type: ABIType, filter_func: Callable[[BoxName], bool] | None = None + ) -> list[BoxABIValue]: + # Get box names and apply filter if provided + names = self.get_box_names() + if filter_func: + names = [name for name in names if filter_func(name)] + + # Get values for filtered names and decode them + values = self._algorand.app.get_box_values_from_abi_type( + self.app_id, [name.name_raw for name in names], abi_type + ) + + # Return list of BoxABIValue objects + return [BoxABIValue(name=name, value=values[i]) for i, name in enumerate(names)] + def new_group(self) -> TransactionComposer: return self._algorand.new_group() diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py new file mode 100644 index 0000000..3765bc0 --- /dev/null +++ b/src/algokit_utils/applications/app_deployer.py @@ -0,0 +1,116 @@ +# from dataclasses import dataclass +# from typing import Any + +# from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +# from algokit_utils.models.account import Account + + +# @dataclass +# class AppFactoryParams: +# app_spec: ApplicationSpecification | str +# algorand: Any # AlgorandClient +# app_name: str | None = None +# default_sender: str | None = None +# default_signer: Any | None = None # TransactionSigner +# version: str | None = None +# updatable: bool | None = None +# deletable: bool | None = None +# deploy_time_params: dict[str, Any] | None = None + + +# @dataclass +# class AppDeployer: +# app_manager: Any # AppManager +# transaction_sender: Any # AlgorandClientTransactionSender +# indexer: Any | None = None # Indexer + +# def __post_init__(self): +# self._app_lookups = {} + +# def deploy(self, deployment: dict[str, Any]): +# metadata = deployment["metadata"] +# deploy_time_params = deployment.get("deploy_time_params") +# on_schema_break = deployment.get("on_schema_break") +# on_update = deployment.get("on_update") +# create_params = deployment["create_params"] +# update_params = deployment["update_params"] +# delete_params = deployment["delete_params"] +# existing_deployments = deployment.get("existing_deployments") +# ignore_cache = deployment.get("ignore_cache", False) +# send_params = { +# k: v +# for k, v in deployment.items() +# if k +# not in { +# "metadata", +# "deploy_time_params", +# "on_schema_break", +# "on_update", +# "create_params", +# "update_params", +# "delete_params", +# "existing_deployments", +# "ignore_cache", +# } +# } + +# create_params["note"] = update_params["note"] = TransactionComposer.arc2_note( +# dapp_name="ALGOKIT_DEPLOYER", data=metadata, format="j" +# ) + +# if existing_deployments and existing_deployments["creator"] != create_params["sender"]: +# raise ValueError("Invalid existingDeployments creator") + +# if not existing_deployments and not self.indexer: +# raise ValueError("Need indexer or existingDeployments") + +# apps = existing_deployments or self.get_creator_apps_by_name(create_params["sender"], ignore_cache) +# existing_app = apps["apps"].get(metadata["name"]) + +# if not existing_app or existing_app["deleted"]: +# return self._create_app(create_params, metadata, send_params) + +# return self._handle_existing_app( +# existing_app, create_params, update_params, delete_params, metadata, on_schema_break, on_update, send_params +# ) + +# def get_creator_apps_by_name(self, creator_address: str | Account, ignore_cache: bool = False): +# if isinstance(creator_address, Account): +# creator_address = creator_address.address + +# if not ignore_cache and creator_address in self._app_lookups: +# return self._app_lookups[creator_address] + +# if not self.indexer: +# raise ValueError("Need indexer for getCreatorApps") + +# app_lookup = {} +# # Implementation of lookup logic here + +# lookup = {"creator": creator_address, "apps": app_lookup} + +# self._app_lookups[creator_address] = lookup +# return lookup + +# def _create_app(self, create_params, metadata, send_params): +# # Implementation of app creation +# pass + +# def _handle_existing_app( +# self, +# existing_app, +# create_params, +# update_params, +# delete_params, +# metadata, +# on_schema_break, +# on_update, +# send_params, +# ): +# # Implementation of handling existing app +# pass + +# def _update_app_lookup(self, sender: str, app_metadata: dict[str, Any]): +# lookup = self._app_lookups.get(sender, {"creator": sender, "apps": {}}) +# lookup["apps"][app_metadata["name"]] = app_metadata +# self._app_lookups[sender] = lookup diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py new file mode 100644 index 0000000..b800e3e --- /dev/null +++ b/src/algokit_utils/applications/app_factory.py @@ -0,0 +1,85 @@ +# from dataclasses import dataclass +# from typing import Any + +# from algokit_utils._legacy_v2.application_client import ApplicationClient +# from algokit_utils._legacy_v2.application_specification import ApplicationSpecification + + +# @dataclass +# class AppFactoryParams: +# app_spec: ApplicationSpecification | str +# algorand: Any # AlgorandClient +# app_name: str | None = None +# default_sender: str | None = None +# default_signer: Any | None = None # TransactionSigner +# version: str | None = None +# updatable: bool | None = None +# deletable: bool | None = None +# deploy_time_params: dict[str, Any] | None = None + + +# class AppFactory: +# def __init__(self, params: AppFactoryParams): +# self._app_spec = ApplicationClient.normalise_app_spec(params.app_spec) +# self._app_name = params.app_name or self._app_spec.name +# self._algorand = params.algorand +# self._version = params.version or "1.0" +# self._default_sender = params.default_sender +# self._default_signer = params.default_signer +# self._deploy_time_params = params.deploy_time_params +# self._updatable = params.updatable +# self._deletable = params.deletable +# self._approval_source_map = None +# self._clear_source_map = None + +# @property +# def app_name(self): +# return self._app_name + +# @property +# def app_spec(self): +# return self._app_spec + +# @property +# def algorand(self): +# return self._algorand + +# def get_app_client_by_id(self, params: dict[str, Any]): +# return ApplicationClient( +# algod_client=self._algorand, +# app_spec=self._app_spec, +# app_id=params.get("app_id", 0), +# app_name=params.get("app_name", self._app_name), +# default_sender=params.get("default_sender", self._default_sender), +# default_signer=params.get("default_signer", self._default_signer), +# template_values=params.get("template_values"), +# ) + +# def get_app_client_by_creator_and_name(self, params: dict[str, Any]): +# return ApplicationClient.from_creator_and_name( +# algod_client=self._algorand, +# app_spec=self._app_spec, +# creator=params["creator"], +# indexer_client=params.get("indexer_client"), +# app_name=params.get("app_name", self._app_name), +# default_sender=params.get("default_sender", self._default_sender), +# template_values=params.get("template_values"), +# ) + +# def deploy(self, params: dict[str, Any]): +# updatable = params.get("updatable", self._updatable) +# deletable = params.get("deletable", self._deletable) +# deploy_time_params = params.get("deploy_time_params", self._deploy_time_params) + +# app_client = self.get_app_client_by_id({}) +# return app_client.deploy( +# version=params.get("version"), +# signer=params.get("signer"), +# sender=params.get("sender"), +# allow_update=updatable, +# allow_delete=deletable, +# template_values=deploy_time_params, +# create_args=params.get("create_args"), +# update_args=params.get("update_args"), +# delete_args=params.get("delete_args"), +# ) diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 4f27310..98ed28c 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -8,27 +8,37 @@ import algosdk.atomic_transaction_composer import algosdk.box_reference from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.box_reference import BoxReference as AlgosdkBoxReference from algosdk.logic import get_application_address from algosdk.v2client import algod -from algokit_utils.models.abi import ABIValue -from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME +from algokit_utils.models.abi import ABIType, ABIValue +from algokit_utils.models.application import ( + DELETABLE_TEMPLATE_NAME, + UPDATABLE_TEMPLATE_NAME, + AppInformation, + AppState, + CompiledTeal, +) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class BoxName: name: str name_raw: bytes name_base64: str -@dataclass(frozen=True) -class AppState: - key_raw: bytes - key_base64: str - value_raw: bytes | None - value_base64: str | None - value: str | int +@dataclass(frozen=True, kw_only=True) +class BoxValue: + name: BoxName + value: bytes + + +@dataclass(frozen=True, kw_only=True) +class BoxABIValue: + name: BoxName + value: ABIValue class DataTypeFlag(IntEnum): @@ -39,36 +49,17 @@ class DataTypeFlag(IntEnum): TealTemplateParams: TypeAlias = Mapping[str, str | int | bytes] | dict[str, str | int | bytes] -@dataclass(frozen=True) -class AppInformation: - app_id: int - app_address: str - approval_program: bytes - clear_state_program: bytes - creator: str - global_state: dict[str, AppState] - local_ints: int - local_byte_slices: int - global_ints: int - global_byte_slices: int - extra_program_pages: int | None - - -@dataclass(frozen=True) -class CompiledTeal: - teal: str - compiled: bytes - compiled_hash: str - compiled_base64_to_bytes: bytes - source_map: algosdk.source_map.SourceMap | None +BoxIdentifier: TypeAlias = str | bytes | AccountTransactionSigner -class AppCompilationResult: - compiled_approval: CompiledTeal - compiled_clear: CompiledTeal +class BoxReference(AlgosdkBoxReference): + def __init__(self, app_id: int, name: bytes): + super().__init__(app_index=app_id, name=name) - -BoxIdentifier = str | bytes | AccountTransactionSigner + def __eq__(self, other: object) -> bool: + if isinstance(other, (BoxReference | AlgosdkBoxReference)): + return self.app_index == other.app_index and self.name == other.name + return False def _is_valid_token_character(char: str) -> bool: @@ -187,7 +178,7 @@ def compile_teal_template( self, teal_template_code: str, template_params: TealTemplateParams | None = None, - deployment_metadata: dict[str, bool] | None = None, + deployment_metadata: Mapping[str, bool] | None = None, ) -> CompiledTeal: teal_code = AppManager.strip_teal_comments(teal_template_code) teal_code = AppManager.replace_template_variables(teal_code, template_params or {}) @@ -242,37 +233,45 @@ def get_box_names(self, app_id: int) -> list[BoxName]: ] def get_box_value(self, app_id: int, box_name: BoxIdentifier) -> bytes: - name = b"" - if isinstance(box_name, str): - name = box_name.encode("utf-8") - elif isinstance(box_name, bytes): - name = box_name - elif isinstance(box_name, AccountTransactionSigner): - name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_name.private_key)) - else: - raise ValueError(f"Invalid box identifier type: {type(box_name)}") - + name = AppManager.get_box_reference(box_name)[1] box_result = self._algod.application_box_by_name(app_id, name) assert isinstance(box_result, dict) - return base64.b64decode(box_result["value"]) + return bytes(box_result["value"], "utf-8") def get_box_values(self, app_id: int, box_names: list[BoxIdentifier]) -> list[bytes]: return [self.get_box_value(app_id, box_name) for box_name in box_names] - def get_box_value_from_abi_type( - self, app_id: int, box_name: BoxIdentifier, abi_type: algosdk.abi.ABIType - ) -> ABIValue: + def get_box_value_from_abi_type(self, app_id: int, box_name: BoxIdentifier, abi_type: ABIType) -> ABIValue: value = self.get_box_value(app_id, box_name) try: - return abi_type.decode(value) # type: ignore[no-any-return] + parse_to_tuple = isinstance(abi_type, algosdk.abi.TupleType) + decoded_value = abi_type.decode(base64.b64decode(value)) # type: ignore[no-any-return] + return tuple(decoded_value) if parse_to_tuple else decoded_value except Exception as e: raise ValueError(f"Failed to decode box value {value.decode('utf-8')} with ABI type {abi_type}") from e def get_box_values_from_abi_type( - self, app_id: int, box_names: list[BoxIdentifier], abi_type: algosdk.abi.ABIType + self, app_id: int, box_names: list[BoxIdentifier], abi_type: ABIType ) -> list[ABIValue]: return [self.get_box_value_from_abi_type(app_id, box_name, abi_type) for box_name in box_names] + @staticmethod + def get_box_reference(box_id: BoxIdentifier | BoxReference) -> tuple[int, bytes]: + if isinstance(box_id, (BoxReference | AlgosdkBoxReference)): + return box_id.app_index, box_id.name + + name = b"" + if isinstance(box_id, str): + name = box_id.encode("utf-8") + elif isinstance(box_id, bytes): + name = box_id + elif isinstance(box_id, AccountTransactionSigner): + name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_id.private_key)) + else: + raise ValueError(f"Invalid box identifier type: {type(box_id)}") + + return 0, name + @staticmethod def get_abi_return( confirmation: algosdk.v2client.algod.AlgodResponseType, method: algosdk.abi.Method | None = None diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 29dae7a..f8e8cda 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -1,7 +1,8 @@ import base64 -from typing import TYPE_CHECKING, Literal, Union +from typing import Any, Literal, TypeVar from algosdk.abi import Method as AlgorandABIMethod +from algosdk.abi import TupleType from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -10,7 +11,7 @@ MethodConfigDict, MethodHints, ) -from algokit_utils.models.abi import ABIValue +from algokit_utils.models.abi import ABIStruct, ABIType, ABIValue from algokit_utils.models.application import ( Arc56Contract, Arc56ContractState, @@ -28,16 +29,6 @@ StructName, ) -if TYPE_CHECKING: - import algosdk - -from typing import Any, TypeVar - -import algosdk -from algosdk.abi import ABIType, TupleType - -from algokit_utils.models.abi import ABIStruct - T = TypeVar("T", bound=ABIValue | bytes | ABIStruct | None) @@ -113,7 +104,7 @@ def get_arc56_return_value( if type_str == "AVMString" and raw_value: return raw_value.decode("utf-8") if type_str == "AVMUint64" and raw_value: - return algosdk.abi.ABIType.from_string("uint64").decode(raw_value) + return ABIType.from_string("uint64").decode(raw_value) # Handle structs if struct and struct in structs: @@ -251,7 +242,7 @@ def get_hint(method: AlgorandABIMethod) -> MethodHints | None: return app_spec.hints.get(sig) def get_default_value( - type: Union[str, "algosdk.abi.ABIType"], + type: str | ABIType, default_arg: DefaultArgumentDict, ) -> DefaultValue | None: if not default_arg or default_arg["source"] == "abi-method": diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py index ee642da..8695614 100644 --- a/src/algokit_utils/assets/asset_manager.py +++ b/src/algokit_utils/assets/asset_manager.py @@ -14,7 +14,7 @@ ) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AccountAssetInformation: """Information about an account's holding of a particular asset.""" @@ -28,7 +28,7 @@ class AccountAssetInformation: """The round this information was retrieved at.""" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetInformation: """Information about an asset.""" @@ -66,7 +66,7 @@ class AssetInformation: """32-byte hash of some metadata that is relevant to the asset and/or asset holders.""" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class BulkAssetOptInOutResult: """Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets.""" diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 6eaf850..7feb461 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -53,7 +53,7 @@ class AlgorandClient: """A client that brokers easy access to Algorand functionality.""" def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): - self._client_manager: ClientManager = ClientManager(config) + self._client_manager: ClientManager = ClientManager(clients_or_configs=config, algorand_client=self) self._account_manager: AccountManager = AccountManager(self._client_manager) self._asset_manager: AssetManager = AssetManager(self._client_manager.algod, lambda: self.new_group()) self._app_manager: AppManager = AppManager(self._client_manager.algod) diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index 8a4161b..b55ff01 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -5,6 +5,7 @@ from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient +# from algokit_utils.applications.app_factory import AppFactory, AppFactoryParams from algokit_utils.clients.dispenser_api_client import TestNetDispenserApiClient from algokit_utils.network_clients import ( AlgoClientConfigs, @@ -12,6 +13,7 @@ get_indexer_client, get_kmd_client, ) +from algokit_utils.protocols.application import AlgorandClientProtocol class AlgoSdkClients: @@ -26,7 +28,7 @@ def __init__( self.kmd = kmd -@dataclass(kw_only=True) +@dataclass(kw_only=True, frozen=True) class NetworkDetail: is_test_net: bool is_main_net: bool @@ -40,7 +42,7 @@ def genesis_id_is_localnet(genesis_id: str) -> bool: class ClientManager: - def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): + def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients, algorand_client: AlgorandClientProtocol): if isinstance(clients_or_configs, AlgoSdkClients): _clients = clients_or_configs elif isinstance(clients_or_configs, AlgoClientConfigs): @@ -54,6 +56,7 @@ def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients): self._algod = _clients.algod self._indexer = _clients.indexer self._kmd = _clients.kmd + self._algorand = algorand_client @property def algod(self) -> AlgodClient: @@ -96,6 +99,31 @@ def get_testnet_dispenser( return TestNetDispenserApiClient(auth_token=auth_token) + # def get_app_factory( + # self, + # app_spec: Arc56Contract | ApplicationSpecification | str, + # app_name: str | None = None, + # default_sender: str | None = None, + # default_signer: TransactionSigner | None = None, + # version: str | None = None, + # updatable: bool | None = None, + # deletable: bool | None = None, + # deploy_time_params: TealTemplateParams | None = None, + # ) -> AppFactory: + # return AppFactory( + # AppFactoryParams( + # algorand=self._algorand, + # app_spec=app_spec, + # app_name=app_name, + # default_sender=default_sender, + # default_signer=default_signer, + # version=version, + # updatable=updatable, + # deletable=deletable, + # deploy_time_params=deploy_time_params, + # ) + # ) + @staticmethod def genesis_id_is_local_net(genesis_id: str) -> bool: return genesis_id_is_localnet(genesis_id) diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py index 016d8af..a9c90e3 100644 --- a/src/algokit_utils/models/abi.py +++ b/src/algokit_utils/models/abi.py @@ -1,7 +1,14 @@ +from typing import TypeAlias + +import algosdk + from algokit_utils.models.application import StructField ABIPrimitiveValue = bool | int | str | bytes | bytearray # NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk -ABIValue = ABIPrimitiveValue | list["ABIValue"] | dict[str, "ABIValue"] +ABIValue = ABIPrimitiveValue | list["ABIValue"] | tuple["ABIValue"] | dict[str, "ABIValue"] ABIStruct = dict[str, list[StructField]] + + +ABIType: TypeAlias = algosdk.abi.ABIType diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 79ab1ab..06c229b 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -3,6 +3,7 @@ from typing import Any, Literal import algosdk +from algosdk.abi import ABIType as AlgosdkABIType UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" @@ -70,7 +71,7 @@ class DefaultValue: @dataclass(kw_only=True) class MethodArg: - type: algosdk.abi.ABIType + type: AlgosdkABIType struct: StructName | None = None name: str | None = None desc: str | None = None @@ -79,7 +80,7 @@ class MethodArg: @dataclass class MethodReturns: - type: algosdk.abi.ABIType + type: AlgosdkABIType struct: StructName | None = None desc: str | None = None @@ -107,7 +108,7 @@ class Recommendations: assets: list[int] | None = None -@dataclass(kw_only=True) +@dataclass(kw_only=True, frozen=True) class Method: name: str desc: str | None = None @@ -126,7 +127,7 @@ def serialize(obj: Any) -> Any: return [serialize(item) for item in obj] elif isinstance(obj, dict): return {k: serialize(v) for k, v in obj.items()} - elif isinstance(obj, algosdk.abi.ABIType): + elif isinstance(obj, AlgosdkABIType): return str(obj) else: return obj @@ -208,3 +209,42 @@ class Arc56Contract: events: list[Event] | None = None template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None + + +@dataclass(frozen=True, kw_only=True) +class AppState: + key_raw: bytes + key_base64: str + value_raw: bytes | None + value_base64: str | None + value: str | int + + +@dataclass(frozen=True, kw_only=True) +class AppInformation: + app_id: int + app_address: str + approval_program: bytes + clear_state_program: bytes + creator: str + global_state: dict[str, AppState] + local_ints: int + local_byte_slices: int + global_ints: int + global_byte_slices: int + extra_program_pages: int | None + + +@dataclass(frozen=True, kw_only=True) +class CompiledTeal: + teal: str + compiled: bytes + compiled_hash: str + compiled_base64_to_bytes: bytes + source_map: algosdk.source_map.SourceMap | None + + +@dataclass(frozen=True, kw_only=True) +class AppCompilationResult: + compiled_approval: CompiledTeal + compiled_clear: CompiledTeal diff --git a/src/algokit_utils/models/transaction.py b/src/algokit_utils/models/transaction.py new file mode 100644 index 0000000..ca8c084 --- /dev/null +++ b/src/algokit_utils/models/transaction.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(kw_only=True, frozen=True) +class SendParams: + max_rounds_to_wait: int | None = None + suppress_log: bool | None = None + populate_app_call_resources: bool | None = None diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index cd61f5a..ff9e6bc 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -24,17 +24,6 @@ class JsonFormatArc2Note(BaseArc2Note): data: str | dict[str, Any] | list[Any] | int | None -class SimulateOptions(TypedDict): - allow_more_logs: bool | None - allow_empty_signatures: bool | None - allow_unnamed_resources: bool | None - extra_opcode_budget: int | None - exec_trace_config: SimulateTraceConfig | None - round: int | None - skip_signatures: int | None - fix_signers: bool | None - - # Combined type for all valid ARC-0002 notes # See: https://github.com/algorandfoundation/ARCs/blob/main/ARCs/arc-0002.md Arc2TransactionNote = StringFormatArc2Note | JsonFormatArc2Note diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 218f94b..5ab292e 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +import math from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Union, Unpack +from typing import TYPE_CHECKING, Any, Union import algosdk import algosdk.atomic_transaction_composer @@ -20,15 +21,16 @@ from algokit_utils._debugging import simulate_and_persist_response, simulate_response from algokit_utils.applications.app_manager import AppManager from algokit_utils.config import config -from algokit_utils.transactions.models import SimulateOptions +from algokit_utils.models.transaction import SendParams if TYPE_CHECKING: from collections.abc import Callable from algosdk.abi import Method - from algosdk.box_reference import BoxReference from algosdk.v2client.algod import AlgodClient + from algosdk.v2client.models import SimulateTraceConfig + from algokit_utils.applications.app_manager import BoxReference from algokit_utils.models.abi import ABIValue from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.models import Arc2TransactionNote @@ -36,13 +38,13 @@ logger = logging.getLogger(__name__) -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class SenderParam: sender: str -@dataclass(frozen=True) -class CommonTxnParams: +@dataclass(frozen=True, kw_only=True) +class CommonTxnParams(SendParams): """ Common transaction parameters. @@ -73,14 +75,10 @@ class CommonTxnParams: last_valid_round: int | None = None -@dataclass(frozen=True) -class _RequiredPaymentParams: - receiver: str - amount: AlgoAmount - - -@dataclass(frozen=True) -class PaymentParams(CommonTxnParams, _RequiredPaymentParams): +@dataclass(frozen=True, kw_only=True) +class PaymentParams( + CommonTxnParams, +): """ Payment transaction parameters. @@ -89,21 +87,14 @@ class PaymentParams(CommonTxnParams, _RequiredPaymentParams): :param close_remainder_to: If given, close the sender account and send the remaining balance to this address. """ + receiver: str + amount: AlgoAmount close_remainder_to: str | None = None -@dataclass(frozen=True) -class _RequiredAssetCreateParams: - total: int - asset_name: str - unit_name: str - url: str - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetCreateParams( CommonTxnParams, - _RequiredAssetCreateParams, ): """ Asset creation parameters. @@ -124,6 +115,10 @@ class AssetCreateParams( :param metadata_hash: Hash of the metadata contained in the metadata URL. """ + total: int + asset_name: str + unit_name: str + url: str decimals: int | None = None default_frozen: bool | None = None manager: str | None = None @@ -133,15 +128,9 @@ class AssetCreateParams( metadata_hash: bytes | None = None -@dataclass(frozen=True) -class _RequiredAssetConfigParams: - asset_id: int - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetConfigParams( CommonTxnParams, - _RequiredAssetConfigParams, ): """ Asset configuration parameters. @@ -156,23 +145,16 @@ class AssetConfigParams( Clawback will be permanently disabled if undefined or an empty string. """ + asset_id: int manager: str | None = None reserve: str | None = None freeze: str | None = None clawback: str | None = None -@dataclass(frozen=True) -class _RequiredAssetFreezeParams: - asset_id: int - account: str - frozen: bool - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetFreezeParams( CommonTxnParams, - _RequiredAssetFreezeParams, ): """ Asset freeze parameters. @@ -182,16 +164,14 @@ class AssetFreezeParams( :param frozen: Whether the assets in the account should be frozen. """ - -@dataclass(frozen=True) -class _RequiredAssetDestroyParams: asset_id: int + account: str + frozen: bool -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetDestroyParams( CommonTxnParams, - _RequiredAssetDestroyParams, ): """ Asset destruction parameters. @@ -199,20 +179,12 @@ class AssetDestroyParams( :param asset_id: ID of the asset. """ - -@dataclass(frozen=True) -class _RequiredOnlineKeyRegistrationParams: - vote_key: str - selection_key: str - vote_first: int - vote_last: int - vote_key_dilution: int + asset_id: int -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class OnlineKeyRegistrationParams( CommonTxnParams, - _RequiredOnlineKeyRegistrationParams, ): """ Online key registration parameters. @@ -228,20 +200,17 @@ class OnlineKeyRegistrationParams( :param state_proof_key: The 64 byte state proof public key commitment. """ + vote_key: str + selection_key: str + vote_first: int + vote_last: int + vote_key_dilution: int state_proof_key: bytes | None = None -@dataclass(frozen=True) -class _RequiredAssetTransferParams: - asset_id: int - amount: int - receiver: str - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetTransferParams( CommonTxnParams, - _RequiredAssetTransferParams, ): """ Asset transfer parameters. @@ -253,19 +222,16 @@ class AssetTransferParams( :param close_asset_to: The account to close the asset to. """ + asset_id: int + amount: int + receiver: str clawback_target: str | None = None close_asset_to: str | None = None -@dataclass(frozen=True) -class _RequiredAssetOptInParams: - asset_id: int - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetOptInParams( CommonTxnParams, - _RequiredAssetOptInParams, ): """ Asset opt-in parameters. @@ -273,24 +239,22 @@ class AssetOptInParams( :param asset_id: ID of the asset. """ - -@dataclass(frozen=True) -class _RequiredAssetOptOutParams: asset_id: int - creator: str -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AssetOptOutParams( CommonTxnParams, - _RequiredAssetOptOutParams, ): """ Asset opt-out parameters. """ + asset_id: int + creator: str + -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AppCallParams(CommonTxnParams, SenderParam): """ Application call parameters. @@ -321,14 +285,8 @@ class AppCallParams(CommonTxnParams, SenderParam): box_references: list[BoxReference] | None = None -@dataclass(frozen=True) -class _RequiredAppCreateParams: - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): +@dataclass(frozen=True, kw_only=True) +class AppCreateParams(CommonTxnParams, SenderParam): """ Application create parameters. @@ -346,6 +304,8 @@ class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): :param extra_program_pages: Number of extra pages required for the programs """ + approval_program: str | bytes + clear_state_program: str | bytes schema: dict[str, int] | None = None on_complete: OnComplete | None = None args: list[bytes] | None = None @@ -356,15 +316,8 @@ class AppCreateParams(CommonTxnParams, SenderParam, _RequiredAppCreateParams): extra_program_pages: int | None = None -@dataclass(frozen=True) -class _RequiredAppUpdateParams: - app_id: int - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): +@dataclass(frozen=True, kw_only=True) +class AppUpdateParams(CommonTxnParams, SenderParam): """ Application update parameters. @@ -375,6 +328,9 @@ class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): teal (bytes) """ + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes args: list[bytes] | None = None account_references: list[str] | None = None app_references: list[int] | None = None @@ -383,16 +339,10 @@ class AppUpdateParams(CommonTxnParams, SenderParam, _RequiredAppUpdateParams): on_complete: OnComplete | None = None -@dataclass(frozen=True) -class _RequiredAppDeleteParams: - app_id: int - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AppDeleteParams( CommonTxnParams, SenderParam, - _RequiredAppDeleteParams, ): """ Application delete parameters. @@ -404,16 +354,12 @@ class AppDeleteParams( on_complete: OnComplete = OnComplete.DeleteApplicationOC -@dataclass(frozen=True) -class _RequiredMethodCallParams: - app_id: int - method: Method - - -@dataclass(frozen=True) -class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): +@dataclass(frozen=True, kw_only=True) +class AppMethodCall(CommonTxnParams, SenderParam): """Base class for ABI method calls.""" + app_id: int + method: Method args: list | None = None account_references: list[str] | None = None app_references: list[int] | None = None @@ -421,14 +367,8 @@ class AppMethodCall(CommonTxnParams, SenderParam, _RequiredMethodCallParams): box_references: list[BoxReference] | None = None -@dataclass(frozen=True) -class _RequiredAppMethodCallParams: - app_id: int - method: Method - - -@dataclass(frozen=True) -class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallParams): +@dataclass(frozen=True, kw_only=True) +class AppMethodCallParams(CommonTxnParams, SenderParam): """ Method call parameters. @@ -438,6 +378,8 @@ class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallPa :param on_complete: The OnComplete action (cannot be UpdateApplication or ClearState) """ + app_id: int + method: Method args: list[bytes] | None = None on_complete: OnComplete | None = None account_references: list[str] | None = None @@ -446,7 +388,7 @@ class AppMethodCallParams(CommonTxnParams, SenderParam, _RequiredAppMethodCallPa box_references: list[BoxReference] | None = None -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AppCallMethodCall(AppMethodCall): """Parameters for a regular ABI method call. @@ -465,14 +407,8 @@ class AppCallMethodCall(AppMethodCall): on_complete: OnComplete | None = None -@dataclass(frozen=True) -class _RequiredAppCreateMethodCallParams: - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): +@dataclass(frozen=True, kw_only=True) +class AppCreateMethodCall(AppMethodCall): """Parameters for an ABI method call that creates an application. :param approval_program: The program to execute for all OnCompletes other than ClearState @@ -482,20 +418,15 @@ class AppCreateMethodCall(AppMethodCall, _RequiredAppCreateMethodCallParams): :param extra_program_pages: Number of extra pages required for the programs """ + approval_program: str | bytes + clear_state_program: str | bytes schema: dict[str, int] | None = None on_complete: OnComplete | None = None extra_program_pages: int | None = None -@dataclass(frozen=True) -class _RequiredAppUpdateMethodCallParams: - app_id: int - approval_program: str | bytes - clear_state_program: str | bytes - - -@dataclass(frozen=True) -class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): +@dataclass(frozen=True, kw_only=True) +class AppUpdateMethodCall(AppMethodCall): """Parameters for an ABI method call that updates an application. :param app_id: ID of the application @@ -503,10 +434,13 @@ class AppUpdateMethodCall(AppMethodCall, _RequiredAppUpdateMethodCallParams): :param clear_state_program: The program to execute for ClearState OnComplete """ + app_id: int + approval_program: str | bytes + clear_state_program: str | bytes on_complete: OnComplete = OnComplete.UpdateApplicationOC -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class AppDeleteMethodCall(AppMethodCall): """Parameters for an ABI method call that deletes an application. @@ -588,7 +522,7 @@ class SendAtomicTransactionComposerResults: simulate_response: dict[str, Any] | None = None -def send_atomic_transaction_composer( # noqa: C901, PLR0912, PLR0913 +def send_atomic_transaction_composer( # noqa: C901, PLR0912 atc: AtomicTransactionComposer, algod: AlgodClient, *, @@ -924,13 +858,20 @@ def send( def simulate( self, - **simulate_options: Unpack[SimulateOptions], + allow_more_logs: bool | None = None, + allow_empty_signatures: bool | None = None, + allow_unnamed_resources: bool | None = None, + extra_opcode_budget: int | None = None, + exec_trace_config: SimulateTraceConfig | None = None, + round: int | None = None, + skip_signatures: int | None = None, + fix_signers: bool | None = None, ) -> SendAtomicTransactionComposerResults: - atc = AtomicTransactionComposer() if simulate_options["skip_signatures"] else self.atc + atc = AtomicTransactionComposer() if skip_signatures else self.atc - if simulate_options["skip_signatures"]: - simulate_options["allow_empty_signatures"] = True - simulate_options["fix_signers"] = True + if skip_signatures: + allow_empty_signatures = True + fix_signers = True transactions = self.build_transactions() for txn in transactions.transactions: atc.add_transaction(TransactionWithSigner(txn=txn, signer=TransactionComposer.NULL_SIGNER)) @@ -944,7 +885,14 @@ def simulate( config.project_root, self.algod, config.trace_buffer_size_mb, - simulate_options, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, ) return SendAtomicTransactionComposerResults( @@ -956,7 +904,18 @@ def simulate( returns=response.abi_results, ) - response = simulate_response(atc, self.algod, simulate_options) + response = simulate_response( + atc, + self.algod, + allow_more_logs, + allow_empty_signatures, + allow_unnamed_resources, + extra_opcode_budget, + exec_trace_config, + round, + skip_signatures, + fix_signers, + ) return SendAtomicTransactionComposerResults( confirmations=[], # TODO: extract confirmations, @@ -1095,11 +1054,141 @@ def _build_method_call( # noqa: C901, PLR0912 on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, note=params.note, lease=params.lease, - boxes=[(ref.app_index, ref.name) for ref in params.box_references] if params.box_references else None, + boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] + if params.box_references + else None, ) return self._build_atc(method_atc) + # TODO: reconsider whether atc's add_method_call is the best way to handle passing manually encoded abi args + # def _build_method_call( + # self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams + # ) -> list[TransactionWithSigner]: + # # Initialize lists to store transactions and encoded arguments + # method_args: list[ABIValue | TransactionWithSigner] = [] + # arg_offset = 0 + + # # Initialize foreign arrays + # accounts = params.account_references[:] if params.account_references else [] + # foreign_apps = params.app_references[:] if params.app_references else [] + # foreign_assets = params.asset_references[:] if params.asset_references else [] + # boxes = params.box_references[:] if params.box_references else [] + + # # Prepare app args starting with method selector + # encoded_args = [] + # encoded_args.append(params.method.get_selector()) + + # # Process method arguments + # if params.args: + # for i, arg in enumerate(params.args): + # if self._is_abi_value(arg): + # method_args.append(arg) + # continue + + # if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): + # match arg: + # case ( + # AppCreateMethodCall() + # | AppCallMethodCall() + # | AppUpdateMethodCall() + # | AppDeleteMethodCall() + # ): + # temp_txn_with_signers = self._build_method_call(arg, suggested_params) + # method_args.extend(temp_txn_with_signers) + # arg_offset += len(temp_txn_with_signers) - 1 + # continue + # case AppCallParams(): + # txn = self._build_app_call(arg, suggested_params) + # case PaymentParams(): + # txn = self._build_payment(arg, suggested_params) + # case AssetOptInParams(): + # txn = self._build_asset_transfer( + # AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params + # ) + # case _: + # raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + + # method_args.append( + # TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + # ) + # continue + + # # Handle ABI reference types + # if algosdk.abi.is_abi_reference_type(params.method.args[i + arg_offset].type): + # arg_type = params.method.args[i + arg_offset].type + # if arg_type == algosdk.abi.ABIReferenceType.ACCOUNT: + # address_type = algosdk.abi.AddressType() + # account_arg = address_type.decode(address_type.encode(cast(str | bytes, arg))) + # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( + # account_arg, accounts, params.sender + # ) + # method_args.append(current_arg) + # elif arg_type == algosdk.abi.ABIReferenceType.ASSET: + # asset_arg = int(cast(int, arg)) + # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( + # asset_arg, foreign_assets + # ) + # method_args.append(current_arg) + # elif arg_type == algosdk.abi.ABIReferenceType.APPLICATION: + # app_arg = int(cast(int, arg)) + # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( + # app_arg, foreign_apps, params.app_id + # ) + # method_args.append(current_arg) + # else: + # raise ValueError(f"Unsupported ABI reference type: {arg_type}") + # continue + + # # Regular ABI value + # method_args.append(arg) + + # # Encode regular ABI arguments + # for i, arg in enumerate(method_args): + # if isinstance(arg, TransactionWithSigner): + # continue + # arg_type = params.method.args[i].type + # if isinstance(arg_type, algosdk.abi.ABIType): + # try: + # encoded_args.append(arg_type.encode(arg)) + # except Exception as e: + # if ( + # isinstance(e, AttributeError) + # and isinstance(arg_type, algosdk.abi.StringType) + # and isinstance(arg, bytes) + # ): + # # Assume user passed a manually abi encoded string, ignore re-encoding and append as raw bytes + # encoded_args.append(arg) + # else: + # raise ValueError(f"Error encoding argument {arg} of type {arg_type}") from e + + # # Create the app call transaction + # txn = algosdk.transaction.ApplicationCallTxn( + # sender=params.sender, + # sp=suggested_params, + # index=params.app_id or 0, + # on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, + # app_args=encoded_args, + # accounts=accounts, + # foreign_apps=foreign_apps, + # foreign_assets=foreign_assets, + # boxes=[AppManager.get_box_reference(ref) for ref in boxes] if boxes else None, + # note=params.note, + # lease=params.lease, + # ) + + # result = [TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender))] + + # # Add any transaction arguments + # for arg in method_args: + # if isinstance(arg, TransactionWithSigner): + # result.append(arg) + + # # Store the method for this transaction + # self.txn_method_map[txn.get_txid()] = params.method + + # return result + def _build_payment( self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: @@ -1272,7 +1361,7 @@ def _build_key_reg( return self._common_txn_build_step(params, txn, suggested_params) def _is_abi_value(self, x: bool | float | str | bytes | list | TxnParams) -> bool: - if isinstance(x, list): + if isinstance(x, list | tuple): return len(x) == 0 or all(self._is_abi_value(item) for item in x) return isinstance(x, bool | int | float | str | bytes) diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index fae7b75..eaeb46b 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -1,7 +1,6 @@ from collections.abc import Callable from dataclasses import dataclass from logging import getLogger -from pathlib import Path from typing import Any, TypedDict, TypeVar import algosdk @@ -9,7 +8,6 @@ from algosdk.atomic_transaction_composer import AtomicTransactionResponse from algosdk.transaction import Transaction -from algokit_utils._debugging import simulate_and_persist_response from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.models.abi import ABIValue @@ -117,26 +115,19 @@ def send_transaction(params: T) -> SendSingleTransactionResult: transaction = composer.build().transactions[-1].txn logger.debug(pre_log(params, transaction)) - try: - raw_result = composer.send() - raw_result_dict = raw_result.__dict__.copy() - del raw_result_dict["simulate_response"] - - result = SendSingleTransactionResult( - **raw_result_dict, - confirmation=raw_result.confirmations[-1], - transaction=raw_result.transactions[-1], - tx_id=raw_result.tx_ids[-1], - ) - - if post_log: - logger.debug(post_log(params, result)) - except Exception: - simulate_and_persist_response( - composer.atc, - Path("/Users/aorumbayev/MakerX/projects/algokit/algokit-utils/utils/algokit-utils-py"), - self._algod, - ) + raw_result = composer.send() + raw_result_dict = raw_result.__dict__.copy() + del raw_result_dict["simulate_response"] + + result = SendSingleTransactionResult( + **raw_result_dict, + confirmation=raw_result.confirmations[-1], + transaction=raw_result.transactions[-1], + tx_id=raw_result.tx_ids[-1], + ) + + if post_log: + logger.debug(post_log(params, result)) return result diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 7df50c7..b485d73 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -1,19 +1,23 @@ +import base64 from pathlib import Path +from typing import Any +import algosdk import pytest from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_client import ( AppClient, - AppClientMethodCallParams, + AppClientMethodCallWithSendParams, AppClientParams, - CloneAppClientParams, - ResolveAppClientByNetwork, + FundAppAccountParams, ) -from algokit_utils.applications.app_manager import AppManager +from algokit_utils.applications.app_manager import AppManager, BoxReference from algokit_utils.applications.utils import arc32_to_arc56 from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.abi import ABIType from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount from algokit_utils.models.application import Arc56Contract from algokit_utils.transactions.transaction_composer import AppCreateParams @@ -101,6 +105,71 @@ def testing_app_arc32_app_id( return response.app_id +@pytest.fixture +def test_app_client( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_spec: ApplicationSpecification, + testing_app_arc32_app_id: int, +) -> AppClient: + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_arc32_app_id, + algorand=algorand, + app_spec=testing_app_arc32_app_spec, + ) + ) + + +@pytest.fixture +def testing_app_puya_arc32_app_spec() -> ApplicationSpecification: + raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app_puya" / "arc32_app_spec.json" + return ApplicationSpecification.from_json(raw_json_spec.read_text()) + + +@pytest.fixture +def testing_app_puya_arc32_app_id( + algorand: AlgorandClient, funded_account: Account, testing_app_puya_arc32_app_spec: ApplicationSpecification +) -> int: + global_schema = testing_app_puya_arc32_app_spec.global_state_schema + local_schema = testing_app_puya_arc32_app_spec.local_state_schema + + response = algorand.send.app_create( + AppCreateParams( + sender=funded_account.address, + approval_program=testing_app_puya_arc32_app_spec.approval_program, + clear_state_program=testing_app_puya_arc32_app_spec.clear_program, + schema={ + "global_bytes": global_schema.num_byte_slices, + "global_ints": global_schema.num_uints, + "local_bytes": local_schema.num_byte_slices, + "local_ints": local_schema.num_uints, + }, # type: ignore[arg-type] + ) + ) + return response.app_id + + +@pytest.fixture +def test_app_client_puya( + algorand: AlgorandClient, + funded_account: Account, + testing_app_puya_arc32_app_spec: ApplicationSpecification, + testing_app_puya_arc32_app_id: int, +) -> AppClient: + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_puya_arc32_app_id, + algorand=algorand, + app_spec=testing_app_puya_arc32_app_spec, + ) + ) + + # TODO: add variations around arc 56 contracts too @@ -121,7 +190,7 @@ def test_clone_overriding_default_sender_and_inheriting_app_name( ) cloned_default_sender = "ABC" * 55 - cloned_app_client = app_client.clone(CloneAppClientParams(default_sender=cloned_default_sender)) + cloned_app_client = app_client.clone(default_sender=cloned_default_sender) assert app_client.app_name == "HelloWorld" assert cloned_app_client.app_id == app_client.app_id @@ -147,7 +216,7 @@ def test_clone_overriding_app_name( ) cloned_app_name = "George CLONEy" - cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) + cloned_app_client = app_client.clone(app_name=cloned_app_name) assert app_client.app_name == hello_world_arc32_app_spec.contract.name == "HelloWorld" assert cloned_app_client.app_name == cloned_app_name @@ -169,7 +238,7 @@ def test_clone_inheriting_app_name_based_on_default_handling( ) cloned_app_name = None - cloned_app_client = app_client.clone(CloneAppClientParams(app_name=cloned_app_name)) + cloned_app_client = app_client.clone(app_name=cloned_app_name) assert cloned_app_client.app_name == hello_world_arc32_app_spec.contract.name == app_client.app_name @@ -192,15 +261,261 @@ def test_resolve_from_network( arc56_app_spec = arc32_to_arc56(hello_world_arc32_app_spec) arc56_app_spec.networks = {"localnet": {"app_id": hello_world_arc32_app_id}} app_client = AppClient.from_network( - ResolveAppClientByNetwork( - algorand=algorand, - app_spec=arc56_app_spec, - ) + algorand=algorand, + app_spec=arc56_app_spec, ) assert app_client +def test_construct_transaction_with_boxes(test_app_client: AppClient) -> None: + call = test_app_client.create_transaction.call( + AppClientMethodCallWithSendParams( + method="call_abi", + args=["test"], + box_references=[BoxReference(app_id=0, name=b"1")], + ) + ) + + assert isinstance(call.transactions[0], algosdk.transaction.ApplicationCallTxn) + assert call.transactions[0].boxes == [BoxReference(app_id=0, name=b"1")] + + # Test with string box reference + call2 = test_app_client.create_transaction.call( + AppClientMethodCallWithSendParams( + method="call_abi", + args=["test"], + box_references=["1"], + ) + ) + + assert isinstance(call2.transactions[0], algosdk.transaction.ApplicationCallTxn) + assert call2.transactions[0].boxes == [BoxReference(app_id=0, name=b"1")] + + +def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> None: + # Test global state + test_app_client.send.call( + AppClientMethodCallWithSendParams(method="set_global", args=[1, 2, "asdf", bytes([1, 2, 3, 4])]) + ) + global_state = test_app_client.get_global_state() + + assert "int1" in global_state + assert "int2" in global_state + assert "bytes1" in global_state + assert "bytes2" in global_state + assert hasattr(global_state["bytes2"], "value_raw") + assert sorted(global_state.keys()) == ["bytes1", "bytes2", "int1", "int2", "value"] + assert global_state["int1"].value == 1 + assert global_state["int2"].value == 2 + assert global_state["bytes1"].value == "asdf" + assert global_state["bytes2"].value_raw == bytes([1, 2, 3, 4]) + + # Test local state + test_app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in")) + test_app_client.send.call( + AppClientMethodCallWithSendParams(method="set_local", args=[1, 2, "asdf", bytes([1, 2, 3, 4])]) + ) + local_state = test_app_client.get_local_state(funded_account.address) + + assert "local_int1" in local_state + assert "local_int2" in local_state + assert "local_bytes1" in local_state + assert "local_bytes2" in local_state + assert sorted(local_state.keys()) == ["local_bytes1", "local_bytes2", "local_int1", "local_int2"] + assert local_state["local_int1"].value == 1 + assert local_state["local_int2"].value == 2 + assert local_state["local_bytes1"].value == "asdf" + assert local_state["local_bytes2"].value_raw == bytes([1, 2, 3, 4]) + + # Test box storage + box_name1 = bytes([0, 0, 0, 1]) + box_name1_base64 = base64.b64encode(box_name1).decode() + box_name2 = bytes([0, 0, 0, 2]) + box_name2_base64 = base64.b64encode(box_name2).decode() + + test_app_client.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name1, "value1"], + box_references=[box_name1], + ) + ) + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name2, "value2"], + box_references=[box_name2], + ) + ) + + box_values = test_app_client.get_box_values() + box1_value = test_app_client.get_box_value(box_name1) + + assert sorted(b.name.name_base64 for b in box_values) == sorted([box_name1_base64, box_name2_base64]) + box1 = next(b for b in box_values if b.name.name_base64 == box_name1_base64) + assert box1.value == base64.b64encode(bytes("value1", "utf-8")) + assert box1_value == box1.value + + box2 = next(b for b in box_values if b.name.name_base64 == box_name2_base64) + assert box2.value == base64.b64encode(bytes("value2", "utf-8")) + + # Legacy contract strips ABI prefix; manually encoded ABI string after + # passing algosdk's atc results in \x00\n\x00\n1234524352. + expected_value_decoded = "1234524352" + expected_value = "\x00\n" + expected_value_decoded + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="set_box", + args=[box_name1, expected_value], + box_references=[box_name1], + ) + ) + + boxes = test_app_client.get_box_values_from_abi_type( + ABIType.from_string("string"), + lambda n: n.name_base64 == box_name1_base64, + ) + box1_abi_value = test_app_client.get_box_value_from_abi_type(box_name1, ABIType.from_string("string")) + + assert len(boxes) == 1 + assert boxes[0].value == expected_value_decoded + assert box1_abi_value == expected_value_decoded + + +@pytest.mark.parametrize( + ("box_name", "box_value", "value_type", "expected_value"), + [ + ( + "name1", + b"test_bytes", # Updated to match Bytes type + "byte[]", + [116, 101, 115, 116, 95, 98, 121, 116, 101, 115], + ), + ( + "name2", + "test_string", + "string", + "test_string", + ), + ( + "name3", # Updated to use string key + 123, + "uint32", + 123, + ), + ( + "name4", # Updated to use string key + 2**256, # Large number within uint512 range + "uint512", + 2**256, + ), + ( + "name5", # Updated to use string key + [1, 2, 3, 4], + "byte[4]", + [1, 2, 3, 4], + ), + ], +) +def test_box_methods_with_manually_encoded_abi_args( + test_app_client_puya: AppClient, + box_name: Any, # noqa: ANN401 + box_value: Any, # noqa: ANN401 + value_type: str, + expected_value: Any, # noqa: ANN401 +) -> None: + # Fund the app account + box_prefix = b"box_bytes" + + test_app_client_puya.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + # Encode the box reference + box_identifier = box_prefix + ABIType.from_string("string").encode(box_name) + + # Call the method to set the box value + test_app_client_puya.send.call( + AppClientMethodCallWithSendParams( + method="set_box_bytes", + args=[box_name, ABIType.from_string(value_type).encode(box_value)], + box_references=[box_identifier], + ) + ) + + # Get and verify the box value + box_abi_value = test_app_client_puya.get_box_value_from_abi_type(box_identifier, ABIType.from_string(value_type)) + + # Convert the retrieved value to match expected type if needed + assert box_abi_value == expected_value + + +@pytest.mark.parametrize( + ("box_prefix_str", "method", "arg_value", "value_type"), + [ + ("box_str", "set_box_str", "string", "string"), + ("box_int", "set_box_int", 123, "uint32"), + ("box_int512", "set_box_int512", 2**256, "uint512"), + ("box_static", "set_box_static", [1, 2, 3, 4], "byte[4]"), + ("", "set_struct", ("box1", 123), "(string,uint64)"), + ], +) +def test_box_methods_with_arc4_returns_parametrized( + test_app_client_puya: AppClient, + box_prefix_str: str, + method: str, + arg_value: Any, # noqa: ANN401 + value_type: str, +) -> None: + """ + Test setting and retrieving box values with different data types and box prefixes. + + Args: + test_app_client_puya (AppClient): The AppClient instance for testing. + box_prefix_str (str): The string prefix for the box. + method (str): The method name to call for setting the box. + arg_value (Any): The value to set in the box. + value_type (str): The ABI type of the value. + """ + # Encode the box prefix + box_prefix = box_prefix_str.encode() + + # Fund the app account with 1 Algo + test_app_client_puya.fund_app_account(params=FundAppAccountParams(amount=AlgoAmount.from_algos(1))) + + # Encode the box name "box1" using ABIType "string" + box_name_encoded = ABIType.from_string("string").encode("box1") + box_reference = box_prefix + box_name_encoded + + # Send the transaction to set the box value + test_app_client_puya.send.call( + AppClientMethodCallWithSendParams( + method=method, + args=["box1", arg_value], + box_references=[box_reference], + ) + ) + + # Encode the expected value using the specified ABI type + value_encoded = ABIType.from_string(value_type).encode(arg_value) + expected_value = base64.b64encode(value_encoded) + + # Retrieve the actual box value + actual_box_value = test_app_client_puya.get_box_value(box_reference) + + # Assert that the actual box value matches the expected value + assert actual_box_value == expected_value + + if method == "set_struct": + abi_decoded_boxes = test_app_client_puya.get_box_values_from_abi_type( + ABIType.from_string("(string,uint64)"), + lambda n: n.name_base64 == base64.b64encode(box_prefix + box_name_encoded).decode(), + ) + assert len(abi_decoded_boxes) == 1 + assert abi_decoded_boxes[0].value == arg_value + + # TODO: see if needs moving into app factory tests file def test_abi_with_default_arg_method( algorand: AlgorandClient, @@ -211,17 +526,15 @@ def test_abi_with_default_arg_method( arc56_app_spec = arc32_to_arc56(testing_app_arc32_app_spec) arc56_app_spec.networks = {"localnet": {"app_id": testing_app_arc32_app_id}} app_client = AppClient.from_network( - ResolveAppClientByNetwork( - algorand=algorand, - app_spec=arc56_app_spec, - default_sender=funded_account.address, - default_signer=funded_account.signer, - ) + algorand=algorand, + app_spec=arc56_app_spec, + default_sender=funded_account.address, + default_signer=funded_account.signer, ) # app_client.send. - app_client.send.opt_in(AppClientMethodCallParams(method="opt_in")) + app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in")) app_client.send.call( - AppClientMethodCallParams( + AppClientMethodCallWithSendParams( method="set_local", args=[1, 2, "banana", [1, 2, 3, 4]], ) @@ -232,10 +545,11 @@ def test_abi_with_default_arg_method( # Test with defined value defined_value_result = app_client.send.call( - AppClientMethodCallParams(method=method_signature, args=[defined_value]) + AppClientMethodCallWithSendParams(method=method_signature, args=[defined_value]) ) + assert defined_value_result.return_value == "Local state, defined value" # Test with default value - default_value_result = app_client.send.call(AppClientMethodCallParams(method=method_signature, args=[None])) + default_value_result = app_client.send.call(AppClientMethodCallWithSendParams(method=method_signature, args=[None])) assert default_value_result.return_value == "Local state, banana" diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py new file mode 100644 index 0000000..35c0c7e --- /dev/null +++ b/tests/applications/test_app_factory.py @@ -0,0 +1,72 @@ +# from pathlib import Path + +# import pytest + +# from algokit_utils.applications.app_factory import AppFactory, AppFactoryDeployParams +# from algokit_utils.clients.algorand_client import AlgorandClient +# from algokit_utils.models.account import Account + + +# @pytest.fixture +# def algorand(funded_account: Account) -> AlgorandClient: +# client = AlgorandClient.default_local_net() +# client.set_signer(sender=funded_account.address, signer=funded_account.signer) +# return client + + +# @pytest.fixture +# def factory(algorand: AlgorandClient, funded_account: Account) -> AppFactory: +# """Create AppFactory fixture""" +# raw_arc56_spec = (Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json").read_text() +# return algorand.client.get_app_factory(app_spec=raw_arc56_spec, default_sender=funded_account.address) + + +# class TestARC56: +# def test_error_messages_with_template_vars(self, factory: AppFactory) -> None: +# """Test ARC56 error messages with dynamic template variables""" +# # Deploy app +# result = factory.deploy( +# AppFactoryDeployParams( +# create_params={"method": "createApplication"}, +# deploy_time_params={ +# "bytes64TmplVar": "0" * 64, +# "uint64TmplVar": 123, +# "bytes32TmplVar": "0" * 32, +# "bytesTmplVar": "foo", +# }, +# ) +# ) +# app_client = result.app_client + +# # Test error handling +# with pytest.raises(Exception) as exc: +# app_client.call(method="throwError") + +# assert "this is an error" in str(exc.value) + +# def test_undefined_error_message(self, factory: AppFactory) -> None: +# """Test ARC56 undefined error message with template variables""" +# # Deploy app +# result = factory.deploy( +# create_params={"method": "createApplication"}, +# deploy_time_params={ +# "bytes64TmplVar": "0" * 64, +# "uint64TmplVar": 0, +# "bytes32TmplVar": "0" * 32, +# "bytesTmplVar": "foo", +# }, +# ) +# app_id = result.app_id + +# # Create new client without source maps +# app_client = AppClient( +# app_id=app_id, algod=algod, app_spec=arc56_json, default_sender=get_localnet_default_account() +# ) + +# # Test error handling +# with pytest.raises(Exception) as exc: +# app_client.call(method="tmpl") + +# error_stack = "\n".join(line.strip() for line in str(exc.value).split("\n")) +# assert "assert <--- Error" in error_stack +# assert "intc 1 // TMPL_uint64TmplVar" in error_stack diff --git a/tests/artifacts/arc_56_templates/templates_arc56_draft.json b/tests/artifacts/arc_56_templates/templates_arc56_draft.json new file mode 100644 index 0000000..da275d1 --- /dev/null +++ b/tests/artifacts/arc_56_templates/templates_arc56_draft.json @@ -0,0 +1,681 @@ +{ + "name": "Templates", + "desc": "", + "methods": [ + { + "name": "tmpl", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "specificLengthTemplateVar", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "throwError", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "itobTemplateVar", + "args": [], + "returns": { + "type": "byte[]" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + } + }, + { + "name": "createApplication", + "args": [], + "returns": { + "type": "void" + }, + "actions": { + "create": [ + "NoOp" + ], + "call": [] + } + } + ], + "arcs": [ + 4, + 56 + ], + "structs": {}, + "state": { + "schema": { + "global": { + "bytes": 0, + "ints": 0 + }, + "local": { + "bytes": 0, + "ints": 0 + } + }, + "keys": { + "global": {}, + "local": {}, + "box": {} + }, + "maps": { + "global": {}, + "local": {}, + "box": {} + } + }, + "bareActions": { + "create": [], + "call": [] + }, + "sourceInfo": { + "approval": { + "sourceInfo": [ + { + "teal": 15, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 1, + 2 + ] + }, + { + "teal": 16, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 3 + ] + }, + { + "teal": 17, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 4, + 5 + ] + }, + { + "teal": 18, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 6 + ] + }, + { + "teal": 19, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 7, + 8 + ] + }, + { + "teal": 20, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 9 + ] + }, + { + "teal": 21, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35 + ] + }, + { + "teal": 25, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "The requested action is not implemented in this contract. Are you using the correct OnComplete? Did you set your app ID?", + "pc": [ + 36 + ] + }, + { + "teal": 30, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 37, + 38, + 39 + ] + }, + { + "teal": 31, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 40 + ] + }, + { + "teal": 32, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 41 + ] + }, + { + "teal": 36, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 42, + 43, + 44 + ] + }, + { + "teal": 40, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:13", + "pc": [ + 45 + ] + }, + { + "teal": 41, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:13", + "pc": [ + 46 + ] + }, + { + "teal": 45, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:14", + "pc": [ + 47 + ] + }, + { + "teal": 46, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:14", + "pc": [ + 48 + ] + }, + { + "teal": 47, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:12", + "pc": [ + 49 + ] + }, + { + "teal": 52, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 50, + 51, + 52 + ] + }, + { + "teal": 53, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 53 + ] + }, + { + "teal": 54, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 54 + ] + }, + { + "teal": 58, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 55, + 56, + 57 + ] + }, + { + "teal": 62, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 58 + ] + }, + { + "teal": 63, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 59 + ] + }, + { + "teal": 64, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 60 + ] + }, + { + "teal": 65, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:18", + "pc": [ + 61 + ] + }, + { + "teal": 66, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:17", + "pc": [ + 62 + ] + }, + { + "teal": 71, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 63, + 64, + 65 + ] + }, + { + "teal": 72, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 66 + ] + }, + { + "teal": 73, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 67 + ] + }, + { + "teal": 77, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 68, + 69, + 70 + ] + }, + { + "teal": 80, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:22", + "errorMessage": "this is an error", + "pc": [ + 71 + ] + }, + { + "teal": 81, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:21", + "pc": [ + 72 + ] + }, + { + "teal": 86, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 73, + 74, + 75, + 76, + 77, + 78 + ] + }, + { + "teal": 89, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 79, + 80, + 81 + ] + }, + { + "teal": 90, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 82 + ] + }, + { + "teal": 91, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 83 + ] + }, + { + "teal": 92, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 84 + ] + }, + { + "teal": 93, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 85, + 86, + 87 + ] + }, + { + "teal": 94, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 88 + ] + }, + { + "teal": 95, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 89 + ] + }, + { + "teal": 96, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 90 + ] + }, + { + "teal": 97, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 91 + ] + }, + { + "teal": 98, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 92 + ] + }, + { + "teal": 99, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 93 + ] + }, + { + "teal": 103, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 94, + 95, + 96 + ] + }, + { + "teal": 107, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:26", + "pc": [ + 97 + ] + }, + { + "teal": 108, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:26", + "pc": [ + 98 + ] + }, + { + "teal": 109, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:25", + "pc": [ + 99 + ] + }, + { + "teal": 112, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 100 + ] + }, + { + "teal": 113, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 101 + ] + }, + { + "teal": 116, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 102, + 103, + 104, + 105, + 106, + 107 + ] + }, + { + "teal": 117, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 108, + 109, + 110 + ] + }, + { + "teal": 118, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 111, + 112, + 113, + 114 + ] + }, + { + "teal": 121, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "this contract does not implement the given ABI method for create NoOp", + "pc": [ + 115 + ] + }, + { + "teal": 124, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 116, + 117, + 118, + 119, + 120, + 121 + ] + }, + { + "teal": 125, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 122, + 123, + 124, + 125, + 126, + 127 + ] + }, + { + "teal": 126, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 128, + 129, + 130, + 131, + 132, + 133 + ] + }, + { + "teal": 127, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 134, + 135, + 136, + 137, + 138, + 139 + ] + }, + { + "teal": 128, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 140, + 141, + 142 + ] + }, + { + "teal": 129, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "pc": [ + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152 + ] + }, + { + "teal": 132, + "source": "tests/example-contracts/arc56_templates/templates.algo.ts:3", + "errorMessage": "this contract does not implement the given ABI method for call NoOp", + "pc": [ + 153 + ] + } + ], + "pcOffsetMethod": "cblocks" + }, + "clear": { + "sourceInfo": [], + "pcOffsetMethod": "none" + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCmludGNibG9jayAxIFRNUExfdWludDY0VG1wbFZhcgpieXRlY2Jsb2NrIFRNUExfYnl0ZXNUbXBsVmFyIFRNUExfYnl0ZXM2NFRtcGxWYXIgVE1QTF9ieXRlczMyVG1wbFZhcgoKLy8gVGhpcyBURUFMIHdhcyBnZW5lcmF0ZWQgYnkgVEVBTFNjcmlwdCB2MC4xMDUuMwovLyBodHRwczovL2dpdGh1Yi5jb20vYWxnb3JhbmRmb3VuZGF0aW9uL1RFQUxTY3JpcHQKCi8vIFRoaXMgY29udHJhY3QgaXMgY29tcGxpYW50IHdpdGggYW5kL29yIGltcGxlbWVudHMgdGhlIGZvbGxvd2luZyBBUkNzOiBbIEFSQzQgXQoKLy8gVGhlIGZvbGxvd2luZyB0ZW4gbGluZXMgb2YgVEVBTCBoYW5kbGUgaW5pdGlhbCBwcm9ncmFtIGZsb3cKLy8gVGhpcyBwYXR0ZXJuIGlzIHVzZWQgdG8gbWFrZSBpdCBlYXN5IGZvciBhbnlvbmUgdG8gcGFyc2UgdGhlIHN0YXJ0IG9mIHRoZSBwcm9ncmFtIGFuZCBkZXRlcm1pbmUgaWYgYSBzcGVjaWZpYyBhY3Rpb24gaXMgYWxsb3dlZAovLyBIZXJlLCBhY3Rpb24gcmVmZXJzIHRvIHRoZSBPbkNvbXBsZXRlIGluIGNvbWJpbmF0aW9uIHdpdGggd2hldGhlciB0aGUgYXBwIGlzIGJlaW5nIGNyZWF0ZWQgb3IgY2FsbGVkCi8vIEV2ZXJ5IHBvc3NpYmxlIGFjdGlvbiBmb3IgdGhpcyBjb250cmFjdCBpcyByZXByZXNlbnRlZCBpbiB0aGUgc3dpdGNoIHN0YXRlbWVudAovLyBJZiB0aGUgYWN0aW9uIGlzIG5vdCBpbXBsZW1lbnRlZCBpbiB0aGUgY29udHJhY3QsIGl0cyByZXNwZWN0aXZlIGJyYW5jaCB3aWxsIGJlICIqTk9UX0lNUExFTUVOVEVEIiB3aGljaCBqdXN0IGNvbnRhaW5zICJlcnIiCnR4biBBcHBsaWNhdGlvbklECiEKcHVzaGludCA2CioKdHhuIE9uQ29tcGxldGlvbgorCnN3aXRjaCAqY2FsbF9Ob09wICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqY3JlYXRlX05vT3AgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVEICpOT1RfSU1QTEVNRU5URUQgKk5PVF9JTVBMRU1FTlRFRCAqTk9UX0lNUExFTUVOVEVECgoqTk9UX0lNUExFTUVOVEVEOgoJLy8gVGhlIHJlcXVlc3RlZCBhY3Rpb24gaXMgbm90IGltcGxlbWVudGVkIGluIHRoaXMgY29udHJhY3QuIEFyZSB5b3UgdXNpbmcgdGhlIGNvcnJlY3QgT25Db21wbGV0ZT8gRGlkIHlvdSBzZXQgeW91ciBhcHAgSUQ/CgllcnIKCi8vIHRtcGwoKXZvaWQKKmFiaV9yb3V0ZV90bXBsOgoJLy8gZXhlY3V0ZSB0bXBsKCl2b2lkCgljYWxsc3ViIHRtcGwKCWludGMgMCAvLyAxCglyZXR1cm4KCi8vIHRtcGwoKTogdm9pZAp0bXBsOgoJcHJvdG8gMCAwCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvYXJjNTZfdGVtcGxhdGVzL3RlbXBsYXRlcy5hbGdvLnRzOjEzCgkvLyBsb2codGhpcy5ieXRlc1RtcGxWYXIpCglieXRlYyAwIC8vIFRNUExfYnl0ZXNUbXBsVmFyCglsb2cKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9hcmM1Nl90ZW1wbGF0ZXMvdGVtcGxhdGVzLmFsZ28udHM6MTQKCS8vIGFzc2VydCh0aGlzLnVpbnQ2NFRtcGxWYXIpCglpbnRjIDEgLy8gVE1QTF91aW50NjRUbXBsVmFyCglhc3NlcnQKCXJldHN1YgoKLy8gc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcigpdm9pZAoqYWJpX3JvdXRlX3NwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXI6CgkvLyBleGVjdXRlIHNwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXIoKXZvaWQKCWNhbGxzdWIgc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcgoJaW50YyAwIC8vIDEKCXJldHVybgoKLy8gc3BlY2lmaWNMZW5ndGhUZW1wbGF0ZVZhcigpOiB2b2lkCnNwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXI6Cglwcm90byAwIDAKCgkvLyB0ZXN0cy9leGFtcGxlLWNvbnRyYWN0cy9hcmM1Nl90ZW1wbGF0ZXMvdGVtcGxhdGVzLmFsZ28udHM6MTgKCS8vIGVkMjU1MTlWZXJpZnlCYXJlKHRoaXMuYnl0ZXNUbXBsVmFyLCB0aGlzLmJ5dGVzNjRUbXBsVmFyLCB0aGlzLmJ5dGVzMzJUbXBsVmFyKQoJYnl0ZWMgMCAvLyBUTVBMX2J5dGVzVG1wbFZhcgoJYnl0ZWMgMSAvLyBUTVBMX2J5dGVzNjRUbXBsVmFyCglieXRlYyAyIC8vIFRNUExfYnl0ZXMzMlRtcGxWYXIKCWVkMjU1MTl2ZXJpZnlfYmFyZQoJcmV0c3ViCgovLyB0aHJvd0Vycm9yKCl2b2lkCiphYmlfcm91dGVfdGhyb3dFcnJvcjoKCS8vIGV4ZWN1dGUgdGhyb3dFcnJvcigpdm9pZAoJY2FsbHN1YiB0aHJvd0Vycm9yCglpbnRjIDAgLy8gMQoJcmV0dXJuCgovLyB0aHJvd0Vycm9yKCk6IHZvaWQKdGhyb3dFcnJvcjoKCXByb3RvIDAgMAoKCS8vIHRoaXMgaXMgYW4gZXJyb3IKCWVycgoJcmV0c3ViCgovLyBpdG9iVGVtcGxhdGVWYXIoKWJ5dGVbXQoqYWJpX3JvdXRlX2l0b2JUZW1wbGF0ZVZhcjoKCS8vIFRoZSBBQkkgcmV0dXJuIHByZWZpeAoJcHVzaGJ5dGVzIDB4MTUxZjdjNzUKCgkvLyBleGVjdXRlIGl0b2JUZW1wbGF0ZVZhcigpYnl0ZVtdCgljYWxsc3ViIGl0b2JUZW1wbGF0ZVZhcgoJZHVwCglsZW4KCWl0b2IKCWV4dHJhY3QgNiAyCglzd2FwCgljb25jYXQKCWNvbmNhdAoJbG9nCglpbnRjIDAgLy8gMQoJcmV0dXJuCgovLyBpdG9iVGVtcGxhdGVWYXIoKTogYnl0ZXMKaXRvYlRlbXBsYXRlVmFyOgoJcHJvdG8gMCAxCgoJLy8gdGVzdHMvZXhhbXBsZS1jb250cmFjdHMvYXJjNTZfdGVtcGxhdGVzL3RlbXBsYXRlcy5hbGdvLnRzOjI2CgkvLyByZXR1cm4gaXRvYih0aGlzLnVpbnQ2NFRtcGxWYXIpCglpbnRjIDEgLy8gVE1QTF91aW50NjRUbXBsVmFyCglpdG9iCglyZXRzdWIKCiphYmlfcm91dGVfY3JlYXRlQXBwbGljYXRpb246CglpbnRjIDAgLy8gMQoJcmV0dXJuCgoqY3JlYXRlX05vT3A6CglwdXNoYnl0ZXMgMHhiODQ0N2IzNiAvLyBtZXRob2QgImNyZWF0ZUFwcGxpY2F0aW9uKCl2b2lkIgoJdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMAoJbWF0Y2ggKmFiaV9yb3V0ZV9jcmVhdGVBcHBsaWNhdGlvbgoKCS8vIHRoaXMgY29udHJhY3QgZG9lcyBub3QgaW1wbGVtZW50IHRoZSBnaXZlbiBBQkkgbWV0aG9kIGZvciBjcmVhdGUgTm9PcAoJZXJyCgoqY2FsbF9Ob09wOgoJcHVzaGJ5dGVzIDB4OWE3MWQyYjQgLy8gbWV0aG9kICJ0bXBsKCl2b2lkIgoJcHVzaGJ5dGVzIDB4ZGY0ZDVjM2IgLy8gbWV0aG9kICJzcGVjaWZpY0xlbmd0aFRlbXBsYXRlVmFyKCl2b2lkIgoJcHVzaGJ5dGVzIDB4M2Q4NzBkODcgLy8gbWV0aG9kICJ0aHJvd0Vycm9yKCl2b2lkIgoJcHVzaGJ5dGVzIDB4YmMwYjE3MDYgLy8gbWV0aG9kICJpdG9iVGVtcGxhdGVWYXIoKWJ5dGVbXSIKCXR4bmEgQXBwbGljYXRpb25BcmdzIDAKCW1hdGNoICphYmlfcm91dGVfdG1wbCAqYWJpX3JvdXRlX3NwZWNpZmljTGVuZ3RoVGVtcGxhdGVWYXIgKmFiaV9yb3V0ZV90aHJvd0Vycm9yICphYmlfcm91dGVfaXRvYlRlbXBsYXRlVmFyCgoJLy8gdGhpcyBjb250cmFjdCBkb2VzIG5vdCBpbXBsZW1lbnQgdGhlIGdpdmVuIEFCSSBtZXRob2QgZm9yIGNhbGwgTm9PcAoJZXJy", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEw" + }, + "templateVariables": { + "bytesTmplVar": { + "type": "byte[]" + }, + "uint64TmplVar": { + "type": "uint64" + }, + "bytes32TmplVar": { + "type": "byte[32]" + }, + "bytes64TmplVar": { + "type": "byte[64]" + } + }, + "scratchVariables": { + "bytesTmplVar": { + "type": "byte[]", + "slot": 200 + }, + "uint64TmplVar": { + "type": "uint64", + "slot": 201 + }, + "bytes32TmplVar": { + "type": "byte[32]", + "slot": 202 + }, + "bytes64TmplVar": { + "type": "byte[64]", + "slot": 203 + } + }, + "compilerInfo": { + "compiler": "algod", + "compilerVersion": { + "major": 3, + "minor": 26, + "patch": 0, + "commitHash": "0d10b244" + } + } +} \ No newline at end of file diff --git a/tests/artifacts/testing_app_puya/arc32_app_spec.json b/tests/artifacts/testing_app_puya/arc32_app_spec.json new file mode 100644 index 0000000..d851890 --- /dev/null +++ b/tests/artifacts/testing_app_puya/arc32_app_spec.json @@ -0,0 +1,184 @@ +{ + "hints": { + "set_box_bytes(string,byte[])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_str(string,string)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_int(string,uint32)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_int512(string,uint512)void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_box_static(string,byte[4])void": { + "call_config": { + "no_op": "CALL" + } + }, + "set_struct(string,(string,uint64))void": { + "call_config": { + "no_op": "CALL" + }, + "structs": { + "value": { + "name": "DummyStruct", + "elements": [ + [ + "name", + "string" + ], + [ + "id", + "uint64" + ] + ] + } + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuYXBwcm92YWxfcHJvZ3JhbToKICAgIGludGNibG9jayAxIDAKICAgIGNhbGxzdWIgX19wdXlhX2FyYzRfcm91dGVyX18KICAgIHJldHVybgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5fX3B1eWFfYXJjNF9yb3V0ZXJfXygpIC0+IHVpbnQ2NDoKX19wdXlhX2FyYzRfcm91dGVyX186CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjExCiAgICAvLyBjbGFzcyBUZXN0UHV5YUJveGVzKEFSQzRDb250cmFjdCk6CiAgICBwcm90byAwIDEKICAgIHR4biBOdW1BcHBBcmdzCiAgICBieiBfX3B1eWFfYXJjNF9yb3V0ZXJfX19iYXJlX3JvdXRpbmdAMTAKICAgIHB1c2hieXRlc3MgMHgyMDJmYTczYSAweGRmN2VlYTRhIDB4MzY4OGVkMmMgMHg1ZDE3MjBkZCAweGY4MDY2NjVjIDB4ODFkMjYwZTIgLy8gbWV0aG9kICJzZXRfYm94X2J5dGVzKHN0cmluZyxieXRlW10pdm9pZCIsIG1ldGhvZCAic2V0X2JveF9zdHIoc3RyaW5nLHN0cmluZyl2b2lkIiwgbWV0aG9kICJzZXRfYm94X2ludChzdHJpbmcsdWludDMyKXZvaWQiLCBtZXRob2QgInNldF9ib3hfaW50NTEyKHN0cmluZyx1aW50NTEyKXZvaWQiLCBtZXRob2QgInNldF9ib3hfc3RhdGljKHN0cmluZyxieXRlWzRdKXZvaWQiLCBtZXRob2QgInNldF9zdHJ1Y3Qoc3RyaW5nLChzdHJpbmcsdWludDY0KSl2b2lkIgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMAogICAgbWF0Y2ggX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9ieXRlc19yb3V0ZUAyIF9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfc3RyX3JvdXRlQDMgX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9pbnRfcm91dGVANCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X2ludDUxMl9yb3V0ZUA1IF9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfc3RhdGljX3JvdXRlQDYgX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X3N0cnVjdF9yb3V0ZUA3CiAgICBpbnRjXzEgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X2J5dGVzX3JvdXRlQDI6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjIwCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgZXh0cmFjdCAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjAKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X2J5dGVzCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X3N0cl9yb3V0ZUAzOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToyNAogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICB0eG4gT25Db21wbGV0aW9uCiAgICAhCiAgICBhc3NlcnQgLy8gT25Db21wbGV0aW9uIGlzIG5vdCBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGNhbiBvbmx5IGNhbGwgd2hlbiBub3QgY3JlYXRpbmcKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MTEKICAgIC8vIGNsYXNzIFRlc3RQdXlhQm94ZXMoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDIKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjQKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X3N0cgogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X2JveF9pbnRfcm91dGVANDoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjgKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBub3QgTm9PcAogICAgdHhuIEFwcGxpY2F0aW9uSUQKICAgIGFzc2VydCAvLyBjYW4gb25seSBjYWxsIHdoZW4gbm90IGNyZWF0aW5nCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjExCiAgICAvLyBjbGFzcyBUZXN0UHV5YUJveGVzKEFSQzRDb250cmFjdCk6CiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAxCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAyCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjI4CiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIGNhbGxzdWIgc2V0X2JveF9pbnQKICAgIGludGNfMCAvLyAxCiAgICByZXRzdWIKCl9fcHV5YV9hcmM0X3JvdXRlcl9fX3NldF9ib3hfaW50NTEyX3JvdXRlQDU6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjMyCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozMgogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICBjYWxsc3ViIHNldF9ib3hfaW50NTEyCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19zZXRfYm94X3N0YXRpY19yb3V0ZUA2OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozNgogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICB0eG4gT25Db21wbGV0aW9uCiAgICAhCiAgICBhc3NlcnQgLy8gT25Db21wbGV0aW9uIGlzIG5vdCBOb09wCiAgICB0eG4gQXBwbGljYXRpb25JRAogICAgYXNzZXJ0IC8vIGNhbiBvbmx5IGNhbGwgd2hlbiBub3QgY3JlYXRpbmcKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MTEKICAgIC8vIGNsYXNzIFRlc3RQdXlhQm94ZXMoQVJDNENvbnRyYWN0KToKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDEKICAgIHR4bmEgQXBwbGljYXRpb25BcmdzIDIKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MzYKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgY2FsbHN1YiBzZXRfYm94X3N0YXRpYwogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fc2V0X3N0cnVjdF9yb3V0ZUA3OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MgogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIHR4biBPbkNvbXBsZXRpb24KICAgICEKICAgIGFzc2VydCAvLyBPbkNvbXBsZXRpb24gaXMgbm90IE5vT3AKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIG5vdCBjcmVhdGluZwogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MgogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIGNhbGxzdWIgc2V0X3N0cnVjdAogICAgaW50Y18wIC8vIDEKICAgIHJldHN1YgoKX19wdXlhX2FyYzRfcm91dGVyX19fYmFyZV9yb3V0aW5nQDEwOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgdHhuIE9uQ29tcGxldGlvbgogICAgYm56IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2FmdGVyX2lmX2Vsc2VAMTQKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICAhCiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIGNyZWF0aW5nCiAgICBpbnRjXzAgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDE0OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToxMQogICAgLy8gY2xhc3MgVGVzdFB1eWFCb3hlcyhBUkM0Q29udHJhY3QpOgogICAgaW50Y18xIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X2J5dGVzKG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfYnl0ZXM6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjIwLTIxCiAgICAvLyBAYXJjNC5hYmltZXRob2QKICAgIC8vIGRlZiBzZXRfYm94X2J5dGVzKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogQnl0ZXMpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjIKICAgIC8vIHNlbGYuYm94X2J5dGVzW25hbWVdID0gdmFsdWUKICAgIHB1c2hieXRlcyAiYm94X2J5dGVzIgogICAgZnJhbWVfZGlnIC0yCiAgICBjb25jYXQKICAgIGR1cAogICAgYm94X2RlbAogICAgcG9wCiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X3N0cihuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfYm94X3N0cjoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjQtMjUKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgLy8gZGVmIHNldF9ib3hfc3RyKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogYXJjNC5TdHJpbmcpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MjYKICAgIC8vIHNlbGYuYm94X3N0cltuYW1lXSA9IHZhbHVlCiAgICBwdXNoYnl0ZXMgImJveF9zdHIiCiAgICBmcmFtZV9kaWcgLTIKICAgIGNvbmNhdAogICAgZHVwCiAgICBib3hfZGVsCiAgICBwb3AKICAgIGZyYW1lX2RpZyAtMQogICAgYm94X3B1dAogICAgcmV0c3ViCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkMy5jb250cmFjdC5UZXN0UHV5YUJveGVzLnNldF9ib3hfaW50KG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfaW50OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weToyOC0yOQogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICAvLyBkZWYgc2V0X2JveF9pbnQoc2VsZiwgbmFtZTogYXJjNC5TdHJpbmcsIHZhbHVlOiBhcmM0LlVJbnQzMikgLT4gTm9uZToKICAgIHByb3RvIDIgMAogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozMAogICAgLy8gc2VsZi5ib3hfaW50W25hbWVdID0gdmFsdWUKICAgIHB1c2hieXRlcyAiYm94X2ludCIKICAgIGZyYW1lX2RpZyAtMgogICAgY29uY2F0CiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZDMuY29udHJhY3QuVGVzdFB1eWFCb3hlcy5zZXRfYm94X2ludDUxMihuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfYm94X2ludDUxMjoKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6MzItMzMKICAgIC8vIEBhcmM0LmFiaW1ldGhvZAogICAgLy8gZGVmIHNldF9ib3hfaW50NTEyKHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogYXJjNC5VSW50NTEyKSAtPiBOb25lOgogICAgcHJvdG8gMiAwCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjM0CiAgICAvLyBzZWxmLmJveF9pbnQ1MTJbbmFtZV0gPSB2YWx1ZQogICAgcHVzaGJ5dGVzICJib3hfaW50NTEyIgogICAgZnJhbWVfZGlnIC0yCiAgICBjb25jYXQKICAgIGZyYW1lX2RpZyAtMQogICAgYm94X3B1dAogICAgcmV0c3ViCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkMy5jb250cmFjdC5UZXN0UHV5YUJveGVzLnNldF9ib3hfc3RhdGljKG5hbWU6IGJ5dGVzLCB2YWx1ZTogYnl0ZXMpIC0+IHZvaWQ6CnNldF9ib3hfc3RhdGljOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTozNi0zOQogICAgLy8gQGFyYzQuYWJpbWV0aG9kCiAgICAvLyBkZWYgc2V0X2JveF9zdGF0aWMoCiAgICAvLyAgICAgc2VsZiwgbmFtZTogYXJjNC5TdHJpbmcsIHZhbHVlOiBhcmM0LlN0YXRpY0FycmF5W2FyYzQuQnl0ZSwgTGl0ZXJhbFs0XV0KICAgIC8vICkgLT4gTm9uZToKICAgIHByb3RvIDIgMAogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0MAogICAgLy8gc2VsZi5ib3hfc3RhdGljW25hbWVdID0gdmFsdWUuY29weSgpCiAgICBwdXNoYnl0ZXMgImJveF9zdGF0aWMiCiAgICBmcmFtZV9kaWcgLTIKICAgIGNvbmNhdAogICAgZnJhbWVfZGlnIC0xCiAgICBib3hfcHV0CiAgICByZXRzdWIKCgovLyBzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuc2V0X3N0cnVjdChuYW1lOiBieXRlcywgdmFsdWU6IGJ5dGVzKSAtPiB2b2lkOgpzZXRfc3RydWN0OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkMy9jb250cmFjdC5weTo0Mi00MwogICAgLy8gQGFyYzQuYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBzZXRfc3RydWN0KHNlbGYsIG5hbWU6IGFyYzQuU3RyaW5nLCB2YWx1ZTogRHVtbXlTdHJ1Y3QpIC0+IE5vbmU6CiAgICBwcm90byAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZDMvY29udHJhY3QucHk6NDQKICAgIC8vIGFzc2VydCBuYW1lLmJ5dGVzID09IHZhbHVlLm5hbWUuYnl0ZXMsICJOYW1lIG11c3QgbWF0Y2ggaWQgb2Ygc3RydWN0IgogICAgZnJhbWVfZGlnIC0xCiAgICBpbnRjXzEgLy8gMAogICAgZXh0cmFjdF91aW50MTYKICAgIGZyYW1lX2RpZyAtMQogICAgbGVuCiAgICBmcmFtZV9kaWcgLTEKICAgIGNvdmVyIDIKICAgIHN1YnN0cmluZzMKICAgIGZyYW1lX2RpZyAtMgogICAgPT0KICAgIGFzc2VydCAvLyBOYW1lIG11c3QgbWF0Y2ggaWQgb2Ygc3RydWN0CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQzL2NvbnRyYWN0LnB5OjQ1CiAgICAvLyBvcC5Cb3gucHV0KG5hbWUuYnl0ZXMsIHZhbHVlLmJ5dGVzKQogICAgZnJhbWVfZGlnIC0yCiAgICBmcmFtZV9kaWcgLTEKICAgIGJveF9wdXQKICAgIHJldHN1Ygo=", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQzLmNvbnRyYWN0LlRlc3RQdXlhQm94ZXMuY2xlYXJfc3RhdGVfcHJvZ3JhbToKICAgIHB1c2hpbnQgMSAvLyAxCiAgICByZXR1cm4K" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} + }, + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "TestPuyaBoxes", + "methods": [ + { + "name": "set_box_bytes", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "byte[]", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_str", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "string", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_int", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "uint32", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_int512", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "uint512", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_box_static", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "byte[4]", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + }, + { + "name": "set_struct", + "args": [ + { + "type": "string", + "name": "name" + }, + { + "type": "(string,uint64)", + "name": "value" + } + ], + "readonly": false, + "returns": { + "type": "void" + } + } + ], + "networks": {} + }, + "bare_call_config": { + "no_op": "CREATE" + } +} diff --git a/tests/artifacts/testing_app_puya/contract.py b/tests/artifacts/testing_app_puya/contract.py new file mode 100644 index 0000000..7074dd6 --- /dev/null +++ b/tests/artifacts/testing_app_puya/contract.py @@ -0,0 +1,43 @@ +from typing import Literal + +from algopy import ARC4Contract, BoxMap, Bytes, arc4, op + + +class DummyStruct(arc4.Struct): + name: arc4.String + id: arc4.UInt64 + + +class TestPuyaBoxes(ARC4Contract): + def __init__(self) -> None: + self.box_bytes = BoxMap(arc4.String, Bytes) + self.box_bytes2 = BoxMap(Bytes, Bytes) + self.box_str = BoxMap(arc4.String, arc4.String) + self.box_int = BoxMap(arc4.String, arc4.UInt32) + self.box_int512 = BoxMap(arc4.String, arc4.UInt512) + self.box_static = BoxMap(arc4.String, arc4.StaticArray[arc4.Byte, Literal[4]]) + + @arc4.abimethod + def set_box_bytes(self, name: arc4.String, value: Bytes) -> None: + self.box_bytes[name] = value + + @arc4.abimethod + def set_box_str(self, name: arc4.String, value: arc4.String) -> None: + self.box_str[name] = value + + @arc4.abimethod + def set_box_int(self, name: arc4.String, value: arc4.UInt32) -> None: + self.box_int[name] = value + + @arc4.abimethod + def set_box_int512(self, name: arc4.String, value: arc4.UInt512) -> None: + self.box_int512[name] = value + + @arc4.abimethod + def set_box_static(self, name: arc4.String, value: arc4.StaticArray[arc4.Byte, Literal[4]]) -> None: + self.box_static[name] = value.copy() + + @arc4.abimethod() + def set_struct(self, name: arc4.String, value: DummyStruct) -> None: + assert name.bytes == value.name.bytes, "Name must match id of struct" + op.Box.put(name.bytes, value.bytes) From 52a6d7d276b6cc110bf3b9bed64927aa559090a2 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 3 Dec 2024 20:56:27 +0100 Subject: [PATCH 10/19] chore: adding initial logic error exposer --- .vscode/settings.json | 23 +- legacy_v2_tests/test_app_client_call.py | 37 +-- pyproject.toml | 7 +- src/algokit_utils/_legacy_v2/deploy.py | 2 +- src/algokit_utils/_legacy_v2/logic_error.py | 5 +- src/algokit_utils/applications/app_client.py | 263 ++++++++++++++---- src/algokit_utils/errors/logic_error.py | 116 ++++++++ src/algokit_utils/transactions/models.py | 2 - tests/applications/test_app_client.py | 36 +++ .../testing_app/sources.teal.map.json | 22 ++ 10 files changed, 424 insertions(+), 89 deletions(-) create mode 100644 src/algokit_utils/errors/logic_error.py create mode 100644 tests/artifacts/testing_app/sources.teal.map.json diff --git a/.vscode/settings.json b/.vscode/settings.json index e570b2a..a116296 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,18 +16,24 @@ "**/__pycache__": true, ".idea": true }, - // Python "platformSettings.autoLoad": true, "python.defaultInterpreterPath": "${workspaceFolder}/.venv", - "python.analysis.extraPaths": ["${workspaceFolder}/src"], + "python.analysis.extraPaths": [ + "${workspaceFolder}/src" + ], "[python]": { "editor.defaultFormatter": "charliermarsh.ruff" }, + "python.analysis.exclude": [ + "tests/artifacts/**" + ], "python.analysis.typeCheckingMode": "basic", "ruff.enable": true, "ruff.lint.run": "onSave", - "ruff.lint.args": ["--config=pyproject.toml"], + "ruff.lint.args": [ + "--config=pyproject.toml" + ], "ruff.importStrategy": "fromEnvironment", "ruff.fixAll": true, //lint and fix all files in workspace "ruff.organizeImports": true, //organize imports on save @@ -37,7 +43,6 @@ "ruff.codeAction.fixViolation": { "enable": true }, - "mypy.configFile": "pyproject.toml", // set to empty array to use config from project "mypy.targets": [], @@ -52,11 +57,7 @@ } ] }, - - // PowerShell - "[powershell]": { - "editor.defaultFormatter": "ms-vscode.powershell" - }, - "powershell.codeFormatting.preset": "Stroustrup", - "python.testing.pytestArgs": ["."] + "python.testing.pytestArgs": [ + "." + ], } diff --git a/legacy_v2_tests/test_app_client_call.py b/legacy_v2_tests/test_app_client_call.py index 67acd4d..78a7165 100644 --- a/legacy_v2_tests/test_app_client_call.py +++ b/legacy_v2_tests/test_app_client_call.py @@ -3,15 +3,7 @@ from typing import TYPE_CHECKING from unittest.mock import Mock, patch -import algokit_utils import pytest -from algokit_utils import ( - Account, - ApplicationClient, - ApplicationSpecification, - CreateCallParameters, - get_account, -) from algosdk.atomic_transaction_composer import ( AccountTransactionSigner, AtomicTransactionComposer, @@ -19,6 +11,16 @@ ) from algosdk.transaction import ApplicationCallTxn, PaymentTxn +import algokit_utils +import algokit_utils._legacy_v2 +import algokit_utils._legacy_v2.logic_error +from algokit_utils import ( + Account, + ApplicationClient, + ApplicationSpecification, + CreateCallParameters, + get_account, +) from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: @@ -186,7 +188,7 @@ def test_readonly_call(client_fixture: ApplicationClient) -> None: def test_readonly_call_with_error(client_fixture: ApplicationClient) -> None: - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -211,7 +213,7 @@ def test_readonly_call_with_error_with_new_client_provided_template_values( ) new_client.approval_source_map = client.approval_source_map - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -234,7 +236,7 @@ def test_readonly_call_with_error_with_new_client_provided_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) new_client.approval_source_map = client.approval_source_map - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -259,7 +261,7 @@ def test_readonly_call_with_error_with_imported_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) new_client.import_source_map(source_map_export) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -281,7 +283,7 @@ def test_readonly_call_with_error_with_new_client_missing_source_map( new_client = ApplicationClient(algod_client, app_spec, app_id=client.app_id, signer=funded_account) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 new_client.call( "readonly", error=1, @@ -292,7 +294,7 @@ def test_readonly_call_with_error_with_new_client_missing_source_map( def test_readonly_call_with_error_debug_mode_disabled(mock_config: Mock, client_fixture: ApplicationClient) -> None: mock_config.debug = False - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -302,7 +304,7 @@ def test_readonly_call_with_error_debug_mode_disabled(mock_config: Mock, client_ def test_readonly_call_with_error_debug_mode_enabled(client_fixture: ApplicationClient) -> None: - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "readonly", error=1, @@ -322,7 +324,7 @@ def test_app_call_with_error_debug_mode_disabled(mock_config: Mock, client_fixtu min_funding_increment_micro_algos=200_000, ), ) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "set_box", name=b"ssss", @@ -342,7 +344,7 @@ def test_app_call_with_error_debug_mode_enabled(client_fixture: ApplicationClien min_funding_increment_micro_algos=200_000, ), ) - with pytest.raises(algokit_utils.LogicError) as ex: + with pytest.raises(algokit_utils._legacy_v2.logic_error.LogicError) as ex: # noqa: SLF001 client_fixture.call( "set_box", name=b"ssss", @@ -350,4 +352,3 @@ def test_app_call_with_error_debug_mode_enabled(client_fixture: ApplicationClien ) assert ex.value.traces is not None - assert ex.value.traces[0].exec_trace["approval-program-trace"] is not None diff --git a/pyproject.toml b/pyproject.toml index e22173c..1477aaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ lint.ignore = [ "Q002", # bad quotes docstring "Q003", # avoidable escaped quotes "W191", # indentation contains tabs + "ERA001", # commented out code ] # Exclude a variety of commonly ignored directories. extend-exclude = [ @@ -113,7 +114,7 @@ extend-exclude = [ ".git", ".mypy_cache", ".ruff_cache", - + "tests/artifacts", ] # Assume Python 3.10. target-version = "py310" @@ -127,7 +128,7 @@ suppress-none-returning = true [tool.ruff.lint.per-file-ignores] "src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] -"path/to/file.py" = ["E402"] +"src/algokit_utils/applications/app_client.py" = ["SLF001"] "tests/clients/test_algorand_client.py" = ["ERA001"] [tool.poe.tasks] @@ -140,7 +141,7 @@ pythonpath = ["src", "tests"] [tool.mypy] files = ["src", "tests"] -exclude = ["dist"] +exclude = ["dist", "tests/artifacts"] python_version = "3.10" warn_unused_ignores = true warn_redundant_casts = true diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 799fe08..0aadb72 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -255,7 +255,7 @@ class AppChanges: schema_change_description: str | None -def check_for_app_changes( # noqa: PLR0913 +def check_for_app_changes( algod_client: "AlgodClient", *, new_approval: bytes, diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py index a365a3c..0878887 100644 --- a/src/algokit_utils/_legacy_v2/logic_error.py +++ b/src/algokit_utils/_legacy_v2/logic_error.py @@ -2,6 +2,8 @@ from copy import copy from typing import TYPE_CHECKING, TypedDict +from deprecated import deprecated + from algokit_utils._legacy_v2.models import SimulationTrace if TYPE_CHECKING: @@ -37,8 +39,9 @@ def parse_logic_error( } +@deprecated(reason="Use algokit_utils.models.error.LogicError instead", version="3.0.0") class LogicError(Exception): - def __init__( # noqa: PLR0913 + def __init__( self, *, logic_error_str: str, diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 69fc31a..4805d79 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -5,7 +5,7 @@ import json import os from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, TypeVar import algosdk from algosdk.transaction import OnComplete, Transaction @@ -18,7 +18,16 @@ get_abi_tuple_from_abi_struct, get_arc56_method, ) -from algokit_utils.models.application import AppState, Arc56Contract, CompiledTeal, StorageKey, StorageMap +from algokit_utils.errors.logic_error import LogicError, parse_logic_error +from algokit_utils.models.application import ( + AppState, + Arc56Contract, + CompiledTeal, + ProgramSourceInfo, + SourceInfoDetail, + StorageKey, + StorageMap, +) from algokit_utils.models.transaction import SendParams from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, @@ -53,6 +62,8 @@ BYTE_CBLOCK = 0x20 # bytecblock opcode INT_CBLOCK = 0x21 # intcblock opcode +T = TypeVar("T") # For generic return type in _handle_call_errors + def get_constant_block_offset(program: bytes) -> int: # noqa: C901 """Calculate the offset after constant blocks in TEAL program. @@ -123,12 +134,6 @@ class AppClientCompilationParams: deletable: bool | None = None -@dataclass(frozen=True, kw_only=True) -class ProgramSourceInfo: - pc_offset_method: str | None - source_info: list[dict[str, Any]] - - @dataclass(frozen=True, kw_only=True) class ExposedLogicErrorDetails: is_clear_state_program: bool = False @@ -354,9 +359,9 @@ def get_map(self, map_name: str) -> dict[str, ABIValue]: class _AppClientStateAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec def local_state(self, address: str) -> _AppClientStateMethodsProtocol: """Methods to access local state for the current app for a given address""" @@ -465,9 +470,9 @@ def get_global_state(self) -> dict[str, AppState]: class _AppClientBareParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec def _get_bare_params( self, params: dict[str, Any] | None, on_complete: algosdk.transaction.OnComplete @@ -523,9 +528,9 @@ def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> AppCallPara class _AppClientMethodCallParamsAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_params_accessor = _AppClientBareParamsAccessor(client) @property @@ -583,16 +588,16 @@ def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transacti input_params["app_id"] = self._app_id input_params["on_complete"] = on_complete - input_params["sender"] = self._client._get_sender(params["sender"]) # noqa: SLF001 - input_params["signer"] = self._client._get_signer(params["sender"], params["signer"]) # noqa: SLF001 + input_params["sender"] = self._client._get_sender(params["sender"]) + input_params["signer"] = self._client._get_signer(params["sender"], params["signer"]) if params.get("method"): input_params["method"] = get_arc56_method(params["method"], self._app_spec) if params.get("args"): - input_params["args"] = self._client._get_abi_args_with_default_values( # noqa: SLF001 + input_params["args"] = self._client._get_abi_args_with_default_values( method_name_or_signature=params["method"], args=params["args"], - sender=self._client._get_sender(input_params["sender"]), # noqa: SLF001 + sender=self._client._get_sender(input_params["sender"]), ) return input_params @@ -601,7 +606,7 @@ def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transacti class _AppClientBareCreateTransactionMethods: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 + self._algorand = client._algorand def update(self, params: AppClientBareCallWithCompilationAndSendParams) -> Transaction: return self._algorand.create_transaction.app_update(self._client.params.bare.update(params)) @@ -625,9 +630,9 @@ def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> Transaction class _AppClientMethodCallTransactionCreator: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_create_transaction_methods = _AppClientBareCreateTransactionMethods(client) @property @@ -656,9 +661,9 @@ def call(self, params: AppClientMethodCallParams) -> BuiltTransactions: class _AppClientBareSendAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec def update( self, @@ -682,31 +687,41 @@ def update( bare_params = self._client.params.bare.update(params) bare_params.__setattr__("approval_program", bare_params.approval_program or compiled.compiled_approval) bare_params.__setattr__("clear_state_program", bare_params.clear_state_program or compiled.compiled_clear) - call_result = self._algorand.send.app_update(bare_params) + call_result = self._client._handle_call_errors(lambda: self._algorand.send.app_update(bare_params)) return SendAppTransactionResult(**{**call_result.__dict__, **(compiled.__dict__ if compiled else {})}) def opt_in(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call(self._client.params.bare.opt_in(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.opt_in(params)) + ) def delete(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call(self._client.params.bare.delete(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.delete(params)) + ) def clear_state(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call(self._client.params.bare.clear_state(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.clear_state(params)) + ) def close_out(self, params: AppClientBareCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call(self._client.params.bare.close_out(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.close_out(params)) + ) def call(self, params: AppClientBareCallWithCallOnCompleteParams) -> SendAppTransactionResult: - return self._algorand.send.app_call(self._client.params.bare.call(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call(self._client.params.bare.call(params)) + ) class _AppClientSendAccessor: def __init__(self, client: AppClient) -> None: self._client = client - self._algorand = client._algorand # noqa: SLF001 - self._app_id = client._app_id # noqa: SLF001 - self._app_spec = client._app_spec # noqa: SLF001 + self._algorand = client._algorand + self._app_id = client._app_id + self._app_spec = client._app_spec self._bare_send_accessor = _AppClientBareSendAccessor(client) @property @@ -714,19 +729,29 @@ def bare(self) -> _AppClientBareSendAccessor: return self._bare_send_accessor def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: - return self._algorand.send.payment(self._client.params.fund_app_account(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.payment(self._client.params.fund_app_account(params)) + ) def opt_in(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call_method_call(self._client.params.opt_in(params)) + ) def delete(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_delete_method_call(self._client.params.delete(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_delete_method_call(self._client.params.delete(params)) + ) def update(self, params: AppClientMethodCallWithCompilationAndSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_update_method_call(self._client.params.update(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_update_method_call(self._client.params.update(params)) + ) def close_out(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: - return self._algorand.send.app_call_method_call(self._client.params.close_out(params)) + return self._client._handle_call_errors( # type: ignore[no-any-return] + lambda: self._algorand.send.app_call_method_call(self._client.params.close_out(params)) + ) def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: is_read_only_call = ( @@ -740,15 +765,17 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR self._client.params.call(params) ) - simulate_response = method_call_to_simulate.simulate( - allow_unnamed_resources=params.populate_app_call_resources or True, - skip_signatures=True, - allow_more_logs=True, - allow_empty_signatures=True, - extra_opcode_budget=None, - exec_trace_config=None, - round=None, - fix_signers=None, # TODO: double check on whether algosdk py even has this param + simulate_response = self._client._handle_call_errors( + lambda: method_call_to_simulate.simulate( + allow_unnamed_resources=params.populate_app_call_resources or True, + skip_signatures=True, + allow_more_logs=True, + allow_empty_signatures=True, + extra_opcode_budget=None, + exec_trace_config=None, + round=None, + fix_signers=None, # TODO: double check on whether algosdk py even has this param + ) ) return SendAppTransactionResult( @@ -763,7 +790,9 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR return_value=simulate_response.returns[-1].return_value, ) - return self._algorand.send.app_call_method_call(self._client.params.call(params)) + return self._client._handle_call_errors( + lambda: self._algorand.send.app_call_method_call(self._client.params.call(params)) + ) class AppClient: @@ -914,7 +943,6 @@ def compile( ) # TODO: Add invocation of persisting sourcemaps - return AppClientCompilationResult( approval_program=compiled_approval.compiled_base64_to_bytes, compiled_approval=compiled_approval, @@ -922,6 +950,78 @@ def compile( compiled_clear=compiled_clear, ) + @staticmethod + def expose_logic_error_static( + e: Exception, app_spec: Arc56Contract, details: ExposedLogicErrorDetails + ) -> Exception: + """Takes an error that may include a logic error and re-exposes it with source info.""" + source_map = details.clear_source_map if details.is_clear_state_program else details.approval_source_map + + error_details = parse_logic_error(str(e)) + if not error_details: + return e + + # The PC value to find in the ARC56 SourceInfo + arc56_pc = error_details["pc"] + + program_source_info = ( + details.clear_source_info if details.is_clear_state_program else details.approval_source_info + ) + + # The offset to apply to the PC if using the cblocks pc offset method + cblocks_offset = 0 + + # If the program uses cblocks offset, then we need to adjust the PC accordingly + if program_source_info and program_source_info.pc_offset_method == "cblocks": + if not details.program: + raise Exception("Program bytes are required to calculate the ARC56 cblocks PC offset") + + cblocks_offset = get_constant_block_offset(details.program) + arc56_pc = error_details["pc"] - cblocks_offset + + # Find the source info for this PC and get the error message + source_info = None + if program_source_info and program_source_info.source_info: + source_info = next( + (s for s in program_source_info.source_info if isinstance(s, SourceInfoDetail) and arc56_pc in s.pc), + None, + ) + error_message = source_info.error_message if source_info else None + + # If we have the source we can display the TEAL in the error message + if hasattr(app_spec, "source"): + program_source = ( + (app_spec.source.get("clear") if details.is_clear_state_program else app_spec.source.get("approval")) + if app_spec.source + else None + ) + if program_source: + e = LogicError( + logic_error_str=str(e), + program=program_source, + source_map=source_map, + transaction_id=error_details["transaction_id"], + message=error_details["message"], + pc=error_details["pc"], + logic_error=e, + traces=None, + ) + + if error_message: + import re + + app_id = re.search(r"(?<=app=)\d+", str(e)) + tx_id = re.search(r"(?<=transaction )\S+(?=:)", str(e)) + error = Exception( + f"Runtime error when executing {app_spec.name} " + f"(appId: {app_id.group() if app_id else ''}) in transaction " + f"{tx_id.group() if tx_id else ''}: {error_message}" + ) + error.__cause__ = e + return error + + return e + # NOTE: No method overloads hence slightly different name, in TS its both instance/static methods named 'compile' def compile_and_persist_sourcemaps( self, @@ -1007,6 +1107,63 @@ def new_group(self) -> TransactionComposer: def fund_app_account(self, params: FundAppAccountParams) -> SendSingleTransactionResult: return self.send.fund_app_account(params) + def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: # noqa: FBT001, FBT002 + """Takes an error that may include a logic error from a call to the current app and re-exposes the + error to include source code information via the source map and ARC-56 spec. + + Args: + e: The error to parse + is_clear_state_program: Whether the code was running the clear state program (defaults to approval program) + + Returns: + The new error, or if there was no logic error or source map then the wrapped error with source details + """ + + # Get source info based on program type + source_info = None + if hasattr(self._app_spec, "source_info") and self._app_spec.source_info: + source_info = ( + self._app_spec.source_info.get("clear") + if is_clear_state_program + else self._app_spec.source_info.get("approval") + ) + + pc_offset_method = source_info.pc_offset_method if source_info else None + + program: bytes | None = None + if pc_offset_method == "cblocks": + # TODO: Cache this if we deploy the app and it's not updateable + app_info = self._algorand.app.get_by_id(self.app_id) + program = app_info.clear_state_program if is_clear_state_program else app_info.approval_program + + return AppClient.expose_logic_error_static( + e, + self._app_spec, + ExposedLogicErrorDetails( + is_clear_state_program=is_clear_state_program, + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + program=program, + approval_source_info=( + self._app_spec.source_info.get("approval") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + clear_source_info=( + self._app_spec.source_info.get("clear") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + ), + ) + + def _handle_call_errors(self, call: Callable[[], T]) -> T: + """Make the given call and catch any errors, augmenting with debugging information before re-throwing.""" + try: + return call() + except Exception as e: + raise self.expose_logic_error(e=e) from None + def _get_sender(self, sender: str | None) -> str: if not sender and not self._default_sender: raise Exception( diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py new file mode 100644 index 0000000..b3d9c12 --- /dev/null +++ b/src/algokit_utils/errors/logic_error.py @@ -0,0 +1,116 @@ +import dataclasses +import re +from copy import copy +from typing import TYPE_CHECKING, TypedDict + +from algosdk.atomic_transaction_composer import ( + SimulateAtomicTransactionResponse, +) + +if TYPE_CHECKING: + from algosdk.source_map import SourceMap as AlgoSourceMap + +__all__ = [ + "LogicError", + "parse_logic_error", +] + +LOGIC_ERROR = ( + ".*transaction (?P[A-Z0-9]+): logic eval error: (?P.*). Details: .*pc=(?P[0-9]+).*" +) + + +class LogicErrorData(TypedDict): + transaction_id: str + message: str + pc: int + + +@dataclasses.dataclass +class SimulationTrace: + app_budget_added: int | None + app_budget_consumed: int | None + failure_message: str | None + exec_trace: dict[str, object] + + +def parse_logic_error( + error_str: str, +) -> LogicErrorData | None: + match = re.match(LOGIC_ERROR, error_str) + if match is None: + return None + + return { + "transaction_id": match.group("transaction_id"), + "message": match.group("message"), + "pc": int(match.group("pc")), + } + + +class LogicError(Exception): + def __init__( + self, + *, + logic_error_str: str, + program: str, + source_map: "AlgoSourceMap | None", + transaction_id: str, + message: str, + pc: int, + logic_error: Exception | None = None, + traces: list[SimulationTrace] | None = None, + ): + self.logic_error = logic_error + self.logic_error_str = logic_error_str + self.program = program + self.source_map = source_map + self.lines = program.split("\n") + self.transaction_id = transaction_id + self.message = message + self.pc = pc + self.traces = traces + + self.line_no = self.source_map.get_line_for_pc(self.pc) if self.source_map else None + + def __str__(self) -> str: + return ( + f"Txn {self.transaction_id} had error '{self.message}' at PC {self.pc}" + + (":" if self.line_no is None else f" and Source Line {self.line_no}:") + + f"\n{self.trace()}" + ) + + def trace(self, lines: int = 5) -> str: + if self.line_no is None: + return """ +Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the +error please provide an approval SourceMap. Either by: + 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2.) Set approval_source_map from a previously compiled approval program OR + 3.) Import a previously exported source map using import_source_map""" + + program_lines = copy(self.lines) + program_lines[self.line_no] += "\t\t<-- Error" + lines_before = max(0, self.line_no - lines) + lines_after = min(len(program_lines), self.line_no + lines) + return "\n\t" + "\n\t".join(program_lines[lines_before:lines_after]) + + +def create_simulate_traces_for_logic_error(simulate: SimulateAtomicTransactionResponse) -> list[SimulationTrace]: + traces = [] + if hasattr(simulate, "simulate_response") and hasattr(simulate, "failed_at") and simulate.failed_at: + for txn_group in simulate.simulate_response["txn-groups"]: + app_budget_added = txn_group.get("app-budget-added", None) + app_budget_consumed = txn_group.get("app-budget-consumed", None) + failure_message = txn_group.get("failure-message", None) + txn_result = txn_group.get("txn-results", [{}])[0] + exec_trace = txn_result.get("exec-trace", {}) + traces.append( + SimulationTrace( + app_budget_added=app_budget_added, + app_budget_consumed=app_budget_consumed, + failure_message=failure_message, + exec_trace=exec_trace, + ) + ) + return traces diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index ff9e6bc..251bbf9 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -1,7 +1,5 @@ from typing import Any, Literal, TypedDict -from algosdk.v2client.models.simulate_request import SimulateTraceConfig - # Define specific types for different formats class BaseArc2Note(TypedDict): diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index b485d73..9256526 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -1,4 +1,5 @@ import base64 +import json from pathlib import Path from typing import Any @@ -15,6 +16,7 @@ from algokit_utils.applications.app_manager import AppManager, BoxReference from algokit_utils.applications.utils import arc32_to_arc56 from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.errors.logic_error import LogicError from algokit_utils.models.abi import ABIType from algokit_utils.models.account import Account from algokit_utils.models.amount import AlgoAmount @@ -123,6 +125,29 @@ def test_app_client( ) +@pytest.fixture +def test_app_client_with_sourcemaps( + algorand: AlgorandClient, + funded_account: Account, + testing_app_arc32_app_spec: ApplicationSpecification, + testing_app_arc32_app_id: int, +) -> AppClient: + sourcemaps = json.loads( + (Path(__file__).parent.parent / "artifacts" / "testing_app" / "sources.teal.map.json").read_text() + ) + return AppClient( + AppClientParams( + default_sender=funded_account.address, + default_signer=funded_account.signer, + app_id=testing_app_arc32_app_id, + algorand=algorand, + approval_source_map=algosdk.source_map.SourceMap(sourcemaps["approvalSourceMap"]), + clear_source_map=algosdk.source_map.SourceMap(sourcemaps["clearSourceMap"]), + app_spec=testing_app_arc32_app_spec, + ) + ) + + @pytest.fixture def testing_app_puya_arc32_app_spec() -> ApplicationSpecification: raw_json_spec = Path(__file__).parent.parent / "artifacts" / "testing_app_puya" / "arc32_app_spec.json" @@ -553,3 +578,14 @@ def test_abi_with_default_arg_method( # Test with default value default_value_result = app_client.send.call(AppClientMethodCallWithSendParams(method=method_signature, args=[None])) assert default_value_result.return_value == "Local state, banana" + + +def test_exposing_logic_error(test_app_client_with_sourcemaps: AppClient) -> None: + with pytest.raises(LogicError) as exc_info: + test_app_client_with_sourcemaps.send.call(AppClientMethodCallWithSendParams(method="error")) + + error = exc_info.value + assert error.pc == 885 # noqa: PLR2004 + assert "assert failed pc=885" in str(error) + assert len(error.transaction_id) == 52 # noqa: PLR2004 + assert error.line_no == 469 # noqa: PLR2004 diff --git a/tests/artifacts/testing_app/sources.teal.map.json b/tests/artifacts/testing_app/sources.teal.map.json new file mode 100644 index 0000000..9ee4339 --- /dev/null +++ b/tests/artifacts/testing_app/sources.teal.map.json @@ -0,0 +1,22 @@ +{ + "approvalSourceMap": { + "version": 3, + "sources": [ + "" + ], + "names": [], + "mappings": ";AACA;;;;;;;;AACA;;;;;;;;AACA;;AACA;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;;;AACA;;;;;;AACA;AACA;;;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;AACA;;;AACA;;AACA;AACA;AACA;;;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAEA;;AACA;AACA;AACA;AACA;;;AACA;AACA;AAIA;;;AACA;AACA;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;AACA;AACA;AACA;;;AACA;AAEA;;;;;;;;;;;;AACA;;AACA;AACA;AACA;AACA;AACA;AACA;;;AAEA;;AACA;AACA;AACA;;;AACA;;;AAEA;;;AAEA;AAIA;;;AACA;AACA;;;;;;;AACA;;AACA;;AACA;;;AACA;AACA;;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;AACA;;;AACA;;;AACA;AACA;;;;;;;;;;;AACA;AACA;;;AACA;;;AACA;AACA;;;;;;;;;;;;;AACA;AACA;;;AACA;AACA;AACA;;;AACA;AACA;;;AACA;AACA;;;AACA;AACA;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;;;;;AACA;;AACA;AACA;;;;;;AACA;;AACA;AACA;;;;;;;;AACA;;AACA;;;AACA;AACA;;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;;AACA;;;;;;;;;;;;AACA;;AACA;AACA;;AACA;;;;;;;;;;;;AACA;;AACA;AACA;;AACA;;;;;;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;;;;;;;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;;AACA;AACA;AACA;;AACA;;AACA;;;AACA;AACA;AAIA;;;AACA;AAEA;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;;;;;;AACA;;AACA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAEA;AACA;;AAEA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;AAIA;;;AACA;AACA;;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;;AACA;;AACA;AAIA;;;AACA;AACA;;;;;;;;;;;;;;;AACA;;AACA;;;AACA;AACA;;AACA;;AACA;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;AACA;;;AACA;;AACA;;AACA;AACA;AACA;;AACA;;AACA;;AACA;AACA;AACA;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;AACA;AACA;;;AACA;AACA;;AACA;;;AACA;AACA;;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;;AACA;;AACA;;AACA;;AACA;;;AACA;AAIA;;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;;;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA;AACA;AAIA;;;AACA;AACA;AACA;;;AACA;;AACA;;AACA;;;AACA;;AACA;AACA;;AACA;AACA;AACA", + "pcToLocation": {}, + "sourceAndLineToPc": {} + }, + "clearSourceMap": { + "version": 3, + "sources": [ + "" + ], + "names": [], + "mappings": ";AACA;;AACA", + "pcToLocation": {}, + "sourceAndLineToPc": {} + } +} From ae5430409dcca4347d17ef76d9afeb0e0f38877d Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 4 Dec 2024 00:17:57 +0100 Subject: [PATCH 11/19] chore: resource population; skeletons for appdeployer and appfactory --- src/algokit_utils/applications/app_client.py | 22 + .../applications/app_deployer.py | 586 ++++++++++++++---- src/algokit_utils/applications/app_factory.py | 569 ++++++++++++++--- src/algokit_utils/clients/algorand_client.py | 8 +- src/algokit_utils/config.py | 6 + src/algokit_utils/protocols/application.py | 4 +- .../transactions/transaction_composer.py | 11 +- src/algokit_utils/transactions/utils.py | 271 ++++++++ 8 files changed, 1272 insertions(+), 205 deletions(-) create mode 100644 src/algokit_utils/transactions/utils.py diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 4805d79..38a2339 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -319,6 +319,12 @@ class ResolveAppClientByNetwork: clear_source_map: SourceMap | None = None +@dataclass(frozen=True, kw_only=True) +class AppSourceMaps: + approval_source_map: SourceMap | None = None + clear_source_map: SourceMap | None = None + + class _AppClientStateMethodsProtocol(Protocol): def get_all(self) -> dict[str, Any]: ... @@ -1059,6 +1065,22 @@ def clone( ) ) + def export_source_maps(self) -> AppSourceMaps: + if not self._approval_source_map or not self._clear_source_map: + raise ValueError( + "Unable to export source maps; they haven't been loaded into this client - " + "you need to call create, update, or deploy first" + ) + + return AppSourceMaps( + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + ) + + def import_source_maps(self, source_maps: AppSourceMaps) -> None: + self._approval_source_map = source_maps.approval_source_map + self._clear_source_map = source_maps.clear_source_map + def get_local_state(self, address: str) -> dict[str, AppState]: return self._state_accessor.get_local_state(address) diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 3765bc0..8b18714 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -1,116 +1,470 @@ -# from dataclasses import dataclass -# from typing import Any - -# from algokit_utils._legacy_v2.application_specification import ApplicationSpecification -# from algokit_utils.models.account import Account - - -# @dataclass -# class AppFactoryParams: -# app_spec: ApplicationSpecification | str -# algorand: Any # AlgorandClient -# app_name: str | None = None -# default_sender: str | None = None -# default_signer: Any | None = None # TransactionSigner -# version: str | None = None -# updatable: bool | None = None -# deletable: bool | None = None -# deploy_time_params: dict[str, Any] | None = None - - -# @dataclass -# class AppDeployer: -# app_manager: Any # AppManager -# transaction_sender: Any # AlgorandClientTransactionSender -# indexer: Any | None = None # Indexer - -# def __post_init__(self): -# self._app_lookups = {} - -# def deploy(self, deployment: dict[str, Any]): -# metadata = deployment["metadata"] -# deploy_time_params = deployment.get("deploy_time_params") -# on_schema_break = deployment.get("on_schema_break") -# on_update = deployment.get("on_update") -# create_params = deployment["create_params"] -# update_params = deployment["update_params"] -# delete_params = deployment["delete_params"] -# existing_deployments = deployment.get("existing_deployments") -# ignore_cache = deployment.get("ignore_cache", False) -# send_params = { -# k: v -# for k, v in deployment.items() -# if k -# not in { -# "metadata", -# "deploy_time_params", -# "on_schema_break", -# "on_update", -# "create_params", -# "update_params", -# "delete_params", -# "existing_deployments", -# "ignore_cache", -# } -# } - -# create_params["note"] = update_params["note"] = TransactionComposer.arc2_note( -# dapp_name="ALGOKIT_DEPLOYER", data=metadata, format="j" -# ) - -# if existing_deployments and existing_deployments["creator"] != create_params["sender"]: -# raise ValueError("Invalid existingDeployments creator") - -# if not existing_deployments and not self.indexer: -# raise ValueError("Need indexer or existingDeployments") - -# apps = existing_deployments or self.get_creator_apps_by_name(create_params["sender"], ignore_cache) -# existing_app = apps["apps"].get(metadata["name"]) - -# if not existing_app or existing_app["deleted"]: -# return self._create_app(create_params, metadata, send_params) - -# return self._handle_existing_app( -# existing_app, create_params, update_params, delete_params, metadata, on_schema_break, on_update, send_params -# ) - -# def get_creator_apps_by_name(self, creator_address: str | Account, ignore_cache: bool = False): -# if isinstance(creator_address, Account): -# creator_address = creator_address.address - -# if not ignore_cache and creator_address in self._app_lookups: -# return self._app_lookups[creator_address] - -# if not self.indexer: -# raise ValueError("Need indexer for getCreatorApps") - -# app_lookup = {} -# # Implementation of lookup logic here - -# lookup = {"creator": creator_address, "apps": app_lookup} - -# self._app_lookups[creator_address] = lookup -# return lookup - -# def _create_app(self, create_params, metadata, send_params): -# # Implementation of app creation -# pass - -# def _handle_existing_app( -# self, -# existing_app, -# create_params, -# update_params, -# delete_params, -# metadata, -# on_schema_break, -# on_update, -# send_params, -# ): -# # Implementation of handling existing app -# pass - -# def _update_app_lookup(self, sender: str, app_metadata: dict[str, Any]): -# lookup = self._app_lookups.get(sender, {"creator": sender, "apps": {}}) -# lookup["apps"][app_metadata["name"]] = app_metadata -# self._app_lookups[sender] = lookup +import base64 +import dataclasses +import json +import logging +from dataclasses import dataclass +from typing import Any, Literal + +from algosdk.atomic_transaction_composer import TransactionSigner +from algosdk.logic import get_application_address +from algosdk.v2client.indexer import IndexerClient + +from algokit_utils._legacy_v2.deploy import ( + AppDeployMetaData, + AppLookup, + AppMetaData, + OnSchemaBreak, + OnUpdate, +) +from algokit_utils.applications.app_manager import AppManager +from algokit_utils.models.abi import ABIValue +from algokit_utils.transactions.transaction_composer import ( + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppDeleteParams, + AppUpdateMethodCall, +) +from algokit_utils.transactions.transaction_sender import ( + AlgorandClientTransactionSender, + SendAppTransactionResult, +) + +APP_DEPLOY_NOTE_DAPP = "algokit_deployer" + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class DeployAppUpdateParams: + """Parameters for an update transaction in app deployment""" + + sender: str + signer: TransactionSigner | None = None + args: list[bytes] | None = None + note: bytes | None = None + lease: bytes | None = None + rekey_to: str | None = None + boxes: list[tuple[int, bytes]] | None = None + accounts: list[str] | None = None + foreign_apps: list[int] | None = None + foreign_assets: list[int] | None = None + + +@dataclass(frozen=True, kw_only=True) +class DeployAppDeleteParams: + """Parameters for a delete transaction in app deployment""" + + sender: str + signer: TransactionSigner | None = None + note: bytes | None = None + lease: bytes | None = None + rekey_to: str | None = None + boxes: list[tuple[int, bytes]] | None = None + accounts: list[str] | None = None + foreign_apps: list[int] | None = None + foreign_assets: list[int] | None = None + + +@dataclass(frozen=True, kw_only=True) +class AppDeployParams: + """Parameters for deploying an app""" + + metadata: AppDeployMetaData + deploy_time_params: dict[str, Any] | None = None + on_schema_break: Literal["replace", "fail", "append"] | OnSchemaBreak = OnSchemaBreak.Fail + on_update: Literal["update", "replace", "fail", "append"] | OnUpdate = OnUpdate.Fail + create_params: AppCreateParams | AppCreateMethodCall + update_params: DeployAppUpdateParams | AppUpdateMethodCall + delete_params: DeployAppDeleteParams | AppDeleteMethodCall + existing_deployments: AppLookup | None = None + ignore_cache: bool = False + max_fee: int | None = None + max_rounds_to_wait: int | None = None + suppress_log: bool = False + + +@dataclass(frozen=True) +class AppDeploymentResult: + operation_performed: Literal["create", "update", "replace", "nothing"] + app_id: int + app_address: str + transaction: transaction.Transaction | None = None + confirmation: dict[str, Any] | None = None + return_value: ABIValue | None = None + delete_return: ABIValue | None = None + delete_result: dict[str, Any] | None = None + + +class AppDeployer: + """Manages deployment and deployment metadata of applications""" + + def __init__( + self, + app_manager: AppManager, + transaction_sender: AlgorandClientTransactionSender, + indexer: IndexerClient | None = None, + ): + self._app_manager = app_manager + self._transaction_sender = transaction_sender + self._indexer = indexer + self._app_lookups: dict[str, AppLookup] = {} + + def _create_deploy_note(self, metadata: AppDeployMetaData) -> bytes: + note = { + "dapp_name": APP_DEPLOY_NOTE_DAPP, + "format": "j", + "data": metadata.__dict__, + } + return json.dumps(note).encode() + + def deploy(self, deployment: AppDeployParams) -> AppDeploymentResult | SendAppTransactionResult: + # Create new instances with updated notes + note = self._create_deploy_note(deployment.metadata) + create_params = dataclasses.replace(deployment.create_params, note=note) + update_params = dataclasses.replace(deployment.update_params, note=note) + + deployment = dataclasses.replace( + deployment, + create_params=create_params, + update_params=update_params, + ) + + # Validate inputs + if ( + deployment.existing_deployments + and deployment.existing_deployments.creator != deployment.create_params.sender + ): + raise ValueError( + f"Received invalid existingDeployments value for creator " + f"{deployment.existing_deployments.creator} when attempting to deploy " + f"for creator {deployment.create_params.sender}" + ) + + if not deployment.existing_deployments and not self._indexer: + raise ValueError( + "Didn't receive an indexer client when this AppManager was created, " + "but also didn't receive an existingDeployments cache - one of them must be provided" + ) + + # Compile code if needed + approval_program = deployment.create_params.approval_program + clear_program = deployment.create_params.clear_state_program + + if isinstance(approval_program, str): + compiled_approval = self._app_manager.compile_teal_template( + approval_program, + deployment.deploy_time_params, + deployment.metadata.__dict__, + ) + approval_program = compiled_approval.compiled_base64_to_bytes + + if isinstance(clear_program, str): + compiled_clear = self._app_manager.compile_teal_template( + clear_program, + deployment.deploy_time_params, + ) + clear_program = compiled_clear.compiled_base64_to_bytes + + # Get existing app metadata + apps = deployment.existing_deployments or self.get_creator_apps_by_name( + deployment.create_params.sender, + deployment.ignore_cache, + ) + + existing_app = apps.apps.get(deployment.metadata.name) + if not existing_app or existing_app.deleted: + return self._create_app( + deployment=deployment, + approval_program=approval_program, + clear_program=clear_program, + ) + + # Check for changes + existing_app_record = self._app_manager.get_by_id(existing_app.app_id) + + existing_approval = base64.b64encode(existing_app_record.approval_program).decode() + existing_clear = base64.b64encode(existing_app_record.clear_state_program).decode() + + new_approval = base64.b64encode(approval_program).decode() + new_clear = base64.b64encode(clear_program).decode() + + is_update = new_approval != existing_approval or new_clear != existing_clear + is_schema_break = ( + existing_app_record.local_ints + < (deployment.create_params.schema.get("local_ints", 0) if deployment.create_params.schema else 0) + or existing_app_record.global_ints + < (deployment.create_params.schema.get("global_ints", 0) if deployment.create_params.schema else 0) + or existing_app_record.local_byte_slices + < (deployment.create_params.schema.get("local_byte_slices", 0) if deployment.create_params.schema else 0) + or existing_app_record.global_byte_slices + < (deployment.create_params.schema.get("global_byte_slices", 0) if deployment.create_params.schema else 0) + ) + + if is_schema_break: + return self._handle_schema_break( + deployment=deployment, + existing_app=existing_app, + approval_program=approval_program, + clear_program=clear_program, + ) + + if is_update: + return self._handle_update( + deployment=deployment, + existing_app=existing_app, + approval_program=approval_program, + clear_program=clear_program, + ) + + return AppDeploymentResult( + operation_performed="nothing", + app_id=existing_app.app_id, + app_address=existing_app.app_address, + ) + + def _create_app( + self, + deployment: AppDeployParams, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeploymentResult: + """Create a new application""" + + if isinstance(deployment.create_params, AppCreateMethodCall): + create_params = AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + result = self._transaction_sender.app_create_method_call(create_params) + else: + create_params = AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + result = self._transaction_sender.app_create(create_params) + + app_metadata = AppMetaData( + app_id=result.app_id, + app_address=get_application_address(result.app_id), + **deployment.metadata.__dict__, + created_metadata=deployment.metadata, + created_round=result.confirmation["confirmed-round"], + updated_round=result.confirmation["confirmed-round"], + deleted=False, + ) + + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + return AppDeploymentResult( + operation_performed="create", + app_id=result.app_id, + app_address=get_application_address(result.app_id), + transaction=result.transaction, + confirmation=result.confirmation, + return_value=result.return_value, + ) + + def _handle_schema_break( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeploymentResult: + if deployment.on_schema_break in (OnSchemaBreak.Fail, "fail"): + raise ValueError( + "Schema break detected and onSchemaBreak=OnSchemaBreak.Fail, stopping deployment. " + "If you want to try deleting and recreating the app then " + "re-run with onSchemaBreak=OnSchemaBreak.ReplaceApp" + ) + + if deployment.on_schema_break in (OnSchemaBreak.AppendApp, "append"): + return self._create_app(deployment, approval_program, clear_program) + + if existing_app.deletable: + return self._create_and_delete_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not deletable but onSchemaBreak=ReplaceApp, " "cannot delete and recreate app") + + def _handle_update( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeploymentResult: + if deployment.on_update in (OnUpdate.Fail, "fail"): + raise ValueError( + "Update detected and onUpdate=Fail, stopping deployment. " "Try a different onUpdate value to not fail." + ) + + if deployment.on_update in (OnUpdate.AppendApp, "append"): + return self._create_app(deployment, approval_program, clear_program) + + if deployment.on_update in (OnUpdate.UpdateApp, "update"): + if existing_app.updatable: + return self._update_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not updatable but onUpdate=UpdateApp, cannot update app") + + if deployment.on_update in (OnUpdate.ReplaceApp, "replace"): + if existing_app.deletable: + return self._create_and_delete_app(deployment, existing_app, approval_program, clear_program) + else: + raise ValueError("App is not deletable but onUpdate=ReplaceApp, " "cannot delete and recreate app") + + raise ValueError(f"Unsupported onUpdate value: {deployment.on_update}") + + def _create_and_delete_app( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeploymentResult: + composer = self._transaction_sender.new_group() + + # Add create transaction + if isinstance(deployment.create_params, AppCreateMethodCall): + create_params = AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + composer.add_app_create_method_call(create_params) + else: + create_params = AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + composer.add_app_create(create_params) + + # Add delete transaction + if isinstance(deployment.delete_params, AppDeleteMethodCall): + delete_params = AppDeleteMethodCall( + **{ + **deployment.delete_params.__dict__, + "app_id": existing_app.app_id, + } + ) + composer.add_app_delete_method_call(delete_params) + else: + delete_params = AppDeleteParams( + **{ + **deployment.delete_params.__dict__, + "app_id": existing_app.app_id, + } + ) + composer.add_app_delete(delete_params) + + result = composer.send() + + app_id = int(result.confirmations[0]["application-index"]) + app_metadata = AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + **deployment.metadata.__dict__, + created_metadata=deployment.metadata, + created_round=result.confirmations[0]["confirmed-round"], + updated_round=result.confirmations[0]["confirmed-round"], + deleted=False, + ) + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + return AppDeploymentResult( + operation_performed="replace", + app_id=app_id, + app_address=get_application_address(app_id), + transaction=result.transactions[0], + confirmation=result.confirmations[0], + return_value=result.returns[0] if result.returns else None, + delete_return=result.returns[-1] if len(result.returns) > 1 else None, + delete_result={ + "transaction": result.transactions[-1], + "confirmation": result.confirmations[-1], + }, + ) + + def _update_app_lookup(self, sender: str, app_metadata: AppMetaData) -> None: + """Update the app lookup cache""" + + lookup = self._app_lookups.get(sender) + if not lookup: + self._app_lookups[sender] = AppLookup( + creator=sender, + apps={app_metadata.name: app_metadata}, + ) + else: + lookup.apps[app_metadata.name] = app_metadata + + def get_creator_apps_by_name(self, creator_address: str, ignore_cache: bool = False) -> AppLookup: + """Get apps created by an account""" + + if not ignore_cache and creator_address in self._app_lookups: + return self._app_lookups[creator_address] + + if not self._indexer: + raise ValueError( + "Didn't receive an indexer client when this AppManager was created, " + "but received a call to get_creator_apps" + ) + + app_lookup: dict[str, AppMetaData] = {} + + # Get all apps created by account + created_apps = self._indexer.search_applications(creator=creator_address) + + for app in created_apps["applications"]: + app_id = app["id"] + + # Get creation transaction + creation_txns = self._indexer.search_transactions( + application_id=app_id, + min_round=app["created-at-round"], + address=creator_address, + address_role="sender", + note_prefix=base64.b64encode(APP_DEPLOY_NOTE_DAPP.encode()), + limit=1, + ) + + if not creation_txns["transactions"]: + continue + + creation_txn = creation_txns["transactions"][0] + + try: + note = base64.b64decode(creation_txn["note"]).decode() + if not note.startswith(f"{APP_DEPLOY_NOTE_DAPP}:j"): + continue + + metadata = json.loads(note[len(APP_DEPLOY_NOTE_DAPP) + 2 :]) + + if metadata.get("name"): + app_lookup[metadata["name"]] = AppMetaData( + app_id=app_id, + app_address=get_application_address(app_id), + created_metadata=metadata, + created_round=creation_txn["confirmed-round"], + **metadata, + updated_round=creation_txn["confirmed-round"], + deleted=app.get("deleted", False), + ) + except Exception as e: + logger.warning(f"Error processing app {app_id} for creator {creator_address}: {e}") + continue + + lookup = AppLookup(creator=creator_address, apps=app_lookup) + self._app_lookups[creator_address] = lookup + return lookup diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index b800e3e..676b34f 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -1,85 +1,484 @@ -# from dataclasses import dataclass -# from typing import Any - -# from algokit_utils._legacy_v2.application_client import ApplicationClient -# from algokit_utils._legacy_v2.application_specification import ApplicationSpecification - - -# @dataclass -# class AppFactoryParams: -# app_spec: ApplicationSpecification | str -# algorand: Any # AlgorandClient -# app_name: str | None = None -# default_sender: str | None = None -# default_signer: Any | None = None # TransactionSigner -# version: str | None = None -# updatable: bool | None = None -# deletable: bool | None = None -# deploy_time_params: dict[str, Any] | None = None - - -# class AppFactory: -# def __init__(self, params: AppFactoryParams): -# self._app_spec = ApplicationClient.normalise_app_spec(params.app_spec) -# self._app_name = params.app_name or self._app_spec.name -# self._algorand = params.algorand -# self._version = params.version or "1.0" -# self._default_sender = params.default_sender -# self._default_signer = params.default_signer -# self._deploy_time_params = params.deploy_time_params -# self._updatable = params.updatable -# self._deletable = params.deletable -# self._approval_source_map = None -# self._clear_source_map = None - -# @property -# def app_name(self): -# return self._app_name - -# @property -# def app_spec(self): -# return self._app_spec - -# @property -# def algorand(self): -# return self._algorand - -# def get_app_client_by_id(self, params: dict[str, Any]): -# return ApplicationClient( -# algod_client=self._algorand, -# app_spec=self._app_spec, -# app_id=params.get("app_id", 0), -# app_name=params.get("app_name", self._app_name), -# default_sender=params.get("default_sender", self._default_sender), -# default_signer=params.get("default_signer", self._default_signer), -# template_values=params.get("template_values"), -# ) - -# def get_app_client_by_creator_and_name(self, params: dict[str, Any]): -# return ApplicationClient.from_creator_and_name( -# algod_client=self._algorand, -# app_spec=self._app_spec, -# creator=params["creator"], -# indexer_client=params.get("indexer_client"), -# app_name=params.get("app_name", self._app_name), -# default_sender=params.get("default_sender", self._default_sender), -# template_values=params.get("template_values"), -# ) - -# def deploy(self, params: dict[str, Any]): -# updatable = params.get("updatable", self._updatable) -# deletable = params.get("deletable", self._deletable) -# deploy_time_params = params.get("deploy_time_params", self._deploy_time_params) - -# app_client = self.get_app_client_by_id({}) -# return app_client.deploy( -# version=params.get("version"), -# signer=params.get("signer"), -# sender=params.get("sender"), -# allow_update=updatable, -# allow_delete=deletable, -# template_values=deploy_time_params, -# create_args=params.get("create_args"), -# update_args=params.get("update_args"), -# delete_args=params.get("delete_args"), -# ) +import base64 +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast + +from algosdk import transaction +from algosdk.abi import Method +from algosdk.atomic_transaction_composer import TransactionSigner + +from algokit_utils._legacy_v2.deploy import ( + AppDeployMetaData, + AppLookup, + OnSchemaBreak, + OnUpdate, +) +from algokit_utils.applications.app_client import ( + AppClient, + AppClientBareCallParams, + AppClientCompilationParams, + AppClientMethodCallParams, + AppClientParams, + ExposedLogicErrorDetails, +) +from algokit_utils.applications.app_manager import TealTemplateParams +from algokit_utils.applications.utils import ( + get_abi_decoded_value, + get_abi_struct_from_abi_tuple, + get_abi_tuple_from_abi_struct, + get_arc56_method, +) +from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, Arc56Contract +from algokit_utils.protocols.application import AlgorandClientProtocol +from algokit_utils.transactions.transaction_composer import AppCreateParams +from algokit_utils.transactions.transaction_sender import SendAppTransactionResult + +if TYPE_CHECKING: + from algosdk.source_map import SourceMap + + +T = TypeVar("T") + + +class ParamsMethodsProtocol(Protocol): + def create(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... + def deploy_update(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... + def deploy_delete(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... + + bare: dict[str, Callable[[AppClientBareCallParams | None], dict[str, Any]]] + + +@dataclass(kw_only=True) +class AppFactoryParams: + app_spec: Arc56Contract | str + algorand: AlgorandClientProtocol + app_name: str | None = None + default_sender: str | bytes | None = None + default_signer: TransactionSigner | None = None + version: str | None = None + updatable: bool | None = None + deletable: bool | None = None + deploy_time_params: TealTemplateParams | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateParams(AppClientBareCallParams, AppClientCompilationParams): + on_complete: transaction.OnComplete | None = None + schema: dict[str, int] | None = None + extra_program_pages: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateMethodCallParams(AppClientMethodCallParams, AppClientCompilationParams): + on_complete: transaction.OnComplete | None = None + schema: dict[str, int] | None = None + extra_program_pages: int | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppFactoryDeployParams: + version: str | None = None + signer: TransactionSigner | None = None + sender: str | None = None + allow_update: bool | None = None + allow_delete: bool | None = None + on_update: OnUpdate = OnUpdate.Fail + on_schema_break: OnSchemaBreak = OnSchemaBreak.Fail + template_values: TealTemplateParams | None = None + create_args: AppClientMethodCallParams | AppClientBareCallParams | None = None + update_args: AppClientMethodCallParams | AppClientBareCallParams | None = None + delete_args: AppClientMethodCallParams | AppClientBareCallParams | None = None + existing_deployments: AppLookup | None = None + ignore_cache: bool = False + updatable: bool | None = None + deletable: bool | None = None + app_name: str | None = None + + +class AppFactory: + def __init__(self, params: AppFactoryParams) -> None: + self._app_spec = AppClient.normalise_app_spec(params.app_spec) + self._app_name = params.app_name or self._app_spec.name + self._algorand = params.algorand + self._version = params.version or "1.0" + self._default_sender = params.default_sender + self._default_signer = params.default_signer + self._deploy_time_params = params.deploy_time_params + self._updatable = params.updatable + self._deletable = params.deletable + self._approval_source_map: SourceMap | None = None + self._clear_source_map: SourceMap | None = None + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def algorand(self) -> AlgorandClientProtocol: + return self._algorand + + def get_app_client_by_id(self, params: AppClientParams) -> AppClient: + return AppClient( + AppClientParams( + app_id=params.app_id, + algorand=self._algorand, + app_spec=self._app_spec, + app_name=params.app_name or self._app_name, + default_sender=params.default_sender or self._default_sender, + default_signer=params.default_signer or self._default_signer, + approval_source_map=params.approval_source_map or self._approval_source_map, + clear_source_map=params.clear_source_map or self._clear_source_map, + ) + ) + + def create_bare(self, params: AppFactoryCreateParams | None = None) -> tuple[AppClient, SendAppTransactionResult]: + updatable = params.updatable if params and params.updatable is not None else self._updatable + deletable = params.deletable if params and params.deletable is not None else self._deletable + deploy_time_params = ( + params.deploy_time_params if params and params.deploy_time_params is not None else self._deploy_time_params + ) + + compiled = self.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + result = self._handle_call_errors( + lambda: self._algorand.send.app_create( + self._get_bare_params( + params=AppCreateParams( + **(params.__dict__ if params else {}), + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, + ), + on_complete=params.on_complete if params else transaction.OnComplete.NoOpOC, + ) + ) + ) + + return ( + self.get_app_client_by_id( + AppClientParams(app_id=result.app_id, app_spec=self._app_spec, algorand=self._algorand) + ), + SendAppTransactionResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), + ) + + def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, SendAppTransactionResult]: + updatable = params.updatable if params.updatable is not None else self._updatable + deletable = params.deletable if params.deletable is not None else self._deletable + deploy_time_params = ( + params.deploy_time_params if params.deploy_time_params is not None else self._deploy_time_params + ) + + compiled = self.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + result = self._handle_call_errors( + lambda: self._get_arc56_return_value( + self._algorand.send.app_create_method_call( + self._get_abi_params( + { + **params.__dict__, + "updatable": updatable, + "deletable": deletable, + "deploy_time_params": deploy_time_params, + }, + params.on_complete or transaction.OnComplete.NoOpOC, + ) + ), + get_arc56_method(params.method, self._app_spec), + ) + ) + + return ( + self.get_app_client_by_id( + AppClientParams(app_id=result.app_id, app_spec=self._app_spec, algorand=self._algorand) + ), + SendAppTransactionResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), + ) + + def deploy(self, params: AppFactoryDeployParams) -> tuple[AppClient, SendAppTransactionResult]: + updatable = params.updatable if params.updatable is not None else self._updatable + deletable = params.deletable if params.deletable is not None else self._deletable + deploy_time_params = params.template_values + + compiled = self.compile( + AppClientCompilationParams( + deploy_time_params=deploy_time_params, + updatable=updatable, + deletable=deletable, + ) + ) + + deploy_result = self._algorand.app_deployer.deploy( + { + **params.__dict__, + "create_params": ( + self._get_abi_params(params.create_args.__dict__, transaction.OnComplete.NoOpOC) + if params.create_args and hasattr(params.create_args, "method") + else self._get_bare_params( + params.create_args.__dict__ if params.create_args else {}, + transaction.OnComplete.NoOpOC, + ) + ) + if params.create_args + else None, + "update_params": ( + self._get_abi_params(params.update_args.__dict__, transaction.OnComplete.UpdateApplicationOC) + if params.update_args and hasattr(params.update_args, "method") + else self._get_bare_params( + params.update_args.__dict__ if params.update_args else {}, + transaction.OnComplete.UpdateApplicationOC, + ) + ) + if params.update_args + else None, + "delete_params": ( + self._get_abi_params(params.delete_args.__dict__, transaction.OnComplete.DeleteApplicationOC) + if params.delete_args and hasattr(params.delete_args, "method") + else self._get_bare_params( + params.delete_args.__dict__ if params.delete_args else {}, + transaction.OnComplete.DeleteApplicationOC, + ) + ) + if params.delete_args + else None, + "metadata": AppDeployMetaData( + name=params.app_name or self._app_name, + version=self._version, + updatable=updatable, + deletable=deletable, + ), + } + ) + + app_client = self.get_app_client_by_id( + AppClientParams( + app_id=deploy_result.app_id, app_name=params.app_name, app_spec=self._app_spec, algorand=self._algorand + ) + ) + + result = {**deploy_result.__dict__, **(compiled.__dict__ if compiled else {})} + + return_value = None + if hasattr(result, "return"): + if result["operationPerformed"] == "update": + if params.update_args and hasattr(params.update_args, "method"): + return_value = self._get_arc56_return_value( + result["return"], + get_arc56_method(params.update_args.method, self._app_spec), + ) + elif params.create_args and hasattr(params.create_args, "method"): + return_value = self._get_arc56_return_value( + result["return"], + get_arc56_method(params.create_args.method, self._app_spec), + ) + + delete_return = None + if hasattr(result, "deleteReturn") and params.delete_args and hasattr(params.delete_args, "method"): + delete_return = self._get_arc56_return_value( + result["deleteReturn"], + get_arc56_method(params.delete_args.method, self._app_spec), + ) + + result["return"] = return_value + result["deleteReturn"] = delete_return + + return app_client, SendAppTransactionResult(**result) + + def compile(self, compilation: AppClientCompilationParams | None = None) -> Any: + result = AppClient.compile( + self._app_spec, + self._algorand.app, + cast(TealTemplateParams | None, compilation.deploy_time_params if compilation else None), + ) + + if result.compiled_approval: + self._approval_source_map = result.compiled_approval.source_map + if result.compiled_clear: + self._clear_source_map = result.compiled_clear.source_map + + return result + + def _handle_call_errors(self, call: Callable[[], T]) -> T: + try: + return call() + except Exception as e: + raise self.expose_logic_error(e) from None + + def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: + return AppClient.expose_logic_error_static( + e, + self._app_spec, + ExposedLogicErrorDetails( + is_clear_state_program=is_clear_state_program, + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + program=None, + approval_source_info=None, + clear_source_info=None, + ), + ) + + def _get_arc56_return_value(self, return_value: Any, method: Method) -> Any: + if method.returns.type == "void" or return_value is None: + return None + + if hasattr(return_value, "decode_error"): + raise ValueError(return_value["decode_error"]) + + raw_value = return_value.get("raw_return_value") + + if method.returns.type == "AVMBytes": + return raw_value + if method.returns.type == "AVMString" and raw_value: + return raw_value.decode("utf-8") + if method.returns.type == "AVMUint64" and raw_value: + return get_abi_decoded_value(raw_value, "uint64", self._app_spec.structs) + + if method.returns.struct and method.returns.struct in self._app_spec.structs: + return_tuple = return_value.get("return_value") + return get_abi_struct_from_abi_tuple( + return_tuple, self._app_spec.structs[method.returns.struct], self._app_spec.structs + ) + + return return_value.get("return_value") + + def _get_deploy_time_control(self, control: str) -> bool | None: + approval = ( + base64.b64decode(self._app_spec.source["approval"]).decode("utf-8") + if self._app_spec.source and "approval" in self._app_spec.source + else None + ) + + template_name = UPDATABLE_TEMPLATE_NAME if control == "updatable" else DELETABLE_TEMPLATE_NAME + if not approval or template_name not in approval: + return None + + on_complete = "UpdateApplication" if control == "updatable" else "DeleteApplication" + return on_complete in self._app_spec.bare_actions.get("call", []) or any( + m.actions.call and on_complete in m.actions.call for m in self._app_spec.methods + ) + + @property + def params(self) -> ParamsMethodsProtocol: + return cast(ParamsMethodsProtocol, self._get_params_methods()) + + def _get_params_methods(self) -> dict[str, Any]: + return { + "create": lambda params: self._get_abi_params( + { + **params.__dict__, + "deploy_time_params": params.deploy_time_params or self._deploy_time_params, + "schema": params.schema + or { + "global_bytes": self._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._app_spec.state.schemas["local"]["ints"], + }, + "approval_program": self.compile(params).approval_program, + "clear_state_program": self.compile(params).clear_state_program, + }, + params.on_complete or transaction.OnComplete.NoOpOC, + ), + "deploy_update": lambda params: self._get_abi_params( + params.__dict__, transaction.OnComplete.UpdateApplicationOC + ), + "deploy_delete": lambda params: self._get_abi_params( + params.__dict__, transaction.OnComplete.DeleteApplicationOC + ), + "bare": { + "create": lambda params: self._get_bare_params( + { + **(params.__dict__ if params else {}), + "deploy_time_params": (params.deploy_time_params if params else None) + or self._deploy_time_params, + "schema": (params.schema if params else None) + or { + "global_bytes": self._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._app_spec.state.schemas["local"]["ints"], + }, + **(self.compile(params).__dict__ if params else {}), + }, + (params.on_complete if params else None) or transaction.OnComplete.NoOpOC, + ), + "deploy_update": lambda params: self._get_bare_params( + params.__dict__ if params else {}, transaction.OnComplete.UpdateApplicationOC + ), + "deploy_delete": lambda params: self._get_bare_params( + params.__dict__ if params else {}, transaction.OnComplete.DeleteApplicationOC + ), + }, + } + + def _get_bare_params(self, params: dict[str, Any], on_complete: transaction.OnComplete) -> dict[str, Any]: + return { + **params, + "sender": self._get_sender(params.get("sender")), + "on_complete": on_complete, + } + + def _get_abi_params(self, params: dict[str, Any], on_complete: transaction.OnComplete) -> dict[str, Any]: + return { + **params, + "sender": self._get_sender(params.get("sender")), + "method": get_arc56_method(params["method"], self._app_spec), + "args": self._get_create_abi_args_with_default_values(params["method"], params.get("args")), + "on_complete": on_complete, + } + + def _get_sender(self, sender: str | bytes | None) -> str: + if not sender and not self._default_sender: + raise Exception( + f"No sender provided and no default sender present in app client for call to app {self._app_name}" + ) + return str(sender or self._default_sender) + + def _get_create_abi_args_with_default_values( + self, method_name_or_signature: str, args: list[Any] | None + ) -> list[Any]: + method = get_arc56_method(method_name_or_signature, self._app_spec) + result = [] + + for i, method_arg in enumerate(method.args): + arg_value = args[i] if args and i < len(args) else None + + if arg_value is not None: + if hasattr(method_arg, "struct") and method_arg.struct and isinstance(arg_value, dict): + arg_value = get_abi_tuple_from_abi_struct( + arg_value, self._app_spec.structs[method_arg.struct], self._app_spec.structs + ) + result.append(arg_value) + continue + + if hasattr(method_arg, "default_value") and method_arg.default_value: + if method_arg.default_value.source == "literal": + value_raw = base64.b64decode(method_arg.default_value.data) + value_type = method_arg.default_value.type or str(method_arg.type) + result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) + else: + raise ValueError( + f"Can't provide default value for {method_arg.default_value.source} for a contract creation call" + ) + else: + raise ValueError( + f"No value provided for required argument {method_arg.name or f'arg{i+1}'} in call to method {method.name}" + ) + + return result diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 7feb461..6066227 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -7,6 +7,7 @@ from typing_extensions import Self from algokit_utils.accounts.account_manager import AccountManager +from algokit_utils.applications.app_deployer import AppDeployer from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager @@ -63,6 +64,9 @@ def __init__(self, config: AlgoClientConfigs | AlgoSdkClients): app_manager=self._app_manager, algod_client=self._client_manager.algod, ) + self._app_deployer: AppDeployer = AppDeployer( + self._app_manager, self._transaction_sender, self._client_manager.indexer_if_present + ) self._transaction_creator = AlgorandClientTransactionCreator( new_group=lambda: self.new_group(), ) @@ -167,9 +171,9 @@ def app(self) -> AppManager: return self._app_manager @property - def app_deployer(self) -> AppManager: + def app_deployer(self) -> AppDeployer: """Get or create applications.""" - return self._app_manager + return self._app_deployer @property def send(self) -> AlgorandClientTransactionSender: diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index 4e5e517..fecc383 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -19,6 +19,7 @@ class UpdatableConfig: trace_all (bool): Indicates whether to trace all operations. trace_buffer_size_mb (int): The size of the trace buffer in megabytes. max_search_depth (int): The maximum depth to search for a specific file. + populate_app_call_resources (bool): Indicates whether to populate app call resources. """ def __init__(self) -> None: @@ -27,6 +28,7 @@ def __init__(self) -> None: self._trace_all: bool = False self._trace_buffer_size_mb: int | float = 256 # megabytes self._max_search_depth: int = 10 + self._populate_app_call_resources: bool = False self._configure_project_root() def _configure_project_root(self) -> None: @@ -59,6 +61,10 @@ def trace_buffer_size_mb(self) -> int | float: """Returns the size of the trace buffer in megabytes.""" return self._trace_buffer_size_mb + @property + def populate_app_call_resource(self) -> bool: + return self._populate_app_call_resources + def with_debug(self, func: Callable[[], str | None]) -> None: """Executes a function with debug mode temporarily enabled.""" original_debug = self._debug diff --git a/src/algokit_utils/protocols/application.py b/src/algokit_utils/protocols/application.py index 7298fc8..a1ba596 100644 --- a/src/algokit_utils/protocols/application.py +++ b/src/algokit_utils/protocols/application.py @@ -5,6 +5,8 @@ from typing_extensions import runtime_checkable +from algokit_utils.applications.app_deployer import AppDeployer + if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient @@ -29,7 +31,7 @@ class AlgorandClientProtocol(Protocol): def app(self) -> AppManager: ... @property - def app_deployer(self) -> AppManager: ... + def app_deployer(self) -> AppDeployer: ... @property def send(self) -> AlgorandClientTransactionSender: ... diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 5ab292e..dac07e1 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -22,6 +22,7 @@ from algokit_utils.applications.app_manager import AppManager from algokit_utils.config import config from algokit_utils.models.transaction import SendParams +from algokit_utils.transactions.utils import populate_app_call_resources if TYPE_CHECKING: from collections.abc import Callable @@ -529,7 +530,7 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 max_rounds_to_wait: int | None = 5, skip_waiting: bool = False, suppress_log: bool = False, - populate_resources: bool | None = None, # TODO: implement/clarify # noqa: ARG001 + populate_resources: bool | None = None, # TODO: implement/clarify ) -> SendAtomicTransactionComposerResults: """Send an AtomicTransactionComposer transaction group @@ -551,6 +552,14 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 try: # Build transactions transactions_with_signer = atc.build_group() + + if ( + populate_resources + or config.populate_app_call_resource + and any(isinstance(t.txn, algosdk.transaction.ApplicationCallTxn) for t in transactions_with_signer) + ): + atc = populate_app_call_resources(atc, algod) + transactions_to_send = [t.txn for t in transactions_with_signer] # Get group ID if multiple transactions diff --git a/src/algokit_utils/transactions/utils.py b/src/algokit_utils/transactions/utils.py new file mode 100644 index 0000000..75cc69b --- /dev/null +++ b/src/algokit_utils/transactions/utils.py @@ -0,0 +1,271 @@ +from typing import Any, cast + +from algosdk import logic, transaction +from algosdk.atomic_transaction_composer import AtomicTransactionComposer, EmptySigner, TransactionWithSigner +from algosdk.box_reference import BoxReference +from algosdk.error import AtomicTransactionComposerError +from algosdk.v2client.algod import AlgodClient +from algosdk.v2client.models import SimulateRequest + +# Constants +MAX_APP_CALL_ACCOUNT_REFERENCES = 4 +MAX_APP_CALL_FOREIGN_REFERENCES = 8 + + +def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer: + """ + Populate application call resources based on simulation results. + """ + # Get unnamed resources from simulation + unnamed_resources = get_unnamed_app_call_resources_accessed(atc, algod) + group = atc.build_group() + + # Process transaction-level resources + for i, txn_resources in enumerate(unnamed_resources["txns"]): + if not txn_resources or not isinstance(group[i].txn, transaction.ApplicationCallTxn): + continue + + # Validate no unexpected resources + if txn_resources.get("boxes") or txn_resources.get("extraBoxRefs"): + raise ValueError("Unexpected boxes at the transaction level") + if txn_resources.get("appLocals"): + raise ValueError("Unexpected app local at the transaction level") + if txn_resources.get("assetHoldings"): + raise ValueError("Unexpected asset holding at the transaction level") + + # Update application call fields + app_txn = cast(transaction.ApplicationCallTxn, group[i].txn) + accounts = list(getattr(app_txn, "accounts", []) or []) + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + boxes = list(getattr(app_txn, "boxes", []) or []) + + # Add new resources + accounts.extend(txn_resources.get("accounts", [])) + foreign_apps.extend(txn_resources.get("apps", [])) + foreign_assets.extend(txn_resources.get("assets", [])) + boxes.extend(txn_resources.get("boxes", [])) + + # Validate limits + if len(accounts) > MAX_APP_CALL_ACCOUNT_REFERENCES: + raise ValueError( + f"Account reference limit of {MAX_APP_CALL_ACCOUNT_REFERENCES} exceeded in transaction {i}" + ) + + total_refs = len(accounts) + len(foreign_assets) + len(foreign_apps) + len(boxes) + if total_refs > MAX_APP_CALL_FOREIGN_REFERENCES: + raise ValueError( + f"Resource reference limit of {MAX_APP_CALL_FOREIGN_REFERENCES} exceeded in transaction {i}" + ) + + # Update transaction + app_txn.accounts = accounts + app_txn.foreign_apps = foreign_apps + app_txn.foreign_assets = foreign_assets + app_txn.boxes = boxes + + def populate_group_resource( + txns: list[TransactionWithSigner], reference: str | BoxReference | dict[str, Any] | int, ref_type: str + ) -> None: + """Helper function to populate group-level resources""" + + def is_appl_below_limit(t: TransactionWithSigner) -> bool: + if not isinstance(t.txn, transaction.ApplicationCallTxn): + return False + + app_txn = t.txn + accounts = len(app_txn.accounts or []) + assets = len(app_txn.foreign_assets or []) + apps = len(app_txn.foreign_apps or []) + boxes = len(app_txn.boxes or []) + + return accounts + assets + apps + boxes < MAX_APP_CALL_FOREIGN_REFERENCES + + # Handle asset holding and app local references + if ref_type in ("assetHolding", "appLocal"): + ref_dict = cast(dict[str, Any], reference) + account = ref_dict["account"] + + # Try to find transaction with account already available + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and ( + account in (getattr(t.txn, "accounts", []) or []) + or account + in ( + logic.get_application_address(app_id) + for app_id in (getattr(t.txn, "foreign_apps", []) or []) + ) + or any(account in str(v) for v in t.txn.__dict__.values()) + ) + ), + -1, + ) + + if txn_idx >= 0: + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + if ref_type == "assetHolding": + asset_id = ref_dict["asset"] + app_txn.foreign_assets = [*list(getattr(app_txn, "foreign_assets", []) or []), asset_id] + else: + app_id = ref_dict["app"] + app_txn.foreign_apps = [*list(getattr(app_txn, "foreign_apps", []) or []), app_id] + return + + # Find available transaction for the resource + txn_idx = next( + ( + i + for i, t in enumerate(txns) + if is_appl_below_limit(t) + and isinstance(t.txn, transaction.ApplicationCallTxn) + and ( + len(getattr(t.txn, "accounts", []) or []) < MAX_APP_CALL_ACCOUNT_REFERENCES + if ref_type == "account" + else True + ) + ), + -1, + ) + + if txn_idx == -1: + raise ValueError("No more transactions below reference limit. Add another app call to the group.") + + app_txn = cast(transaction.ApplicationCallTxn, txns[txn_idx].txn) + + # Add resource based on type + if ref_type == "account": + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(cast(str, reference)) + app_txn.accounts = accounts + elif ref_type == "app": + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(int(cast(str | int, reference))) + app_txn.foreign_apps = foreign_apps + elif ref_type == "box": + box_ref = cast(BoxReference, reference) + boxes = list(getattr(app_txn, "boxes", []) or []) + boxes.append(box_ref) + app_txn.boxes = boxes + if box_ref.app_index != 0: + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(box_ref.app_index) + app_txn.foreign_apps = foreign_apps + elif ref_type == "asset": + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + foreign_assets.append(int(cast(str | int, reference))) + app_txn.foreign_assets = foreign_assets + elif ref_type == "assetHolding": + ref_dict = cast(dict[str, Any], reference) + foreign_assets = list(getattr(app_txn, "foreign_assets", []) or []) + foreign_assets.append(ref_dict["asset"]) + app_txn.foreign_assets = foreign_assets + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(ref_dict["account"]) + app_txn.accounts = accounts + elif ref_type == "appLocal": + ref_dict = cast(dict[str, Any], reference) + foreign_apps = list(getattr(app_txn, "foreign_apps", []) or []) + foreign_apps.append(ref_dict["app"]) + app_txn.foreign_apps = foreign_apps + accounts = list(getattr(app_txn, "accounts", []) or []) + accounts.append(ref_dict["account"]) + app_txn.accounts = accounts + + # Process group-level resources + group_resources = unnamed_resources["group"] + if group_resources: + # Handle cross-reference resources first + for app_local in group_resources.get("appLocals", []): + populate_group_resource(group, app_local, "appLocal") + # Remove processed resources + if "accounts" in group_resources: + group_resources["accounts"] = [ + acc for acc in group_resources["accounts"] if acc != app_local["account"] + ] + if "apps" in group_resources: + group_resources["apps"] = [app for app in group_resources["apps"] if int(app) != int(app_local["app"])] + + for asset_holding in group_resources.get("assetHoldings", []): + populate_group_resource(group, asset_holding, "assetHolding") + # Remove processed resources + if "accounts" in group_resources: + group_resources["accounts"] = [ + acc for acc in group_resources["accounts"] if acc != asset_holding["account"] + ] + if "assets" in group_resources: + group_resources["assets"] = [ + asset for asset in group_resources["assets"] if int(asset) != int(asset_holding["asset"]) + ] + + # Handle remaining resources + for account in group_resources.get("accounts", []): + populate_group_resource(group, account, "account") + + for box in group_resources.get("boxes", []): + populate_group_resource(group, box, "box") + if "apps" in group_resources: + group_resources["apps"] = [app for app in group_resources["apps"] if int(app) != int(box.app_index)] + + for asset in group_resources.get("assets", []): + populate_group_resource(group, asset, "asset") + + for app in group_resources.get("apps", []): + populate_group_resource(group, app, "app") + + # Handle extra box references + extra_box_refs = group_resources.get("extraBoxRefs", 0) + for _ in range(extra_box_refs): + empty_box = BoxReference(0, b"") + populate_group_resource(group, empty_box, "box") + + # Create new ATC with updated transactions + new_atc = AtomicTransactionComposer() + for txn_with_signer in group: + txn_with_signer.txn.group = None + new_atc.add_transaction(txn_with_signer) + + # Copy method calls + new_atc.method_dict = atc.method_dict.copy() + + return new_atc + + +def get_unnamed_app_call_resources_accessed(atc: AtomicTransactionComposer, algod: AlgodClient) -> dict[str, Any]: + """Get unnamed resources accessed by application calls in an atomic transaction group.""" + # Create simulation request with required flags + simulate_request = SimulateRequest( + txn_groups=[], allow_unnamed_resources=True, allow_empty_signatures=True, extra_opcode_budget=0 + ) + + # Create empty signer + null_signer = EmptySigner() + + # Clone the ATC and replace signers + empty_signer_atc = atc.clone() + for txn in empty_signer_atc.txn_list: + txn.signer = null_signer + + # Run simulation + result = empty_signer_atc.simulate(algod, simulate_request) + + # Get first group response + group_response = result.simulate_response["txn-groups"][0] + + # Check for simulation failure + if group_response.get("failure-message"): + failed_at = group_response.get("failed-at", [0])[0] + raise AtomicTransactionComposerError( + f"Error during resource population simulation in transaction {failed_at}: " + f"{group_response['failure-message']}" + ) + + # Return resources accessed at group and transaction level + return { + "group": group_response.get("unnamed-resources-accessed", {}), + "txns": [txn.get("unnamed-resources-accessed", {}) for txn in group_response.get("txn-results", [])], + } From 6d27062362c93885ee5cf4d9419bebf0dec50235 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Thu, 5 Dec 2024 12:39:55 +0100 Subject: [PATCH 12/19] chore: adding draft deprecation warnings; wip --- docs/markdown/index.md | 69 +- .../_legacy_v2/_ensure_funded.py | 2 + src/algokit_utils/_legacy_v2/account.py | 8 + .../_legacy_v2/application_client.py | 3 + .../_legacy_v2/application_specification.py | 2 + src/algokit_utils/_legacy_v2/asset.py | 3 + src/algokit_utils/_legacy_v2/deploy.py | 6 +- src/algokit_utils/_legacy_v2/logic_error.py | 4 +- src/algokit_utils/_legacy_v2/models.py | 8 +- .../_legacy_v2/network_clients.py | 10 + src/algokit_utils/accounts/account_manager.py | 120 ++- src/algokit_utils/applications/app_client.py | 110 +-- .../applications/app_deployer.py | 116 ++- src/algokit_utils/applications/app_factory.py | 800 +++++++++++------- src/algokit_utils/applications/app_manager.py | 8 +- src/algokit_utils/assets/asset_manager.py | 6 +- src/algokit_utils/clients/client_manager.py | 56 +- src/algokit_utils/models/application.py | 10 +- .../transactions/transaction_composer.py | 60 +- .../transactions/transaction_sender.py | 30 +- tests/applications/test_app_factory.py | 210 +++-- .../transactions/test_transaction_creator.py | 2 +- 22 files changed, 1046 insertions(+), 597 deletions(-) diff --git a/docs/markdown/index.md b/docs/markdown/index.md index a3fa051..7197256 100644 --- a/docs/markdown/index.md +++ b/docs/markdown/index.md @@ -7,46 +7,47 @@ The goal of this library is to provide intuitive, productive utility functions t Largely these functions wrap the underlying Algorand SDK, but provide a higher level interface with sensible defaults and capabilities for common tasks. #### NOTE + If you prefer TypeScript there’s an equivalent [TypeScript utility library](https://github.com/algorandfoundation/algokit-utils-ts). [Core principles]() | [Installation]() | [Usage]() | [Capabilities]() | [Reference docs]() # Contents -* [Account management](capabilities/account.md) - * [`Account`](capabilities/account.md#account) -* [Client management](capabilities/client.md) - * [Network configuration](capabilities/client.md#network-configuration) - * [Clients](capabilities/client.md#clients) -* [App client](capabilities/app-client.md) - * [Design](capabilities/app-client.md#design) - * [Creating an application client](capabilities/app-client.md#creating-an-application-client) - * [Calling methods on the app](capabilities/app-client.md#calling-methods-on-the-app) - * [Composing calls](capabilities/app-client.md#composing-calls) - * [Reading state](capabilities/app-client.md#reading-state) - * [Handling logic errors and diagnosing errors](capabilities/app-client.md#handling-logic-errors-and-diagnosing-errors) -* [App deployment](capabilities/app-deploy.md) - * [Design](capabilities/app-deploy.md#design) - * [Finding apps by creator](capabilities/app-deploy.md#finding-apps-by-creator) - * [Deploying an application](capabilities/app-deploy.md#deploying-an-application) -* [Algo transfers](capabilities/transfer.md) - * [Transferring Algos](capabilities/transfer.md#transferring-algos) - * [Ensuring minimum Algos](capabilities/transfer.md#ensuring-minimum-algos) - * [Transfering Assets](capabilities/transfer.md#transfering-assets) - * [Dispenser](capabilities/transfer.md#dispenser) -* [TestNet Dispenser Client](capabilities/dispenser-client.md) - * [Creating a Dispenser Client](capabilities/dispenser-client.md#creating-a-dispenser-client) - * [Funding an Account](capabilities/dispenser-client.md#funding-an-account) - * [Registering a Refund](capabilities/dispenser-client.md#registering-a-refund) - * [Getting Current Limit](capabilities/dispenser-client.md#getting-current-limit) - * [Error Handling](capabilities/dispenser-client.md#error-handling) -* [Debugger](capabilities/debugger.md) - * [Configuration](capabilities/debugger.md#configuration) - * [Debugging Utilities](capabilities/debugger.md#debugging-utilities) -* [`algokit_utils`](apidocs/algokit_utils/algokit_utils.md) - * [Data](apidocs/algokit_utils/algokit_utils.md#data) - * [Classes](apidocs/algokit_utils/algokit_utils.md#classes) - * [Functions](apidocs/algokit_utils/algokit_utils.md#functions) +- [Account management](capabilities/account.md) + - [`Account`](capabilities/account.md#account) +- [Client management](capabilities/client.md) + - [Network configuration](capabilities/client.md#network-configuration) + - [Clients](capabilities/client.md#clients) +- [App client](capabilities/app-client.md) + - [Design](capabilities/app-client.md#design) + - [Creating an application client](capabilities/app-client.md#creating-an-application-client) + - [Calling methods on the app](capabilities/app-client.md#calling-methods-on-the-app) + - [Composing calls](capabilities/app-client.md#composing-calls) + - [Reading state](capabilities/app-client.md#reading-state) + - [Handling logic errors and diagnosing errors](capabilities/app-client.md#handling-logic-errors-and-diagnosing-errors) +- [App deployment](capabilities/app-deploy.md) + - [Design](capabilities/app-deploy.md#design) + - [Finding apps by creator](capabilities/app-deploy.md#finding-apps-by-creator) + - [Deploying an application](capabilities/app-deploy.md#deploying-an-application) +- [Algo transfers](capabilities/transfer.md) + - [Transferring Algos](capabilities/transfer.md#transferring-algos) + - [Ensuring minimum Algos](capabilities/transfer.md#ensuring-minimum-algos) + - [Transfering Assets](capabilities/transfer.md#transfering-assets) + - [Dispenser](capabilities/transfer.md#dispenser) +- [TestNet Dispenser Client](capabilities/dispenser-client.md) + - [Creating a Dispenser Client](capabilities/dispenser-client.md#creating-a-dispenser-client) + - [Funding an Account](capabilities/dispenser-client.md#funding-an-account) + - [Registering a Refund](capabilities/dispenser-client.md#registering-a-refund) + - [Getting Current Limit](capabilities/dispenser-client.md#getting-current-limit) + - [Error Handling](capabilities/dispenser-client.md#error-handling) +- [Debugger](capabilities/debugger.md) + - [Configuration](capabilities/debugger.md#configuration) + - [Debugging Utilities](capabilities/debugger.md#debugging-utilities) +- [`algokit_utils`](apidocs/algokit_utils/algokit_utils.md) + - [Data](apidocs/algokit_utils/algokit_utils.md#data) + - [Classes](apidocs/algokit_utils/algokit_utils.md#classes) + - [Functions](apidocs/algokit_utils/algokit_utils.md#functions) diff --git a/src/algokit_utils/_legacy_v2/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py index 2db90f3..7f9d662 100644 --- a/src/algokit_utils/_legacy_v2/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -4,6 +4,7 @@ from algosdk.atomic_transaction_composer import AccountTransactionSigner from algosdk.transaction import SuggestedParams from algosdk.v2client.algod import AlgodClient +from typing_extensions import deprecated from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.account import get_dispenser_account @@ -115,6 +116,7 @@ def _fund_using_transfer( return EnsureFundedResponse(transaction_id=transaction_id, amount=response.amt) +@deprecated("Deprecated") def ensure_funded( client: AlgodClient, parameters: EnsureBalanceParameters, diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index d98a875..f72a646 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -5,6 +5,7 @@ from algosdk.account import address_from_private_key from algosdk.mnemonic import from_private_key, to_private_key from algosdk.util import algos_to_microalgos +from typing_extensions import deprecated from algokit_utils._legacy_v2._transfer import TransferParameters, transfer from algokit_utils._legacy_v2.network_clients import get_kmd_client_from_algod_client, is_localnet @@ -30,6 +31,7 @@ _DEFAULT_ACCOUNT_MINIMUM_BALANCE = 1_000_000_000 +@deprecated("Deprecated") def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" private_key = to_private_key(mnemonic) @@ -37,6 +39,7 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: return Account(private_key=private_key, address=address) +@deprecated("Deprecated") def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: """Creates a wallet with specified name""" wallet_id = kmd_client.create_wallet(name, "")["id"] @@ -50,6 +53,7 @@ def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: return get_account_from_mnemonic(from_private_key(private_account_key)) +@deprecated("Deprecated") def get_or_create_kmd_wallet_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: @@ -90,6 +94,7 @@ def _is_default_account(account: dict[str, Any]) -> bool: return bool(account["status"] != "Offline" and account["amount"] > _DEFAULT_ACCOUNT_MINIMUM_BALANCE) +@deprecated("Deprecated") def get_localnet_default_account(client: "AlgodClient") -> Account: """Returns the default Account in a LocalNet instance""" if not is_localnet(client): @@ -102,6 +107,7 @@ def get_localnet_default_account(client: "AlgodClient") -> Account: return account +@deprecated("Deprecated") def get_dispenser_account(client: "AlgodClient") -> Account: """Returns an Account based on DISPENSER_MNENOMIC environment variable or the default account on LocalNet""" if is_localnet(client): @@ -109,6 +115,7 @@ def get_dispenser_account(client: "AlgodClient") -> Account: return get_account(client, "DISPENSER") +@deprecated("Deprecated") def get_kmd_wallet_account( client: "AlgodClient", kmd_client: "KMDClient", @@ -142,6 +149,7 @@ def get_kmd_wallet_account( return get_account_from_mnemonic(from_private_key(private_account_key)) +@deprecated("Deprecated") def get_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index 33dfe95..254041b 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -27,6 +27,7 @@ from algosdk.constants import APP_PAGE_MAX_SIZE from algosdk.logic import get_application_address from algosdk.source_map import SourceMap +from typing_extensions import deprecated import algokit_utils._legacy_v2.application_specification as au_spec import algokit_utils._legacy_v2.deploy as au_deploy @@ -83,6 +84,7 @@ def num_extra_program_pages(approval: bytes, clear: bytes) -> int: return ceil(((len(approval) + len(clear)) - APP_PAGE_MAX_SIZE) / APP_PAGE_MAX_SIZE) +@deprecated("Use AppClient from algokit_utils.applications instead") class ApplicationClient: """A class that wraps an ARC-0032 app spec and provides high productivity methods to deploy and call the app""" @@ -1254,6 +1256,7 @@ def _try_convert_to_logic_error( return None +@deprecated("Deprecated") def execute_atc_with_logic_error( atc: AtomicTransactionComposer, algod_client: "AlgodClient", diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index 865dece..887f9c1 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -8,6 +8,7 @@ from algosdk.abi import Contract from algosdk.abi.method import MethodDict from algosdk.transaction import StateSchema +from typing_extensions import deprecated __all__ = [ "CallConfig", @@ -136,6 +137,7 @@ def _decode_state_schema(data: dict[str, int]) -> StateSchema: ) +@deprecated("Deprecated") @dataclasses.dataclass(kw_only=True) class ApplicationSpecification: """ARC-0032 application specification diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py index 2f71cbf..bc001ce 100644 --- a/src/algokit_utils/_legacy_v2/asset.py +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -4,6 +4,7 @@ from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionWithSigner from algosdk.constants import TX_GROUP_LIMIT from algosdk.transaction import AssetTransferTxn +from typing_extensions import deprecated if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -68,6 +69,7 @@ def _ensure_asset_balance_conditions( raise ValueError(error_message) +@deprecated("Deprecated") def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt-in to a list of assets on the Algorand blockchain. Before an account can receive a specific asset, @@ -116,6 +118,7 @@ def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) return result +@deprecated("Deprecated") def opt_out(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt out from a list of Algorand Standard Assets (ASAs) by transferring them back to their creators. diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 0aadb72..62c17ad 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -11,7 +11,7 @@ from algosdk import transaction from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner from algosdk.transaction import StateSchema -from deprecated import deprecated +from typing_extensions import deprecated from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -175,6 +175,7 @@ def _parse_note(metadata_b64: str | None) -> AppDeployMetaData | None: return None +@deprecated("Deprecated") def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) -> AppLookup: """Returns a mapping of Application names to {py:class}`AppMetaData` for all Applications created by specified creator that have a transaction note containing {py:class}`AppDeployMetaData` @@ -255,6 +256,7 @@ class AppChanges: schema_change_description: str | None +@deprecated("Deprecated") def check_for_app_changes( algod_client: "AlgodClient", *, @@ -412,7 +414,7 @@ def check_template_variables(approval_program: str, template_values: TemplateVal logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided") -@deprecated(reason="Use `AppManager.replace_template_variables` instead", version="3.0.0") +@deprecated("Use `AppManager.replace_template_variables` instead") def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str: """Replaces `TMPL_*` variables in `program` with `template_values` diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py index 0878887..a3fb9ac 100644 --- a/src/algokit_utils/_legacy_v2/logic_error.py +++ b/src/algokit_utils/_legacy_v2/logic_error.py @@ -2,7 +2,7 @@ from copy import copy from typing import TYPE_CHECKING, TypedDict -from deprecated import deprecated +from typing_extensions import deprecated from algokit_utils._legacy_v2.models import SimulationTrace @@ -39,7 +39,7 @@ def parse_logic_error( } -@deprecated(reason="Use algokit_utils.models.error.LogicError instead", version="3.0.0") +@deprecated("Use algokit_utils.models.error.LogicError instead") class LogicError(Exception): def __init__( self, diff --git a/src/algokit_utils/_legacy_v2/models.py b/src/algokit_utils/_legacy_v2/models.py index d20bed8..7887cb6 100644 --- a/src/algokit_utils/_legacy_v2/models.py +++ b/src/algokit_utils/_legacy_v2/models.py @@ -9,7 +9,7 @@ SimulateAtomicTransactionResponse, TransactionSigner, ) -from deprecated import deprecated +from typing_extensions import deprecated # Imports from latest sdk version that rely on models previously used in legacy v2 (but moved to root models/*) @@ -185,17 +185,17 @@ class CreateCallParametersDict(OnCompleteCallParametersDict, total=False): # Pre 1.3.1 backwards compatibility -@deprecated(reason="Use TransactionParameters instead", version="1.3.1") +@deprecated("Use TransactionParameters instead") class RawTransactionParameters(TransactionParameters): """Deprecated, use TransactionParameters instead""" -@deprecated(reason="Use TransactionParameters instead", version="1.3.1") +@deprecated("Use TransactionParameters instead") class CommonCallParameters(TransactionParameters): """Deprecated, use TransactionParameters instead""" -@deprecated(reason="Use TransactionParametersDict instead", version="1.3.1") +@deprecated("Use TransactionParametersDict instead") class CommonCallParametersDict(TransactionParametersDict): """Deprecated, use TransactionParametersDict instead""" diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index b1bcc2c..ab4a4db 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -6,6 +6,7 @@ from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient +from typing_extensions import deprecated __all__ = [ "AlgoClientConfig", @@ -40,12 +41,14 @@ class AlgoClientConfigs: kmd_config: AlgoClientConfig | None +@deprecated("Deprecated") def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: """Returns the client configuration to point to the default LocalNet""" port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) +@deprecated("Deprecated") def get_algonode_config( network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"], token: str ) -> AlgoClientConfig: @@ -56,6 +59,7 @@ def get_algonode_config( ) +@deprecated("Deprecated") def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment @@ -65,6 +69,7 @@ def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: return AlgodClient(config.token, config.server, headers) +@deprecated("Deprecated") def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment @@ -73,6 +78,7 @@ def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: return KMDClient(config.token, config.server) +@deprecated("Deprecated") def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. @@ -82,24 +88,28 @@ def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: return IndexerClient(config.token, config.server, headers) +@deprecated("Deprecated") def is_localnet(client: AlgodClient) -> bool: """Returns True if client genesis is `devnet-v1` or `sandnet-v1`""" params = client.suggested_params() return params.gen in ["devnet-v1", "sandnet-v1", "dockernet-v1"] +@deprecated("Deprecated") def is_mainnet(client: AlgodClient) -> bool: """Returns True if client genesis is `mainnet-v1`""" params = client.suggested_params() return params.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"] +@deprecated("Deprecated") def is_testnet(client: AlgodClient) -> bool: """Returns True if client genesis is `testnet-v1`""" params = client.suggested_params() return params.gen in ["testnet-v1.0", "testnet-v1", "testnet"] +@deprecated("Deprecated") def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from supplied `client` diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index d4d95d1..9ef0e89 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -4,18 +4,28 @@ from algosdk.account import generate_account from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.transaction import SuggestedParams from typing_extensions import Self from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer +from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult -@dataclass +@dataclass(frozen=True, kw_only=True) class AddressAndSigner: address: str signer: TransactionSigner +@dataclass(frozen=True, kw_only=True) +class EnsureFundedResponse(SendSingleTransactionResult): + transaction_id: str + amount_funded: AlgoAmount + + class AccountManager: """Creates and keeps track of addresses and signers""" @@ -112,9 +122,9 @@ def random(self) -> AddressAndSigner: (sk, addr) = generate_account() signer = AccountTransactionSigner(sk) - self.set_signer(addr, signer) + self.set_signer(str(addr), signer) - return AddressAndSigner(address=addr, signer=signer) + return AddressAndSigner(address=str(addr), signer=signer) def dispenser(self) -> AddressAndSigner: """ @@ -138,3 +148,107 @@ def localnet_dispenser(self) -> AddressAndSigner: acct = get_localnet_default_account(self._client_manager.algod) self.set_signer(acct.address, acct.signer) return AddressAndSigner(address=acct.address, signer=acct.signer) + + def ensure_funded( # noqa: PLR0913 + self, + account_fo_fund: str, + dispenser_account: str, + min_spending_balance: AlgoAmount, + min_funding_increment: AlgoAmount, + # Sender params + max_rounds_to_wait: int | None = None, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, + # Common txn params + signer: TransactionSigner | None = None, + rekey_to: str | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + ) -> EnsureFundedResponse | None: + amount_funded = self._get_ensure_funded_amount(account_fo_fund, min_spending_balance, min_funding_increment) + + if not amount_funded: + return None + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=dispenser_account, + receiver=account_fo_fund, + amount=amount_funded, + signer=signer, + rekey_to=rekey_to, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + ) + ) + .send( + max_rounds_to_wait=max_rounds_to_wait, + suppress_log=suppress_log, + populate_app_call_resources=populate_app_call_resources, + ) + ) + + return EnsureFundedResponse( + returns=result.returns, + transactions=result.transactions, + confirmations=result.confirmations, + tx_ids=result.tx_ids, + group_id=result.group_id, + transaction_id=result.tx_ids[0], + confirmation=result.confirmations[0], + transaction=result.transactions[0], + amount_funded=amount_funded, + ) + + def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer: + if get_suggested_params is None: + + def _get_suggested_params() -> SuggestedParams: + return self._client_manager.algod.suggested_params() + + get_suggested_params = _get_suggested_params + + return TransactionComposer( + algod=self._client_manager.algod, get_signer=self.get_signer, get_suggested_params=get_suggested_params + ) + + def _calculate_fund_amount( + self, + min_spending_balance: int, + current_spending_balance: int, + min_funding_increment: int, + ) -> int | None: + if min_spending_balance > current_spending_balance: + min_fund_amount = min_spending_balance - current_spending_balance + return max(min_fund_amount, min_funding_increment) + return None + + def _get_ensure_funded_amount( + self, + sender: str, + min_spending_balance: AlgoAmount, + min_funding_increment: AlgoAmount | None = None, + ) -> AlgoAmount | None: + account_info = self.get_information(sender) + current_spending_balance = account_info["amount"] - account_info["min-balance"] + + min_increment = min_funding_increment.micro_algo if min_funding_increment else 0 + amount_funded = self._calculate_fund_amount( + min_spending_balance.micro_algo, current_spending_balance, min_increment + ) + + return AlgoAmount.from_micro_algo(amount_funded) if amount_funded is not None else None diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 38a2339..4cb40c9 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -127,14 +127,14 @@ def get_constant_block_offset(program: bytes) -> int: # noqa: C901 return max(bytecblock_offset or 0, intcblock_offset or 0) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientCompilationParams: deploy_time_params: TealTemplateParams | None = None updatable: bool | None = None deletable: bool | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class ExposedLogicErrorDetails: is_clear_state_program: bool = False approval_source_map: SourceMap | None = None @@ -145,20 +145,14 @@ class ExposedLogicErrorDetails: @dataclass(kw_only=True, frozen=True) -class _AppClientParamsBase: - """Base parameters for creating an app client""" +class AppClientParams: + """Full parameters for creating an app client""" - app_id: int app_spec: ( Arc56Contract | ApplicationSpecification | str ) # Using string quotes since these types may be defined elsewhere algorand: AlgorandClientProtocol # Using string quotes since this type may be defined elsewhere - - -@dataclass(kw_only=True, frozen=True) -class AppClientParams(_AppClientParamsBase): - """Full parameters for creating an app client""" - + app_id: int app_name: str | None = None default_sender: str | bytes | None = None # Address can be string or bytes default_signer: TransactionSigner | None = None @@ -166,7 +160,7 @@ class AppClientParams(_AppClientParamsBase): clear_source_map: SourceMap | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientCompilationResult: approval_program: bytes clear_state_program: bytes @@ -174,7 +168,7 @@ class AppClientCompilationResult: compiled_clear: CompiledTeal | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class CommonTxnParams: sender: str signer: TransactionSigner | None = None @@ -189,7 +183,7 @@ class CommonTxnParams: last_valid_round: int | None = None -@dataclass(kw_only=True, frozen=True) +@dataclass(kw_only=True) class FundAppAccountParams: sender: str | None = None signer: TransactionSigner | None = None @@ -210,7 +204,7 @@ class FundAppAccountParams: on_complete: algosdk.transaction.OnComplete | None = None -@dataclass(kw_only=True, frozen=True) +@dataclass(kw_only=True) class AppClientCallParams: method: str | None = None # If calling ABI method, name or signature args: list | None = None # Arguments to pass to the method @@ -224,7 +218,7 @@ class AppClientCallParams: send_params: dict | None = None # Parameters to control transaction sending -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientMethodCallParams: method: str args: list[ABIValue | ABIStruct | AppMethodCallTransactionArgument | None] | None = None @@ -246,69 +240,69 @@ class AppClientMethodCallParams: on_complete: algosdk.transaction.OnComplete | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientMethodCallWithCompilationParams(AppClientMethodCallParams, AppClientCompilationParams): """Combined parameters for method calls with compilation""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientMethodCallWithSendParams(AppClientMethodCallParams, SendParams): """Combined parameters for method calls with send options""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientMethodCallWithCompilationAndSendParams( AppClientMethodCallParams, AppClientCompilationParams, SendParams ): """Combined parameters for method calls with compilation and send options""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientBareCallParams: - signer: TransactionSigner | None - rekey_to: str | None - lease: bytes | None - static_fee: AlgoAmount | None - extra_fee: AlgoAmount | None - max_fee: AlgoAmount | None - validity_window: int | None - first_valid_round: int | None - last_valid_round: int | None - sender: str | None - note: bytes | None - args: list[bytes] | None - account_references: list[str] | None - app_references: list[int] | None - asset_references: list[int] | None - box_references: list[BoxReference | BoxIdentifier] | None - - -@dataclass(frozen=True, kw_only=True) + signer: TransactionSigner | None = None + rekey_to: str | None = None + lease: bytes | None = None + static_fee: AlgoAmount | None = None + extra_fee: AlgoAmount | None = None + max_fee: AlgoAmount | None = None + validity_window: int | None = None + first_valid_round: int | None = None + last_valid_round: int | None = None + sender: str | None = None + note: bytes | None = None + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference | BoxIdentifier] | None = None + + +@dataclass(kw_only=True, frozen=True) class CallOnComplete: on_complete: algosdk.transaction.OnComplete -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientBareCallWithCompilationParams(AppClientBareCallParams, AppClientCompilationParams): """Combined parameters for bare calls with compilation""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientBareCallWithSendParams(AppClientBareCallParams, SendParams): """Combined parameters for bare calls with send options""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientBareCallWithCompilationAndSendParams(AppClientBareCallParams, AppClientCompilationParams, SendParams): """Combined parameters for bare calls with compilation and send options""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppClientBareCallWithCallOnCompleteParams(AppClientBareCallParams, CallOnComplete): """Combined parameters for bare calls with an OnComplete value""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class ResolveAppClientByNetwork: app_spec: Arc56Contract | ApplicationSpecification | str algorand: AlgorandClientProtocol @@ -319,7 +313,7 @@ class ResolveAppClientByNetwork: clear_source_map: SourceMap | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppSourceMaps: approval_source_map: SourceMap | None = None clear_source_map: SourceMap | None = None @@ -785,7 +779,6 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR ) return SendAppTransactionResult( - tx_id=simulate_response.tx_ids[-1], tx_ids=simulate_response.tx_ids, transactions=simulate_response.transactions, transaction=simulate_response.transactions[-1], @@ -921,6 +914,12 @@ def compile( updatable: bool | None = None, deletable: bool | None = None, ) -> AppClientCompilationResult: + def is_base64(s: str) -> bool: + try: + return base64.b64encode(base64.b64decode(s)).decode() == s + except Exception: + return False + if not app_spec.source: if not app_spec.byte_code or not app_spec.byte_code.get("approval") or not app_spec.byte_code.get("clear"): raise ValueError(f"Attempt to compile app {app_spec.name} without source or byte_code") @@ -930,19 +929,24 @@ def compile( clear_state_program=base64.b64decode(app_spec.byte_code.get("clear", "")), ) - approval_template: str = base64.b64decode(app_spec.source.get("approval", "")).decode("utf-8") # type: ignore[assignment] - deployment_metadata = ( - {"updatable": updatable or False, "deletable": deletable or False} - if updatable is not None or deletable is not None - else None + approval_source = app_spec.source.get("approval", "") + approval_template: str = ( + base64.b64decode(approval_source).decode("utf-8") if is_base64(approval_source) else approval_source ) compiled_approval = app_manager.compile_teal_template( approval_template, template_params=deploy_time_params, - deployment_metadata=deployment_metadata, + deployment_metadata=( + {"updatable": updatable or False, "deletable": deletable or False} + if updatable is not None or deletable is not None + else None + ), ) - clear_template: str = base64.b64decode(app_spec.source.get("clear", "")).decode("utf-8") # type: ignore[assignment] + clear_source = app_spec.source.get("clear", "") + clear_template: str = ( + base64.b64decode(clear_source).decode("utf-8") if is_base64(clear_source) else clear_source + ) compiled_clear = app_manager.compile_teal_template( clear_template, template_params=deploy_time_params, diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 8b18714..6e75ad5 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -3,10 +3,12 @@ import json import logging from dataclasses import dataclass -from typing import Any, Literal +from typing import Literal +import algosdk from algosdk.atomic_transaction_composer import TransactionSigner from algosdk.logic import get_application_address +from algosdk.transaction import OnComplete, Transaction from algosdk.v2client.indexer import IndexerClient from algokit_utils._legacy_v2.deploy import ( @@ -15,8 +17,9 @@ AppMetaData, OnSchemaBreak, OnUpdate, + OperationPerformed, ) -from algokit_utils.applications.app_manager import AppManager +from algokit_utils.applications.app_manager import AppManager, TealTemplateParams from algokit_utils.models.abi import ABIValue from algokit_utils.transactions.transaction_composer import ( AppCreateMethodCall, @@ -27,7 +30,6 @@ ) from algokit_utils.transactions.transaction_sender import ( AlgorandClientTransactionSender, - SendAppTransactionResult, ) APP_DEPLOY_NOTE_DAPP = "algokit_deployer" @@ -35,11 +37,12 @@ logger = logging.getLogger(__name__) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class DeployAppUpdateParams: """Parameters for an update transaction in app deployment""" sender: str + on_complete: OnComplete = OnComplete.UpdateApplicationOC signer: TransactionSigner | None = None args: list[bytes] | None = None note: bytes | None = None @@ -51,11 +54,12 @@ class DeployAppUpdateParams: foreign_assets: list[int] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class DeployAppDeleteParams: """Parameters for a delete transaction in app deployment""" sender: str + on_complete: OnComplete = OnComplete.DeleteApplicationOC signer: TransactionSigner | None = None note: bytes | None = None lease: bytes | None = None @@ -66,12 +70,12 @@ class DeployAppDeleteParams: foreign_assets: list[int] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True) class AppDeployParams: """Parameters for deploying an app""" metadata: AppDeployMetaData - deploy_time_params: dict[str, Any] | None = None + deploy_time_params: TealTemplateParams | None = None on_schema_break: Literal["replace", "fail", "append"] | OnSchemaBreak = OnSchemaBreak.Fail on_update: Literal["update", "replace", "fail", "append"] | OnUpdate = OnUpdate.Fail create_params: AppCreateParams | AppCreateMethodCall @@ -84,16 +88,36 @@ class AppDeployParams: suppress_log: bool = False -@dataclass(frozen=True) -class AppDeploymentResult: - operation_performed: Literal["create", "update", "replace", "nothing"] - app_id: int - app_address: str - transaction: transaction.Transaction | None = None - confirmation: dict[str, Any] | None = None +@dataclass(kw_only=True, frozen=True) +class ConfirmedTransactionResult: + transaction: algosdk.transaction.Transaction + confirmation: algosdk.v2client.algod.AlgodResponseType + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None + + +@dataclass(kw_only=True, frozen=True) +class AppDeployResult: + operation_performed: OperationPerformed + + # Common fields from AppMetadata + name: str + version: str + created_round: int + updated_round: int + deleted: bool + created_metadata: dict + deletable: bool | None = None + updatable: bool | None = None + + app_id: int | None = None + app_address: str | None = None + transaction: Transaction | None = None + confirmation: algosdk.v2client.algod.AlgodResponseType | None = None + compiled_approval: dict | None = None + compiled_clear: dict | None = None return_value: ABIValue | None = None delete_return: ABIValue | None = None - delete_result: dict[str, Any] | None = None + delete_result: ConfirmedTransactionResult | None = None class AppDeployer: @@ -118,7 +142,7 @@ def _create_deploy_note(self, metadata: AppDeployMetaData) -> bytes: } return json.dumps(note).encode() - def deploy(self, deployment: AppDeployParams) -> AppDeploymentResult | SendAppTransactionResult: + def deploy(self, deployment: AppDeployParams) -> AppDeployResult: # Create new instances with updated notes note = self._create_deploy_note(deployment.metadata) create_params = dataclasses.replace(deployment.create_params, note=note) @@ -217,8 +241,9 @@ def deploy(self, deployment: AppDeployParams) -> AppDeploymentResult | SendAppTr clear_program=clear_program, ) - return AppDeploymentResult( - operation_performed="nothing", + return AppDeployResult( + **existing_app.__dict__, + operation_performed=OperationPerformed.Nothing, app_id=existing_app.app_id, app_address=existing_app.app_address, ) @@ -228,44 +253,49 @@ def _create_app( deployment: AppDeployParams, approval_program: bytes, clear_program: bytes, - ) -> AppDeploymentResult: + ) -> AppDeployResult: """Create a new application""" if isinstance(deployment.create_params, AppCreateMethodCall): - create_params = AppCreateMethodCall( - **{ - **deployment.create_params.__dict__, - "approval_program": approval_program, - "clear_state_program": clear_program, - } + result = self._transaction_sender.app_create_method_call( + AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) ) - result = self._transaction_sender.app_create_method_call(create_params) else: - create_params = AppCreateParams( - **{ - **deployment.create_params.__dict__, - "approval_program": approval_program, - "clear_state_program": clear_program, - } + result = self._transaction_sender.app_create( + AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) ) - result = self._transaction_sender.app_create(create_params) app_metadata = AppMetaData( app_id=result.app_id, app_address=get_application_address(result.app_id), **deployment.metadata.__dict__, created_metadata=deployment.metadata, - created_round=result.confirmation["confirmed-round"], - updated_round=result.confirmation["confirmed-round"], + created_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, + updated_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, deleted=False, ) self._update_app_lookup(deployment.create_params.sender, app_metadata) - return AppDeploymentResult( - operation_performed="create", - app_id=result.app_id, - app_address=get_application_address(result.app_id), + app_metadata_dict = app_metadata.__dict__ + app_metadata_dict["operation_performed"] = OperationPerformed.Create + app_metadata_dict["app_id"] = result.app_id + app_metadata_dict["app_address"] = get_application_address(result.app_id) + + return AppDeployResult( + **app_metadata_dict, transaction=result.transaction, confirmation=result.confirmation, return_value=result.return_value, @@ -277,7 +307,7 @@ def _handle_schema_break( existing_app: AppMetaData, approval_program: bytes, clear_program: bytes, - ) -> AppDeploymentResult: + ) -> AppDeployResult: if deployment.on_schema_break in (OnSchemaBreak.Fail, "fail"): raise ValueError( "Schema break detected and onSchemaBreak=OnSchemaBreak.Fail, stopping deployment. " @@ -299,7 +329,7 @@ def _handle_update( existing_app: AppMetaData, approval_program: bytes, clear_program: bytes, - ) -> AppDeploymentResult: + ) -> AppDeployResult: if deployment.on_update in (OnUpdate.Fail, "fail"): raise ValueError( "Update detected and onUpdate=Fail, stopping deployment. " "Try a different onUpdate value to not fail." @@ -328,7 +358,7 @@ def _create_and_delete_app( existing_app: AppMetaData, approval_program: bytes, clear_program: bytes, - ) -> AppDeploymentResult: + ) -> AppDeployResult: composer = self._transaction_sender.new_group() # Add create transaction @@ -383,7 +413,7 @@ def _create_and_delete_app( ) self._update_app_lookup(deployment.create_params.sender, app_metadata) - return AppDeploymentResult( + return AppDeployResult( operation_performed="replace", app_id=app_id, app_address=get_application_address(app_id), diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index 676b34f..a522656 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -1,57 +1,61 @@ import base64 from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Protocol, TypeVar, cast +from typing import Any, TypeGuard, TypeVar +import algosdk from algosdk import transaction from algosdk.abi import Method from algosdk.atomic_transaction_composer import TransactionSigner +from algosdk.source_map import SourceMap +from algosdk.transaction import OnComplete, Transaction -from algokit_utils._legacy_v2.deploy import ( - AppDeployMetaData, - AppLookup, - OnSchemaBreak, - OnUpdate, -) +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils._legacy_v2.deploy import AppDeployMetaData, AppLookup, OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import ( AppClient, AppClientBareCallParams, AppClientCompilationParams, + AppClientCompilationResult, AppClientMethodCallParams, AppClientParams, + AppSourceMaps, ExposedLogicErrorDetails, ) +from algokit_utils.applications.app_deployer import AppDeployParams, DeployAppDeleteParams, DeployAppUpdateParams from algokit_utils.applications.app_manager import TealTemplateParams from algokit_utils.applications.utils import ( get_abi_decoded_value, - get_abi_struct_from_abi_tuple, get_abi_tuple_from_abi_struct, get_arc56_method, + get_arc56_return_value, +) +from algokit_utils.models.application import ( + DELETABLE_TEMPLATE_NAME, + UPDATABLE_TEMPLATE_NAME, + Arc56Contract, + Arc56Method, + CompiledTeal, + MethodArg, ) -from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, Arc56Contract +from algokit_utils.models.transaction import SendParams from algokit_utils.protocols.application import AlgorandClientProtocol -from algokit_utils.transactions.transaction_composer import AppCreateParams +from algokit_utils.transactions.transaction_composer import ( + AppCreateMethodCall, + AppCreateParams, + AppDeleteMethodCall, + AppUpdateMethodCall, + BuiltTransactions, +) from algokit_utils.transactions.transaction_sender import SendAppTransactionResult -if TYPE_CHECKING: - from algosdk.source_map import SourceMap - - T = TypeVar("T") -class ParamsMethodsProtocol(Protocol): - def create(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... - def deploy_update(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... - def deploy_delete(self, params: AppClientMethodCallParams) -> dict[str, Any]: ... - - bare: dict[str, Callable[[AppClientBareCallParams | None], dict[str, Any]]] - - -@dataclass(kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppFactoryParams: - app_spec: Arc56Contract | str algorand: AlgorandClientProtocol + app_spec: Arc56Contract | ApplicationSpecification | str app_name: str | None = None default_sender: str | bytes | None = None default_signer: TransactionSigner | None = None @@ -68,6 +72,11 @@ class AppFactoryCreateParams(AppClientBareCallParams, AppClientCompilationParams extra_program_pages: int | None = None +@dataclass(kw_only=True, frozen=True) +class AppFactoryCreateWithSendParams(AppFactoryCreateParams, SendParams): + pass + + @dataclass(kw_only=True, frozen=True) class AppFactoryCreateMethodCallParams(AppClientMethodCallParams, AppClientCompilationParams): on_complete: transaction.OnComplete | None = None @@ -76,73 +85,189 @@ class AppFactoryCreateMethodCallParams(AppClientMethodCallParams, AppClientCompi @dataclass(kw_only=True, frozen=True) -class AppFactoryDeployParams: - version: str | None = None - signer: TransactionSigner | None = None - sender: str | None = None - allow_update: bool | None = None - allow_delete: bool | None = None - on_update: OnUpdate = OnUpdate.Fail - on_schema_break: OnSchemaBreak = OnSchemaBreak.Fail - template_values: TealTemplateParams | None = None - create_args: AppClientMethodCallParams | AppClientBareCallParams | None = None - update_args: AppClientMethodCallParams | AppClientBareCallParams | None = None - delete_args: AppClientMethodCallParams | AppClientBareCallParams | None = None - existing_deployments: AppLookup | None = None - ignore_cache: bool = False - updatable: bool | None = None - deletable: bool | None = None - app_name: str | None = None +class AppFactoryCreateMethodCallWithSendParams(AppFactoryCreateMethodCallParams, SendParams): + pass -class AppFactory: - def __init__(self, params: AppFactoryParams) -> None: - self._app_spec = AppClient.normalise_app_spec(params.app_spec) - self._app_name = params.app_name or self._app_spec.name - self._algorand = params.algorand - self._version = params.version or "1.0" - self._default_sender = params.default_sender - self._default_signer = params.default_signer - self._deploy_time_params = params.deploy_time_params - self._updatable = params.updatable - self._deletable = params.deletable - self._approval_source_map: SourceMap | None = None - self._clear_source_map: SourceMap | None = None +@dataclass(frozen=True, kw_only=True) +class AppFactoryCreateResult(SendAppTransactionResult): + """Result from creating an application via AppFactory""" - @property - def app_name(self) -> str: - return self._app_name + app_id: int + """The ID of the created application""" + app_address: str + """The address of the created application""" + compiled_approval: CompiledTeal | None = None + """The compiled approval program if source was provided""" + compiled_clear: CompiledTeal | None = None + """The compiled clear program if source was provided""" - @property - def app_spec(self) -> Arc56Contract: - return self._app_spec + +@dataclass(kw_only=True, frozen=True) +class AppFactoryDeployResult: + """Represents the result object from app deployment""" + + app_address: str + app_id: int + approval_program: bytes # Uint8Array + clear_state_program: bytes # Uint8Array + compiled_approval: dict # Contains teal, compiled, compiledHash, compiledBase64ToBytes, sourceMap + compiled_clear: dict # Contains teal, compiled, compiledHash, compiledBase64ToBytes, sourceMap + confirmation: algosdk.v2client.algod.AlgodResponseType + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None + created_metadata: dict # {name: str, version: str, updatable: bool, deletable: bool} + created_round: int + deletable: bool + deleted: bool + delete_return: Any | None = None + group_id: str | None = None + name: str + operation_performed: OperationPerformed + return_value: Any | None = None + returns: list[Any] | None = None + transaction: Transaction + transactions: list[Transaction] + tx_id: str + tx_ids: list[str] + updatable: bool + updated_round: int + version: str + + +class _AppFactoryBareParamsAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + + def create(self, params: AppFactoryCreateParams | None = None) -> AppCreateParams: + create_args = {} + if params: + create_args = {**params.__dict__} + del create_args["schema"] + del create_args["sender"] + del create_args["on_complete"] + del create_args["deploy_time_params"] + del create_args["updatable"] + del create_args["deletable"] + compiled = self._factory.compile(params) + create_args["approval_program"] = compiled.approval_program + create_args["clear_state_program"] = compiled.clear_state_program + + return AppCreateParams( + **create_args, + schema=(params.schema if params else None) + or { + "global_bytes": self._factory._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._factory._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._factory._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._factory._app_spec.state.schemas["local"]["ints"], + }, + sender=self._factory._get_sender(params.sender if params else None), + on_complete=(params.on_complete if params else None) or OnComplete.NoOpOC, + ) + + def deploy_update(self, params: AppClientBareCallParams | None = None) -> dict[str, Any]: + return { + **(params.__dict__ if params else {}), + "sender": self._factory._get_sender(params.sender if params else None), + "on_complete": OnComplete.UpdateApplicationOC, + } + + def deploy_delete(self, params: AppClientBareCallParams | None = None) -> dict[str, Any]: + return { + **(params.__dict__ if params else {}), + "sender": self._factory._get_sender(params.sender if params else None), + "on_complete": OnComplete.DeleteApplicationOC, + } + + +class _AppFactoryParamsAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._bare = _AppFactoryBareParamsAccessor(factory) @property - def algorand(self) -> AlgorandClientProtocol: - return self._algorand + def bare(self) -> _AppFactoryBareParamsAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> AppCreateMethodCall: + compiled = self._factory.compile(params) + params_dict = params.__dict__ + params_dict["schema"] = params.schema or { + "global_bytes": self._factory._app_spec.state.schemas["global"]["bytes"], + "global_ints": self._factory._app_spec.state.schemas["global"]["ints"], + "local_bytes": self._factory._app_spec.state.schemas["local"]["bytes"], + "local_ints": self._factory._app_spec.state.schemas["local"]["ints"], + } + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = params.on_complete or OnComplete.NoOpOC + del params_dict["deploy_time_params"] + del params_dict["updatable"] + del params_dict["deletable"] + return AppCreateMethodCall( + **params_dict, + app_id=0, + approval_program=compiled.approval_program, + clear_state_program=compiled.clear_state_program, + ) - def get_app_client_by_id(self, params: AppClientParams) -> AppClient: - return AppClient( - AppClientParams( - app_id=params.app_id, - algorand=self._algorand, - app_spec=self._app_spec, - app_name=params.app_name or self._app_name, - default_sender=params.default_sender or self._default_sender, - default_signer=params.default_signer or self._default_signer, - approval_source_map=params.approval_source_map or self._approval_source_map, - clear_source_map=params.clear_source_map or self._clear_source_map, - ) + def deploy_update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: + return AppUpdateMethodCall( + **params.__dict__, + sender=self._factory._get_sender(params.sender), + method=get_arc56_method(params.method, self._factory._app_spec), + args=self._factory._get_create_abi_args_with_default_values(params.method, params.args), + on_complete=OnComplete.UpdateApplicationOC, ) - def create_bare(self, params: AppFactoryCreateParams | None = None) -> tuple[AppClient, SendAppTransactionResult]: - updatable = params.updatable if params and params.updatable is not None else self._updatable - deletable = params.deletable if params and params.deletable is not None else self._deletable + def deploy_delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: + return AppDeleteMethodCall( + **params.__dict__, + sender=self._factory._get_sender(params.sender), + method=get_arc56_method(params.method, self._factory._app_spec), + args=self._factory._get_create_abi_args_with_default_values(params.method, params.args), + on_complete=OnComplete.DeleteApplicationOC, + ) + + +class _AppFactoryBareCreateTransactionAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + + def create(self, params: AppFactoryCreateParams | None = None) -> Transaction: + return self._factory._algorand.create_transaction.app_create(self._factory.params.bare.create(params)) + + +class _AppFactoryCreateTransactionAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._bare = _AppFactoryBareCreateTransactionAccessor(factory) + + @property + def bare(self) -> _AppFactoryBareCreateTransactionAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> BuiltTransactions: + return self._factory._algorand.create_transaction.app_create_method_call(self._factory.params.create(params)) + + +class _AppFactoryBareSendAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + + def create(self, params: AppFactoryCreateWithSendParams | None = None) -> tuple[AppClient, AppFactoryCreateResult]: + updatable = params.updatable if params and params.updatable is not None else self._factory._updatable + deletable = params.deletable if params and params.deletable is not None else self._factory._deletable deploy_time_params = ( - params.deploy_time_params if params and params.deploy_time_params is not None else self._deploy_time_params + params.deploy_time_params + if params and params.deploy_time_params is not None + else self._factory._deploy_time_params ) - compiled = self.compile( + compiled = self._factory.compile( AppClientCompilationParams( deploy_time_params=deploy_time_params, updatable=updatable, @@ -150,35 +275,52 @@ def create_bare(self, params: AppFactoryCreateParams | None = None) -> tuple[App ) ) - result = self._handle_call_errors( + create_args = {} + if params: + create_args = {**params.__dict__} + del create_args["max_rounds_to_wait"] + del create_args["suppress_log"] + del create_args["populate_app_call_resources"] + + create_args["updatable"] = updatable + create_args["deletable"] = deletable + create_args["deploy_time_params"] = deploy_time_params + + result = self._factory._handle_call_errors( lambda: self._algorand.send.app_create( - self._get_bare_params( - params=AppCreateParams( - **(params.__dict__ if params else {}), - updatable=updatable, - deletable=deletable, - deploy_time_params=deploy_time_params, - ), - on_complete=params.on_complete if params else transaction.OnComplete.NoOpOC, - ) + self._factory.params.bare.create(AppFactoryCreateParams(**create_args)) ) - ) + ).__dict__ + + result["compiled_approval"] = compiled.compiled_approval + result["compiled_clear"] = compiled.compiled_clear return ( - self.get_app_client_by_id( - AppClientParams(app_id=result.app_id, app_spec=self._app_spec, algorand=self._algorand) + self._factory.get_app_client_by_id( + app_id=result["app_id"], ), - SendAppTransactionResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), + AppFactoryCreateResult(**result), ) - def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, SendAppTransactionResult]: - updatable = params.updatable if params.updatable is not None else self._updatable - deletable = params.deletable if params.deletable is not None else self._deletable + +class _AppFactorySendAccessor: + def __init__(self, factory: "AppFactory") -> None: + self._factory = factory + self._algorand = factory._algorand + self._bare = _AppFactoryBareSendAccessor(factory) + + @property + def bare(self) -> _AppFactoryBareSendAccessor: + return self._bare + + def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, AppFactoryDeployResult]: + updatable = params.updatable if params.updatable is not None else self._factory._updatable + deletable = params.deletable if params.deletable is not None else self._factory._deletable deploy_time_params = ( - params.deploy_time_params if params.deploy_time_params is not None else self._deploy_time_params + params.deploy_time_params if params.deploy_time_params is not None else self._factory._deploy_time_params ) - compiled = self.compile( + compiled = self._factory.compile( AppClientCompilationParams( deploy_time_params=deploy_time_params, updatable=updatable, @@ -186,34 +328,93 @@ def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, S ) ) - result = self._handle_call_errors( - lambda: self._get_arc56_return_value( - self._algorand.send.app_create_method_call( - self._get_abi_params( - { - **params.__dict__, - "updatable": updatable, - "deletable": deletable, - "deploy_time_params": deploy_time_params, - }, - params.on_complete or transaction.OnComplete.NoOpOC, + result = self._factory._handle_call_errors( + lambda: self._algorand.send.app_create_method_call( + self._factory.params.create( + AppFactoryCreateMethodCallParams( + **params.__dict__, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, ) - ), - get_arc56_method(params.method, self._app_spec), + ) ) ) return ( - self.get_app_client_by_id( - AppClientParams(app_id=result.app_id, app_spec=self._app_spec, algorand=self._algorand) + self._factory.get_app_client_by_id( + app_id=result.app_id, ), - SendAppTransactionResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), + AppFactoryDeployResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), ) - def deploy(self, params: AppFactoryDeployParams) -> tuple[AppClient, SendAppTransactionResult]: - updatable = params.updatable if params.updatable is not None else self._updatable - deletable = params.deletable if params.deletable is not None else self._deletable - deploy_time_params = params.template_values + +class AppFactory: + def __init__(self, params: AppFactoryParams) -> None: + self._app_spec = AppClient.normalise_app_spec(params.app_spec) + self._app_name = params.app_name or self._app_spec.name + self._algorand = params.algorand + self._version = params.version or "1.0" + self._default_sender = params.default_sender + self._default_signer = params.default_signer + self._deploy_time_params = params.deploy_time_params + self._updatable = params.updatable + self._deletable = params.deletable + self._approval_source_map: SourceMap | None = None + self._clear_source_map: SourceMap | None = None + self._params_accessor = _AppFactoryParamsAccessor(self) + self._send_accessor = _AppFactorySendAccessor(self) + self._create_transaction_accessor = _AppFactoryCreateTransactionAccessor(self) + + @property + def app_name(self) -> str: + return self._app_name + + @property + def app_spec(self) -> Arc56Contract: + return self._app_spec + + @property + def algorand(self) -> AlgorandClientProtocol: + return self._algorand + + @property + def params(self) -> _AppFactoryParamsAccessor: + return self._params_accessor + + @property + def send(self) -> _AppFactorySendAccessor: + return self._send_accessor + + @property + def create_transaction(self) -> _AppFactoryCreateTransactionAccessor: + return self._create_transaction_accessor + + def deploy( + self, + *, + deploy_time_params: TealTemplateParams | None = None, + on_update: OnUpdate = OnUpdate.Fail, + on_schema_break: OnSchemaBreak = OnSchemaBreak.Fail, + create_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + update_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + delete_params: AppClientMethodCallParams | AppClientBareCallParams | None = None, + existing_deployments: AppLookup | None = None, + ignore_cache: bool = False, + updatable: bool | None = None, + deletable: bool | None = None, + app_name: str | None = None, + max_rounds_to_wait: int | None = None, + suppress_log: bool = False, + populate_app_call_resources: bool = False, + ) -> tuple[AppClient, AppFactoryDeployResult]: + updatable = ( + updatable if updatable is not None else self._updatable or self._get_deploy_time_control("updatable") + ) + deletable = ( + deletable if deletable is not None else self._deletable or self._get_deploy_time_control("deletable") + ) + deploy_time_params = deploy_time_params if deploy_time_params is not None else self._deploy_time_params compiled = self.compile( AppClientCompilationParams( @@ -223,102 +424,127 @@ def deploy(self, params: AppFactoryDeployParams) -> tuple[AppClient, SendAppTran ) ) - deploy_result = self._algorand.app_deployer.deploy( - { - **params.__dict__, - "create_params": ( - self._get_abi_params(params.create_args.__dict__, transaction.OnComplete.NoOpOC) - if params.create_args and hasattr(params.create_args, "method") - else self._get_bare_params( - params.create_args.__dict__ if params.create_args else {}, - transaction.OnComplete.NoOpOC, - ) + def _is_method_call_params( + params: AppClientMethodCallParams | AppClientBareCallParams | None, + ) -> TypeGuard[AppClientMethodCallParams]: + return params is not None and hasattr(params, "method") + + update_args: DeployAppUpdateParams | AppUpdateMethodCall + if _is_method_call_params(update_params): + update_args = self.params.deploy_update(update_params) # type: ignore[arg-type] + else: + update_args = DeployAppUpdateParams( + **self.params.bare.deploy_update( + update_params if isinstance(update_params, AppClientBareCallParams) else None + ) + ) + + delete_args: DeployAppDeleteParams | AppDeleteMethodCall + if _is_method_call_params(delete_params): + delete_args = self.params.deploy_delete(delete_params) # type: ignore[arg-type] + else: + delete_args = DeployAppDeleteParams( + **self.params.bare.deploy_delete( + delete_params if isinstance(delete_params, AppClientBareCallParams) else None ) - if params.create_args - else None, - "update_params": ( - self._get_abi_params(params.update_args.__dict__, transaction.OnComplete.UpdateApplicationOC) - if params.update_args and hasattr(params.update_args, "method") - else self._get_bare_params( - params.update_args.__dict__ if params.update_args else {}, - transaction.OnComplete.UpdateApplicationOC, + ) + + app_deploy_params = AppDeployParams( + deploy_time_params=deploy_time_params, + on_schema_break=on_schema_break, + on_update=on_update, + existing_deployments=existing_deployments, + ignore_cache=ignore_cache, + create_params=( + self.params.create( + AppFactoryCreateMethodCallParams( + **create_params.__dict__, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, ) ) - if params.update_args - else None, - "delete_params": ( - self._get_abi_params(params.delete_args.__dict__, transaction.OnComplete.DeleteApplicationOC) - if params.delete_args and hasattr(params.delete_args, "method") - else self._get_bare_params( - params.delete_args.__dict__ if params.delete_args else {}, - transaction.OnComplete.DeleteApplicationOC, + if create_params and hasattr(create_params, "method") + else self.params.bare.create( + AppFactoryCreateParams( + **create_params.__dict__ if create_params else {}, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, ) ) - if params.delete_args - else None, - "metadata": AppDeployMetaData( - name=params.app_name or self._app_name, - version=self._version, - updatable=updatable, - deletable=deletable, - ), - } + ), + update_params=update_args, + delete_params=delete_args, + metadata=AppDeployMetaData( + name=app_name or self._app_name, + version=self._version, + updatable=updatable, + deletable=deletable, + ), ) + deploy_result = self._algorand.app_deployer.deploy(app_deploy_params) app_client = self.get_app_client_by_id( - AppClientParams( - app_id=deploy_result.app_id, app_name=params.app_name, app_spec=self._app_spec, algorand=self._algorand - ) + app_id=deploy_result.app_id or 0, + app_name=app_name, + default_sender=self._default_sender, + default_signer=self._default_signer, ) result = {**deploy_result.__dict__, **(compiled.__dict__ if compiled else {})} - return_value = None - if hasattr(result, "return"): - if result["operationPerformed"] == "update": - if params.update_args and hasattr(params.update_args, "method"): - return_value = self._get_arc56_return_value( - result["return"], - get_arc56_method(params.update_args.method, self._app_spec), + if hasattr(result, "return_value"): + if result["operation_performed"] == "update": + if update_params and hasattr(update_params, "method"): + result["return_value"] = get_arc56_return_value( + result["return_value"], + get_arc56_method(update_params.method, self._app_spec), # type: ignore[arg-type] + self._app_spec.structs, ) - elif params.create_args and hasattr(params.create_args, "method"): - return_value = self._get_arc56_return_value( - result["return"], - get_arc56_method(params.create_args.method, self._app_spec), + elif create_params and hasattr(create_params, "method"): + result["return_value"] = get_arc56_return_value( + result["return_value"], + get_arc56_method(create_params.method, self._app_spec), # type: ignore[arg-type] + self._app_spec.structs, ) - delete_return = None - if hasattr(result, "deleteReturn") and params.delete_args and hasattr(params.delete_args, "method"): - delete_return = self._get_arc56_return_value( - result["deleteReturn"], - get_arc56_method(params.delete_args.method, self._app_spec), + if "delete_return" in result and delete_params and hasattr(delete_params, "method"): + result["delete_return"] = get_arc56_return_value( + result["delete_return"], + get_arc56_method(delete_params.method, self._app_spec), # type: ignore[arg-type] + self._app_spec.structs, ) - result["return"] = return_value - result["deleteReturn"] = delete_return - - return app_client, SendAppTransactionResult(**result) - - def compile(self, compilation: AppClientCompilationParams | None = None) -> Any: - result = AppClient.compile( - self._app_spec, - self._algorand.app, - cast(TealTemplateParams | None, compilation.deploy_time_params if compilation else None), + del result["delete_result"] + result["transactions"] = [] + result["tx_id"] = "" + result["tx_ids"] = [] + + return app_client, AppFactoryDeployResult(**result) + + def get_app_client_by_id( + self, + app_id: int, + app_name: str | None = None, + default_sender: str | bytes | None = None, # Address can be string or bytes + default_signer: TransactionSigner | None = None, + approval_source_map: SourceMap | None = None, + clear_source_map: SourceMap | None = None, + ) -> AppClient: + return AppClient( + AppClientParams( + app_id=app_id, + algorand=self._algorand, + app_spec=self._app_spec, + app_name=app_name or self._app_name, + default_sender=default_sender or self._default_sender, + default_signer=default_signer or self._default_signer, + approval_source_map=approval_source_map or self._approval_source_map, + clear_source_map=clear_source_map or self._clear_source_map, + ) ) - if result.compiled_approval: - self._approval_source_map = result.compiled_approval.source_map - if result.compiled_clear: - self._clear_source_map = result.compiled_clear.source_map - - return result - - def _handle_call_errors(self, call: Callable[[], T]) -> T: - try: - return call() - except Exception as e: - raise self.expose_logic_error(e) from None - def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: return AppClient.expose_logic_error_static( e, @@ -328,40 +554,53 @@ def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) approval_source_map=self._approval_source_map, clear_source_map=self._clear_source_map, program=None, - approval_source_info=None, - clear_source_info=None, + approval_source_info=( + self._app_spec.source_info.get("approval") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), + clear_source_info=( + self._app_spec.source_info.get("clear") + if self._app_spec.source_info and hasattr(self._app_spec, "source_info") + else None + ), ), ) - def _get_arc56_return_value(self, return_value: Any, method: Method) -> Any: - if method.returns.type == "void" or return_value is None: - return None - - if hasattr(return_value, "decode_error"): - raise ValueError(return_value["decode_error"]) + def export_source_maps(self) -> AppSourceMaps: + if not self._approval_source_map or not self._clear_source_map: + raise ValueError( + "Unable to export source maps; they haven't been loaded into this client - " + "you need to call create, update, or deploy first" + ) + return AppSourceMaps( + approval_source_map=self._approval_source_map, + clear_source_map=self._clear_source_map, + ) - raw_value = return_value.get("raw_return_value") + def import_source_maps(self, source_maps: AppSourceMaps) -> None: + self._approval_source_map = source_maps.approval_source_map + self._clear_source_map = source_maps.clear_source_map - if method.returns.type == "AVMBytes": - return raw_value - if method.returns.type == "AVMString" and raw_value: - return raw_value.decode("utf-8") - if method.returns.type == "AVMUint64" and raw_value: - return get_abi_decoded_value(raw_value, "uint64", self._app_spec.structs) + def compile(self, compilation: AppClientCompilationParams | None = None) -> AppClientCompilationResult: + result = AppClient.compile( + self._app_spec, + self._algorand.app, + deploy_time_params=compilation.deploy_time_params if compilation else None, + updatable=compilation.updatable if compilation else None, + deletable=compilation.deletable if compilation else None, + ) - if method.returns.struct and method.returns.struct in self._app_spec.structs: - return_tuple = return_value.get("return_value") - return get_abi_struct_from_abi_tuple( - return_tuple, self._app_spec.structs[method.returns.struct], self._app_spec.structs - ) + if result.compiled_approval: + self._approval_source_map = result.compiled_approval.source_map + if result.compiled_clear: + self._clear_source_map = result.compiled_clear.source_map - return return_value.get("return_value") + return result def _get_deploy_time_control(self, control: str) -> bool | None: approval = ( - base64.b64decode(self._app_spec.source["approval"]).decode("utf-8") - if self._app_spec.source and "approval" in self._app_spec.source - else None + self._app_spec.source["approval"] if self._app_spec.source and "approval" in self._app_spec.source else None ) template_name = UPDATABLE_TEMPLATE_NAME if control == "updatable" else DELETABLE_TEMPLATE_NAME @@ -370,79 +609,9 @@ def _get_deploy_time_control(self, control: str) -> bool | None: on_complete = "UpdateApplication" if control == "updatable" else "DeleteApplication" return on_complete in self._app_spec.bare_actions.get("call", []) or any( - m.actions.call and on_complete in m.actions.call for m in self._app_spec.methods + on_complete in m.actions.call for m in self._app_spec.methods if m.actions and m.actions.call ) - @property - def params(self) -> ParamsMethodsProtocol: - return cast(ParamsMethodsProtocol, self._get_params_methods()) - - def _get_params_methods(self) -> dict[str, Any]: - return { - "create": lambda params: self._get_abi_params( - { - **params.__dict__, - "deploy_time_params": params.deploy_time_params or self._deploy_time_params, - "schema": params.schema - or { - "global_bytes": self._app_spec.state.schemas["global"]["bytes"], - "global_ints": self._app_spec.state.schemas["global"]["ints"], - "local_bytes": self._app_spec.state.schemas["local"]["bytes"], - "local_ints": self._app_spec.state.schemas["local"]["ints"], - }, - "approval_program": self.compile(params).approval_program, - "clear_state_program": self.compile(params).clear_state_program, - }, - params.on_complete or transaction.OnComplete.NoOpOC, - ), - "deploy_update": lambda params: self._get_abi_params( - params.__dict__, transaction.OnComplete.UpdateApplicationOC - ), - "deploy_delete": lambda params: self._get_abi_params( - params.__dict__, transaction.OnComplete.DeleteApplicationOC - ), - "bare": { - "create": lambda params: self._get_bare_params( - { - **(params.__dict__ if params else {}), - "deploy_time_params": (params.deploy_time_params if params else None) - or self._deploy_time_params, - "schema": (params.schema if params else None) - or { - "global_bytes": self._app_spec.state.schemas["global"]["bytes"], - "global_ints": self._app_spec.state.schemas["global"]["ints"], - "local_bytes": self._app_spec.state.schemas["local"]["bytes"], - "local_ints": self._app_spec.state.schemas["local"]["ints"], - }, - **(self.compile(params).__dict__ if params else {}), - }, - (params.on_complete if params else None) or transaction.OnComplete.NoOpOC, - ), - "deploy_update": lambda params: self._get_bare_params( - params.__dict__ if params else {}, transaction.OnComplete.UpdateApplicationOC - ), - "deploy_delete": lambda params: self._get_bare_params( - params.__dict__ if params else {}, transaction.OnComplete.DeleteApplicationOC - ), - }, - } - - def _get_bare_params(self, params: dict[str, Any], on_complete: transaction.OnComplete) -> dict[str, Any]: - return { - **params, - "sender": self._get_sender(params.get("sender")), - "on_complete": on_complete, - } - - def _get_abi_params(self, params: dict[str, Any], on_complete: transaction.OnComplete) -> dict[str, Any]: - return { - **params, - "sender": self._get_sender(params.get("sender")), - "method": get_arc56_method(params["method"], self._app_spec), - "args": self._get_create_abi_args_with_default_values(params["method"], params.get("args")), - "on_complete": on_complete, - } - def _get_sender(self, sender: str | bytes | None) -> str: if not sender and not self._default_sender: raise Exception( @@ -450,35 +619,66 @@ def _get_sender(self, sender: str | bytes | None) -> str: ) return str(sender or self._default_sender) + def _handle_call_errors(self, call: Callable[[], T]) -> T: + try: + return call() + except Exception as e: + raise self.expose_logic_error(e) from None + + def _parse_method_call_return(self, result: SendAppTransactionResult, method: Method) -> SendAppTransactionResult: + return_value = result.return_value + if isinstance(return_value, dict): + return_value = get_arc56_return_value(return_value, method, self._app_spec.structs) + return SendAppTransactionResult( + **{ + **result.__dict__, + "return_value": return_value, + } + ) + def _get_create_abi_args_with_default_values( - self, method_name_or_signature: str, args: list[Any] | None + self, + method_name_or_signature: str | Arc56Method, + args: list[Any] | None, ) -> list[Any]: - method = get_arc56_method(method_name_or_signature, self._app_spec) + method = ( + get_arc56_method(method_name_or_signature, self._app_spec) + if isinstance(method_name_or_signature, str) + else method_name_or_signature + ) result = [] + def _has_struct(arg: Any) -> TypeGuard[MethodArg]: # noqa: ANN401 + return hasattr(arg, "struct") + for i, method_arg in enumerate(method.args): + arg = method_arg arg_value = args[i] if args and i < len(args) else None if arg_value is not None: - if hasattr(method_arg, "struct") and method_arg.struct and isinstance(arg_value, dict): + if _has_struct(arg) and arg.struct and isinstance(arg_value, dict): arg_value = get_abi_tuple_from_abi_struct( - arg_value, self._app_spec.structs[method_arg.struct], self._app_spec.structs + arg_value, + self._app_spec.structs[arg.struct], + self._app_spec.structs, ) result.append(arg_value) continue - if hasattr(method_arg, "default_value") and method_arg.default_value: - if method_arg.default_value.source == "literal": - value_raw = base64.b64decode(method_arg.default_value.data) - value_type = method_arg.default_value.type or str(method_arg.type) + default_value = getattr(arg, "default_value", None) + if default_value: + if default_value.source == "literal": + value_raw = base64.b64decode(default_value.data) + value_type = default_value.type or str(arg.type) result.append(get_abi_decoded_value(value_raw, value_type, self._app_spec.structs)) else: raise ValueError( - f"Can't provide default value for {method_arg.default_value.source} for a contract creation call" + f"Can't provide default value for {default_value.source} for a contract creation call" ) else: raise ValueError( - f"No value provided for required argument {method_arg.name or f'arg{i+1}'} in call to method {method.name}" + f"No value provided for required argument " + f"{arg.name or f'arg{i+1}'} in call to method {method.name}" ) return result diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 98ed28c..09f6028 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -22,20 +22,20 @@ ) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class BoxName: name: str name_raw: bytes name_base64: str -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class BoxValue: name: BoxName value: bytes -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class BoxABIValue: name: BoxName value: ABIValue @@ -130,7 +130,7 @@ def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) def _replace_template_variable(program_lines: list[str], template_variable: str, value: str) -> tuple[list[str], int]: result: list[str] = [] match_count = 0 - token = f"TMPL_{template_variable}" + token = f"TMPL_{template_variable}" if not template_variable.startswith("TMPL_") else template_variable token_idx_offset = len(value) - len(token) for line in program_lines: comment_idx = _find_unquoted_string(line, "//") diff --git a/src/algokit_utils/assets/asset_manager.py b/src/algokit_utils/assets/asset_manager.py index 8695614..1818471 100644 --- a/src/algokit_utils/assets/asset_manager.py +++ b/src/algokit_utils/assets/asset_manager.py @@ -14,7 +14,7 @@ ) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AccountAssetInformation: """Information about an account's holding of a particular asset.""" @@ -28,7 +28,7 @@ class AccountAssetInformation: """The round this information was retrieved at.""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetInformation: """Information about an asset.""" @@ -66,7 +66,7 @@ class AssetInformation: """32-byte hash of some metadata that is relevant to the asset and/or asset holders.""" -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class BulkAssetOptInOutResult: """Individual result from performing a bulk opt-in or bulk opt-out for an account against a series of assets.""" diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index b55ff01..43ab921 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -1,12 +1,17 @@ from dataclasses import dataclass import algosdk +from algosdk.atomic_transaction_composer import TransactionSigner from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient # from algokit_utils.applications.app_factory import AppFactory, AppFactoryParams +from algokit_utils._legacy_v2.application_specification import ApplicationSpecification +from algokit_utils.applications.app_factory import AppFactory, AppFactoryParams +from algokit_utils.applications.app_manager import TealTemplateParams from algokit_utils.clients.dispenser_api_client import TestNetDispenserApiClient +from algokit_utils.models.application import Arc56Contract from algokit_utils.network_clients import ( AlgoClientConfigs, get_algod_client, @@ -99,30 +104,33 @@ def get_testnet_dispenser( return TestNetDispenserApiClient(auth_token=auth_token) - # def get_app_factory( - # self, - # app_spec: Arc56Contract | ApplicationSpecification | str, - # app_name: str | None = None, - # default_sender: str | None = None, - # default_signer: TransactionSigner | None = None, - # version: str | None = None, - # updatable: bool | None = None, - # deletable: bool | None = None, - # deploy_time_params: TealTemplateParams | None = None, - # ) -> AppFactory: - # return AppFactory( - # AppFactoryParams( - # algorand=self._algorand, - # app_spec=app_spec, - # app_name=app_name, - # default_sender=default_sender, - # default_signer=default_signer, - # version=version, - # updatable=updatable, - # deletable=deletable, - # deploy_time_params=deploy_time_params, - # ) - # ) + def get_app_factory( + self, + app_spec: Arc56Contract | ApplicationSpecification | str, + app_name: str | None = None, + default_sender: str | None = None, + default_signer: TransactionSigner | None = None, + version: str | None = None, + updatable: bool | None = None, + deletable: bool | None = None, + deploy_time_params: TealTemplateParams | None = None, + ) -> AppFactory: + if not self._algorand: + raise ValueError("Attempt to get app factory from a ClientManager without an Algorand client") + + return AppFactory( + AppFactoryParams( + algorand=self._algorand, + app_spec=app_spec, + app_name=app_name, + default_sender=default_sender, + default_signer=default_signer, + version=version, + updatable=updatable, + deletable=deletable, + deploy_time_params=deploy_time_params, + ) + ) @staticmethod def genesis_id_is_local_net(genesis_id: str) -> bool: diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 06c229b..c04312c 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -108,7 +108,7 @@ class Recommendations: assets: list[int] | None = None -@dataclass(kw_only=True, frozen=True) +@dataclass(kw_only=True) class Method: name: str desc: str | None = None @@ -211,7 +211,7 @@ class Arc56Contract: scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppState: key_raw: bytes key_base64: str @@ -220,7 +220,7 @@ class AppState: value: str | int -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppInformation: app_id: int app_address: str @@ -235,7 +235,7 @@ class AppInformation: extra_program_pages: int | None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class CompiledTeal: teal: str compiled: bytes @@ -244,7 +244,7 @@ class CompiledTeal: source_map: algosdk.source_map.SourceMap | None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppCompilationResult: compiled_approval: CompiledTeal compiled_clear: CompiledTeal diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index dac07e1..cbdd60e 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -16,7 +16,7 @@ from algosdk.error import AlgodHTTPError from algosdk.transaction import OnComplete, Transaction from algosdk.v2client.algod import AlgodClient -from deprecated import deprecated +from typing_extensions import deprecated from algokit_utils._debugging import simulate_and_persist_response, simulate_response from algokit_utils.applications.app_manager import AppManager @@ -39,12 +39,12 @@ logger = logging.getLogger(__name__) -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class SenderParam: sender: str -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class CommonTxnParams(SendParams): """ Common transaction parameters. @@ -76,7 +76,7 @@ class CommonTxnParams(SendParams): last_valid_round: int | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class PaymentParams( CommonTxnParams, ): @@ -93,7 +93,7 @@ class PaymentParams( close_remainder_to: str | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetCreateParams( CommonTxnParams, ): @@ -129,7 +129,7 @@ class AssetCreateParams( metadata_hash: bytes | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetConfigParams( CommonTxnParams, ): @@ -153,7 +153,7 @@ class AssetConfigParams( clawback: str | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetFreezeParams( CommonTxnParams, ): @@ -170,7 +170,7 @@ class AssetFreezeParams( frozen: bool -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetDestroyParams( CommonTxnParams, ): @@ -183,7 +183,7 @@ class AssetDestroyParams( asset_id: int -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class OnlineKeyRegistrationParams( CommonTxnParams, ): @@ -209,7 +209,7 @@ class OnlineKeyRegistrationParams( state_proof_key: bytes | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetTransferParams( CommonTxnParams, ): @@ -230,7 +230,7 @@ class AssetTransferParams( close_asset_to: str | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetOptInParams( CommonTxnParams, ): @@ -243,7 +243,7 @@ class AssetOptInParams( asset_id: int -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AssetOptOutParams( CommonTxnParams, ): @@ -255,7 +255,7 @@ class AssetOptOutParams( creator: str -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppCallParams(CommonTxnParams, SenderParam): """ Application call parameters. @@ -286,7 +286,7 @@ class AppCallParams(CommonTxnParams, SenderParam): box_references: list[BoxReference] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppCreateParams(CommonTxnParams, SenderParam): """ Application create parameters. @@ -317,7 +317,7 @@ class AppCreateParams(CommonTxnParams, SenderParam): extra_program_pages: int | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppUpdateParams(CommonTxnParams, SenderParam): """ Application update parameters. @@ -340,7 +340,7 @@ class AppUpdateParams(CommonTxnParams, SenderParam): on_complete: OnComplete | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppDeleteParams( CommonTxnParams, SenderParam, @@ -355,7 +355,7 @@ class AppDeleteParams( on_complete: OnComplete = OnComplete.DeleteApplicationOC -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppMethodCall(CommonTxnParams, SenderParam): """Base class for ABI method calls.""" @@ -368,7 +368,7 @@ class AppMethodCall(CommonTxnParams, SenderParam): box_references: list[BoxReference] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppMethodCallParams(CommonTxnParams, SenderParam): """ Method call parameters. @@ -389,7 +389,7 @@ class AppMethodCallParams(CommonTxnParams, SenderParam): box_references: list[BoxReference] | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppCallMethodCall(AppMethodCall): """Parameters for a regular ABI method call. @@ -408,7 +408,7 @@ class AppCallMethodCall(AppMethodCall): on_complete: OnComplete | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppCreateMethodCall(AppMethodCall): """Parameters for an ABI method call that creates an application. @@ -426,7 +426,7 @@ class AppCreateMethodCall(AppMethodCall): extra_program_pages: int | None = None -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppUpdateMethodCall(AppMethodCall): """Parameters for an ABI method call that updates an application. @@ -441,7 +441,7 @@ class AppUpdateMethodCall(AppMethodCall): on_complete: OnComplete = OnComplete.UpdateApplicationOC -@dataclass(frozen=True, kw_only=True) +@dataclass(kw_only=True, frozen=True) class AppDeleteMethodCall(AppMethodCall): """Parameters for an ABI method call that deletes an application. @@ -484,7 +484,7 @@ class AppDeleteMethodCall(AppMethodCall): ] -@dataclass +@dataclass(frozen=True) class BuiltTransactions: """ Set of transactions built by TransactionComposer. @@ -510,7 +510,7 @@ class TransactionComposerBuildResult: class SendAtomicTransactionComposerResults: """Results from sending an AtomicTransactionComposer transaction group""" - group_id: str | None + group_id: str """The group ID if this was a transaction group""" confirmations: list[algosdk.v2client.algod.AlgodResponseType] """The confirmation info for each transaction""" @@ -529,7 +529,7 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 *, max_rounds_to_wait: int | None = 5, skip_waiting: bool = False, - suppress_log: bool = False, + suppress_log: bool | None = None, populate_resources: bool | None = None, # TODO: implement/clarify ) -> SendAtomicTransactionComposerResults: """Send an AtomicTransactionComposer transaction group @@ -597,7 +597,7 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 # Return results return SendAtomicTransactionComposerResults( - group_id=group_id, + group_id=group_id or "", confirmations=confirmations or [], tx_ids=[t.get_txid() for t in transactions_to_send], transactions=transactions_to_send, @@ -829,7 +829,7 @@ def build_transactions(self) -> BuiltTransactions: return BuiltTransactions(transactions=transactions, method_calls=method_calls, signers=signers) - @deprecated(reason="Use send() instead", version="3.0.0") + @deprecated("Use send() instead") def execute( self, *, @@ -843,8 +843,8 @@ def send( self, *, max_rounds_to_wait: int | None = None, - suppress_log: bool = False, - populate_app_call_resources: bool = False, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, ) -> SendAtomicTransactionComposerResults: group = self.build().transactions @@ -1066,6 +1066,8 @@ def _build_method_call( # noqa: C901, PLR0912 boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] if params.box_references else None, + approval_program=params.approval_program if isinstance(params, AppCreateMethodCall) else None, # type: ignore[arg-type] + clear_program=params.clear_state_program if isinstance(params, AppCreateMethodCall) else None, # type: ignore[arg-type] ) return self._build_atc(method_atc) diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index eaeb46b..cfb4682 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -36,9 +36,8 @@ logger = getLogger(__name__) -@dataclass +@dataclass(frozen=True, kw_only=True) class SendSingleTransactionResult: - tx_id: str # Single transaction ID (last from txIds array) transaction: Transaction # Last transaction confirmation: algosdk.v2client.algod.AlgodResponseType # Last confirmation @@ -49,32 +48,29 @@ class SendSingleTransactionResult: confirmations: list[algosdk.v2client.algod.AlgodResponseType] returns: list[algosdk.atomic_transaction_composer.ABIResult] | None = None - # Fields from AssetCreateParams - asset_id: int | None = None +@dataclass(frozen=True, kw_only=True) +class SendSingleAssetCreateTransactionResult(SendSingleTransactionResult): + asset_id: int -@dataclass + +@dataclass(frozen=True) class SendAppTransactionResult(SendSingleTransactionResult): return_value: ABIValue | None = None -@dataclass +@dataclass(frozen=True) class SendAppUpdateTransactionResult(SendAppTransactionResult): compiled_approval: Any | None = None compiled_clear: Any | None = None -@dataclass -class _RequiredSendAppTransactionResult: +@dataclass(frozen=True, kw_only=True) +class SendAppCreateTransactionResult(SendAppUpdateTransactionResult): app_id: int app_address: str -@dataclass -class SendAppCreateTransactionResult(SendAppUpdateTransactionResult, _RequiredSendAppTransactionResult): - pass - - class LogConfig(TypedDict, total=False): pre_log: Callable[[TxnParams, Transaction], str] post_log: Callable[[TxnParams, AtomicTransactionResponse], str] @@ -123,7 +119,6 @@ def send_transaction(params: T) -> SendSingleTransactionResult: **raw_result_dict, confirmation=raw_result.confirmations[-1], transaction=raw_result.transactions[-1], - tx_id=raw_result.tx_ids[-1], ) if post_log: @@ -212,7 +207,7 @@ def payment(self, params: PaymentParams) -> SendSingleTransactionResult: ), )(params) - def asset_create(self, params: AssetCreateParams) -> SendSingleTransactionResult: + def asset_create(self, params: AssetCreateParams) -> SendSingleAssetCreateTransactionResult: """Create a new Algorand Standard Asset.""" result = self._send( lambda c: c.add_asset_create, @@ -225,11 +220,10 @@ def asset_create(self, params: AssetCreateParams) -> SendSingleTransactionResult ), )(params) - result = SendSingleTransactionResult( + return SendSingleAssetCreateTransactionResult( **result.__dict__, + asset_id=int(result.confirmation["asset-index"]), # type: ignore[call-overload] ) - result.asset_id = int(result.confirmation["asset-index"]) # type: ignore[call-overload] - return result def asset_config(self, params: AssetConfigParams) -> SendSingleTransactionResult: """Configure an existing Algorand Standard Asset.""" diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index 35c0c7e..32fe3b4 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -1,72 +1,138 @@ -# from pathlib import Path - -# import pytest - -# from algokit_utils.applications.app_factory import AppFactory, AppFactoryDeployParams -# from algokit_utils.clients.algorand_client import AlgorandClient -# from algokit_utils.models.account import Account - - -# @pytest.fixture -# def algorand(funded_account: Account) -> AlgorandClient: -# client = AlgorandClient.default_local_net() -# client.set_signer(sender=funded_account.address, signer=funded_account.signer) -# return client - - -# @pytest.fixture -# def factory(algorand: AlgorandClient, funded_account: Account) -> AppFactory: -# """Create AppFactory fixture""" -# raw_arc56_spec = (Path(__file__).parent.parent / "artifacts" / "hello_world" / "arc32_app_spec.json").read_text() -# return algorand.client.get_app_factory(app_spec=raw_arc56_spec, default_sender=funded_account.address) - - -# class TestARC56: -# def test_error_messages_with_template_vars(self, factory: AppFactory) -> None: -# """Test ARC56 error messages with dynamic template variables""" -# # Deploy app -# result = factory.deploy( -# AppFactoryDeployParams( -# create_params={"method": "createApplication"}, -# deploy_time_params={ -# "bytes64TmplVar": "0" * 64, -# "uint64TmplVar": 123, -# "bytes32TmplVar": "0" * 32, -# "bytesTmplVar": "foo", -# }, -# ) -# ) -# app_client = result.app_client - -# # Test error handling -# with pytest.raises(Exception) as exc: -# app_client.call(method="throwError") - -# assert "this is an error" in str(exc.value) - -# def test_undefined_error_message(self, factory: AppFactory) -> None: -# """Test ARC56 undefined error message with template variables""" -# # Deploy app -# result = factory.deploy( -# create_params={"method": "createApplication"}, -# deploy_time_params={ -# "bytes64TmplVar": "0" * 64, -# "uint64TmplVar": 0, -# "bytes32TmplVar": "0" * 32, -# "bytesTmplVar": "foo", -# }, -# ) -# app_id = result.app_id - -# # Create new client without source maps -# app_client = AppClient( -# app_id=app_id, algod=algod, app_spec=arc56_json, default_sender=get_localnet_default_account() -# ) - -# # Test error handling -# with pytest.raises(Exception) as exc: -# app_client.call(method="tmpl") - -# error_stack = "\n".join(line.strip() for line in str(exc.value).split("\n")) -# assert "assert <--- Error" in error_stack -# assert "intc 1 // TMPL_uint64TmplVar" in error_stack +from pathlib import Path + +import pytest +from algosdk.logic import get_application_address +from algosdk.transaction import ApplicationCreateTxn, OnComplete + +from algokit_utils._legacy_v2.deploy import OnSchemaBreak, OnUpdate, OperationPerformed +from algokit_utils.applications.app_client import AppClientMethodCallParams +from algokit_utils.applications.app_factory import AppFactory, AppFactoryCreateWithSendParams +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount + + +@pytest.fixture +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +@pytest.fixture +def app_spec() -> str: + return (Path(__file__).parent.parent / "artifacts" / "testing_app" / "arc32_app_spec.json").read_text() + + +@pytest.fixture +def factory(algorand: AlgorandClient, funded_account: Account, app_spec: str) -> AppFactory: + """Create AppFactory fixture""" + return algorand.client.get_app_factory(app_spec=app_spec, default_sender=funded_account.address) + + +def test_create_app(factory: AppFactory) -> None: + """Test creating an app using the factory""" + app_client, result = factory.send.bare.create( + params=AppFactoryCreateWithSendParams( + deploy_time_params={ + # It should strip off the TMPL_ + "TMPL_UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + } + ) + ) + + assert app_client.app_id > 0 + assert app_client.app_address == get_application_address(app_client.app_id) + assert isinstance(result.confirmation, dict) + assert result.confirmation.get("application-index", 0) == app_client.app_id + assert result.compiled_approval is not None + assert result.compiled_clear is not None + + +def test_create_app_with_constructor_deploy_time_params(algorand: AlgorandClient, app_spec: str) -> None: + """Test creating an app using the factory with constructor deploy time params""" + random_account = algorand.account.random() + dispenser_account = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + account_fo_fund=random_account.address, + dispenser_account=dispenser_account.address, + min_spending_balance=AlgoAmount.from_algo(10), + min_funding_increment=AlgoAmount.from_algo(1), + ) + + factory = algorand.client.get_app_factory( + app_spec=app_spec, + default_sender=random_account.address, + deploy_time_params={ + # It should strip off the TMPL_ + "TMPL_UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + }, + ) + + app_client, result = factory.send.bare.create() + + assert result.app_id > 0 + assert app_client.app_id == result.app_id + + +def test_create_app_with_oncomplete_overload(factory: AppFactory) -> None: + app_client, result = factory.send.bare.create( + params=AppFactoryCreateWithSendParams( + on_complete=OnComplete.OptInOC, + updatable=True, + deletable=True, + deploy_time_params={ + "VALUE": 1, + }, + ) + ) + + assert isinstance(result.transaction, ApplicationCreateTxn) + assert result.transaction.on_complete == OnComplete.OptInOC + assert app_client.app_id > 0 + assert app_client.app_address == get_application_address(app_client.app_id) + assert isinstance(result.confirmation, dict) + assert result.confirmation.get("application-index", 0) == app_client.app_id + + +def test_deploy_when_immutable_and_permanent(factory: AppFactory) -> None: + factory.deploy( + deletable=False, + updatable=False, + on_schema_break=OnSchemaBreak.Fail, + on_update=OnUpdate.Fail, + deploy_time_params={ + "VALUE": 1, + }, + ) + + +def test_deploy_app_create(factory: AppFactory) -> None: + app_client, result = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + ) + + assert result.operation_performed == OperationPerformed.Create + assert result.app_id > 0 + assert app_client.app_id == result.app_id == result.confirmation["application-index"] # type: ignore[call-overload] + assert app_client.app_address == get_application_address(app_client.app_id) + + +def test_deploy_app_create_abi(factory: AppFactory) -> None: + app_client, result = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + create_params=AppClientMethodCallParams(method="create_abi", args=["arg_io"]), + ) + + assert result.operation_performed == OperationPerformed.Create + assert result.app_id > 0 + assert app_client.app_id == result.app_id == result.confirmation["application-index"] # type: ignore[call-overload] + assert app_client.app_address == get_application_address(app_client.app_id) diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index e6db07a..cd034db 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -222,7 +222,7 @@ def test_create_app_call_method_call_transaction(algorand: AlgorandClient, funde schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, ) ) - app_id = algorand.client.algod.pending_transaction_info(create_result.tx_id)["application-index"] # type: ignore[call-overload] + app_id = algorand.client.algod.pending_transaction_info(create_result.tx_ids[0])["application-index"] # type: ignore[call-overload] # Then test creating a method call transaction result = algorand.create_transaction.app_call_method_call( From 1e224bd64a51ed4a9929e2d55dc1902bb6e2a132 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Fri, 6 Dec 2024 14:17:23 +0100 Subject: [PATCH 13/19] chore: wip --- legacy_v2_tests/conftest.py | 6 +- src/algokit_utils/_legacy_v2/account.py | 2 +- src/algokit_utils/accounts/account_manager.py | 279 ++++++++++++++---- .../accounts/kmd_account_manager.py | 190 ++++++++++++ .../applications/app_deployer.py | 101 ++++++- src/algokit_utils/applications/app_factory.py | 26 +- src/algokit_utils/applications/utils.py | 21 +- src/algokit_utils/clients/algorand_client.py | 31 +- src/algokit_utils/clients/client_manager.py | 177 ++++++++++- src/algokit_utils/config.py | 48 ++- src/algokit_utils/models/account.py | 4 +- src/algokit_utils/models/network.py | 20 ++ .../transactions/transaction_composer.py | 15 +- tests/accounts/__init__.py | 0 tests/accounts/test_account_manager.py | 99 +++++++ tests/applications/test_app_factory.py | 84 +++++- tests/clients/test_algorand_client.py | 26 +- 17 files changed, 972 insertions(+), 157 deletions(-) create mode 100644 src/algokit_utils/accounts/kmd_account_manager.py create mode 100644 src/algokit_utils/models/network.py create mode 100644 tests/accounts/__init__.py create mode 100644 tests/accounts/test_account_manager.py diff --git a/legacy_v2_tests/conftest.py b/legacy_v2_tests/conftest.py index dbe4be4..f8989eb 100644 --- a/legacy_v2_tests/conftest.py +++ b/legacy_v2_tests/conftest.py @@ -8,6 +8,8 @@ import algosdk.transaction import pytest +from dotenv import load_dotenv + from algokit_utils import ( DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -22,8 +24,6 @@ get_kmd_client_from_algod_client, replace_template_variables, ) -from dotenv import load_dotenv - from legacy_v2_tests import app_client_test if TYPE_CHECKING: @@ -142,7 +142,7 @@ def indexer_client() -> "IndexerClient": return get_indexer_client() -@pytest.fixture() +@pytest.fixture def creator(algod_client: "AlgodClient") -> Account: creator_name = get_unique_name() return get_account(algod_client, creator_name) diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index f72a646..0fef469 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -35,7 +35,7 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" private_key = to_private_key(mnemonic) - address = address_from_private_key(private_key) + address = str(address_from_private_key(private_key)) return Account(private_key=private_key, address=address) diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index 9ef0e89..a4e13b5 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -1,29 +1,37 @@ +import os from collections.abc import Callable from dataclasses import dataclass from typing import Any -from algosdk.account import generate_account +from algosdk import mnemonic from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner +from algosdk.mnemonic import to_private_key from algosdk.transaction import SuggestedParams from typing_extensions import Self -from algokit_utils.account import get_dispenser_account, get_kmd_wallet_account, get_localnet_default_account +from algokit_utils.accounts.kmd_account_manager import KmdAccountManager from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.clients.dispenser_api_client import DispenserAssetName, TestNetDispenserApiClient +from algokit_utils.models.account import DISPENSER_ACCOUNT_NAME, Account from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult @dataclass(frozen=True, kw_only=True) -class AddressAndSigner: - address: str - signer: TransactionSigner +class _CommonEnsureFundedParams: + transaction_id: str + amount_funded: AlgoAmount @dataclass(frozen=True, kw_only=True) -class EnsureFundedResponse(SendSingleTransactionResult): - transaction_id: str - amount_funded: AlgoAmount +class EnsureFundedResponse(SendSingleTransactionResult, _CommonEnsureFundedParams): + pass + + +@dataclass(frozen=True, kw_only=True) +class EnsureFundedFromTestnetDispenserApiResponse(_CommonEnsureFundedParams): + pass class AccountManager: @@ -36,14 +44,15 @@ def __init__(self, client_manager: ClientManager): :param client_manager: The ClientManager client to use for algod and kmd clients """ self._client_manager = client_manager - self._accounts = dict[str, TransactionSigner]() + self._kmd_account_manager = KmdAccountManager(client_manager) + self._accounts = dict[str, Account]() self._default_signer: TransactionSigner | None = None def set_default_signer(self, signer: TransactionSigner) -> Self: """ Sets the default signer to use if no other signer is specified. - :param signer: The signer to use, either a `TransactionSigner` or a `TransactionSignerAccount` + :param signer: The signer to use :return: The `AccountManager` so method calls can be chained """ self._default_signer = signer @@ -57,9 +66,16 @@ def set_signer(self, sender: str, signer: TransactionSigner) -> Self: :param signer: The signer to sign transactions with for the given sender :return: The AccountCreator instance for method chaining """ - self._accounts[sender] = signer + if isinstance(signer, AccountTransactionSigner): + self._accounts[sender] = Account(private_key=signer.private_key) return self + def get_account(self, sender: str) -> Account: + account = self._accounts.get(sender) + if not account: + raise ValueError(f"No account found for address {sender}") + return account + def get_signer(self, sender: str) -> TransactionSigner: """ Returns the `TransactionSigner` for the given sender address. @@ -69,7 +85,8 @@ def get_signer(self, sender: str) -> TransactionSigner: :param sender: The sender address :return: The `TransactionSigner` or throws an error if not found """ - signer = self._accounts.get(sender, None) or self._default_signer + account = self._accounts.get(sender) + signer = account.signer if account else self._default_signer if not signer: raise ValueError(f"No signer found for address {sender}") return signer @@ -78,12 +95,6 @@ def get_information(self, sender: str) -> dict[str, Any]: """ Returns the given sender account's current status, balance and spendable amounts. - Example: - address = "XBYLS2E6YI6XXL5BWCAMOA4GTWHXWENZMX5UHXMRNWWUQ7BXCY5WC5TEPA" - account_info = account.get_information(address) - - `Response data schema details `_ - :param sender: The address of the sender/account to look up :return: The account information """ @@ -91,68 +102,70 @@ def get_information(self, sender: str) -> dict[str, Any]: assert isinstance(info, dict) return info - def get_asset_information(self, sender: str, asset_id: int) -> dict[str, Any]: - info = self._client_manager.algod.account_asset_info(sender, asset_id) - assert isinstance(info, dict) - return info + def from_mnemonic(self, mnemonic: str) -> Account: + private_key = to_private_key(mnemonic) + account = Account(private_key=private_key) + self._accounts[account.address] = account + return account - def from_kmd( - self, - name: str, - predicate: Callable[[dict[str, Any]], bool] | None = None, - ) -> AddressAndSigner: - account = get_kmd_wallet_account( - name=name, predicate=predicate, client=self._client_manager.algod, kmd_client=self._client_manager.kmd - ) - if not account: - raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}") + def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> Account: + account_mnemonic = os.getenv(f"{name.upper()}_MNEMONIC") - self.set_signer(account.address, account.signer) - return AddressAndSigner(address=account.address, signer=account.signer) + if account_mnemonic: + private_key = mnemonic.to_private_key(account_mnemonic) + account = Account(private_key=private_key) + self._accounts[account.address] = account + return account - def random(self) -> AddressAndSigner: - """ - Tracks and returns a new, random Algorand account with secret key loaded. + if self._client_manager.is_local_net(): + kmd_account = self._kmd_account_manager.get_or_create_wallet_account(name, fund_with) + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + return account - Example: - account = account.random() + raise ValueError(f"Missing environment variable {name.upper()}_MNEMONIC when looking for account {name}") - :return: The account - """ - (sk, addr) = generate_account() - signer = AccountTransactionSigner(sk) + def from_kmd( + self, name: str, predicate: Callable[[dict[str, Any]], bool] | None = None, sender: str | None = None + ) -> Account: + kmd_account = self._kmd_account_manager.get_wallet_account(name, predicate, sender) + if not kmd_account: + raise ValueError(f"Unable to find KMD account {name}{' with predicate' if predicate else ''}") - self.set_signer(str(addr), signer) + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + return account - return AddressAndSigner(address=str(addr), signer=signer) + def rekeyed(self, sender: str, account: Account) -> Account: + self._accounts[sender] = account + return account - def dispenser(self) -> AddressAndSigner: + def random(self) -> Account: """ - Returns an account (with private key loaded) that can act as a dispenser. - - Example: - account = account.dispenser() - - If running on LocalNet then it will return the default dispenser account automatically, - otherwise it will load the account mnemonic stored in os.environ['DISPENSER_MNEMONIC']. + Tracks and returns a new, random Algorand account. :return: The account """ - acct = get_dispenser_account(self._client_manager.algod) - - self.set_signer(acct.address, acct.signer) - - return AddressAndSigner(address=acct.address, signer=acct.signer) - - def localnet_dispenser(self) -> AddressAndSigner: - acct = get_localnet_default_account(self._client_manager.algod) - self.set_signer(acct.address, acct.signer) - return AddressAndSigner(address=acct.address, signer=acct.signer) + account = Account.new_account() + self._accounts[account.address] = account + return account + + def localnet_dispenser(self) -> Account: + kmd_account = self._kmd_account_manager.get_localnet_dispenser_account() + account = Account(private_key=kmd_account.private_key) + self._accounts[account.address] = account + return account + + def dispenser_from_environment(self) -> Account: + name = os.getenv(f"{DISPENSER_ACCOUNT_NAME}_MNEMONIC") + if name: + return self.from_environment(DISPENSER_ACCOUNT_NAME) + return self.localnet_dispenser() def ensure_funded( # noqa: PLR0913 self, - account_fo_fund: str, - dispenser_account: str, + account_fo_fund: str | Account, + dispenser_account: str | Account, min_spending_balance: AlgoAmount, min_funding_increment: AlgoAmount, # Sender params @@ -171,6 +184,8 @@ def ensure_funded( # noqa: PLR0913 first_valid_round: int | None = None, last_valid_round: int | None = None, ) -> EnsureFundedResponse | None: + account_fo_fund = account_fo_fund.address if isinstance(account_fo_fund, Account) else account_fo_fund + dispenser_account = dispenser_account.address if isinstance(dispenser_account, Account) else dispenser_account amount_funded = self._get_ensure_funded_amount(account_fo_fund, min_spending_balance, min_funding_increment) if not amount_funded: @@ -214,6 +229,140 @@ def ensure_funded( # noqa: PLR0913 amount_funded=amount_funded, ) + def ensure_funded_from_environment( # noqa: PLR0913 + self, + account_to_fund: str | Account, + min_spending_balance: AlgoAmount, + *, # Force remaining params to be keyword-only + min_funding_increment: AlgoAmount | None = None, + # SendParams + max_rounds_to_wait: int | None = None, + suppress_log: bool | None = None, + populate_app_call_resources: bool | None = None, + # Common transaction params (omitting sender) + signer: TransactionSigner | None = None, + rekey_to: str | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + ) -> EnsureFundedResponse | None: + """Ensure an account is funded from a dispenser account configured in environment. + + Args: + account_to_fund: Address of account to fund + min_spending_balance: Minimum spending balance to ensure + min_funding_increment: Optional minimum funding increment + max_rounds_to_wait: Optional maximum rounds to wait for transaction + suppress_log: Optional flag to suppress logging + populate_app_call_resources: Optional flag to populate app call resources + signer: Optional transaction signer + rekey_to: Optional rekey address + note: Optional transaction note + lease: Optional transaction lease + static_fee: Optional static fee + extra_fee: Optional extra fee + max_fee: Optional maximum fee + validity_window: Optional validity window + first_valid_round: Optional first valid round + last_valid_round: Optional last valid round + + Returns: + EnsureFundedResponse if funding was needed, None otherwise + """ + account_to_fund = account_to_fund.address if isinstance(account_to_fund, Account) else account_to_fund + dispenser_account = self.dispenser_from_environment() + + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) + + if not amount_funded: + return None + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=dispenser_account.address, + receiver=account_to_fund, + amount=amount_funded, + signer=signer, + rekey_to=rekey_to, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + ) + ) + .send( + max_rounds_to_wait=max_rounds_to_wait, + suppress_log=suppress_log, + populate_app_call_resources=populate_app_call_resources, + ) + ) + + return EnsureFundedResponse( + returns=result.returns, + transactions=result.transactions, + confirmations=result.confirmations, + tx_ids=result.tx_ids, + group_id=result.group_id, + transaction_id=result.tx_ids[0], + confirmation=result.confirmations[0], + transaction=result.transactions[0], + amount_funded=amount_funded, + ) + + def ensure_funded_from_testnet_dispenser_api( + self, + account_to_fund: str | Account, + dispenser_client: TestNetDispenserApiClient, + min_spending_balance: AlgoAmount, + *, # Force remaining params to be keyword-only + min_funding_increment: AlgoAmount | None = None, + ) -> EnsureFundedFromTestnetDispenserApiResponse | None: + """Ensure an account is funded using the TestNet Dispenser API. + + Args: + account_to_fund: Address of account to fund + dispenser_client: Instance of TestNetDispenserApiClient to use for funding + min_spending_balance: Minimum spending balance to ensure + min_funding_increment: Optional minimum funding increment + + Returns: + EnsureFundedResponse if funding was needed, None otherwise + + Raises: + ValueError: If attempting to fund on non-TestNet network + """ + account_to_fund = account_to_fund.address if isinstance(account_to_fund, Account) else account_to_fund + + if not self._client_manager.is_test_net(): + raise ValueError("Attempt to fund using TestNet dispenser API on non TestNet network.") + + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) + + if not amount_funded: + return None + + result = dispenser_client.fund( + address=account_to_fund, + amount=amount_funded.micro_algo, + asset_id=DispenserAssetName.ALGO, + ) + + return EnsureFundedFromTestnetDispenserApiResponse( + transaction_id=result.tx_id, + amount_funded=amount_funded, + ) + def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer: if get_suggested_params is None: diff --git a/src/algokit_utils/accounts/kmd_account_manager.py b/src/algokit_utils/accounts/kmd_account_manager.py new file mode 100644 index 0000000..9af13dd --- /dev/null +++ b/src/algokit_utils/accounts/kmd_account_manager.py @@ -0,0 +1,190 @@ +import logging +from collections.abc import Callable +from typing import Any, cast + +from algosdk.kmd import KMDClient + +from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer + +logger = logging.getLogger(__name__) + + +class KmdAccount(Account): + """Account retrieved from KMD with signing capabilities, extending base Account""" + + def __init__(self, private_key: str, address: str | None = None) -> None: + """Initialize KMD account with private key and optional address override + + Args: + private_key: Base64 encoded private key + address: Optional address override (for rekeyed accounts) + """ + super().__init__(private_key=private_key, address=address or "") + + +class KmdAccountManager: + """Provides abstractions over KMD that makes it easier to get and manage accounts.""" + + _kmd: KMDClient | None + + def __init__(self, client_manager: ClientManager) -> None: + """Create a new KMD manager. + + Args: + client_manager: ClientManager to use for account management + """ + self._client_manager = client_manager + try: + self._kmd = client_manager.kmd + except ValueError: + self._kmd = None + + def kmd(self) -> KMDClient: + """Get the KMD client, initializing it if needed. + + Returns: + KMDClient: The initialized KMD client + + Raises: + Exception: If KMD is not configured + """ + if self._kmd is None: + if self._client_manager.is_local_net(): + kmd_config = ClientManager.get_config_from_environment_or_localnet() + self._kmd = ClientManager.get_kmd_client(kmd_config.kmd_config) + return self._kmd + raise Exception("Attempt to use KMD client with no KMD configured") + return self._kmd + + def get_wallet_account( + self, + wallet_name: str, + predicate: Callable[[dict[str, Any]], bool] | None = None, + sender: str | None = None, + ) -> KmdAccount | None: + """Returns an Algorand signing account with private key loaded from the given KMD wallet. + + Args: + wallet_name: The name of the wallet to retrieve an account from + predicate: Optional filter to use to find the account (otherwise returns a random account from the wallet) + sender: Optional sender address to use this signer for (aka a rekeyed account) + + Returns: + Optional[KmdAccount]: The signing account or None if no matching wallet or account was found + + Example: + ```python + # Get default funded account in a LocalNet + default_dispenser = kmd_manager.get_wallet_account( + "unencrypted-default-wallet", + lambda a: a["status"] != "Offline" and a["amount"] > 1_000_000_000 + ) + ``` + """ + kmd_client = self.kmd() + wallets = kmd_client.list_wallets() + wallet = next((w for w in wallets if w["name"] == wallet_name), None) + if not wallet: + return None + + wallet_id = wallet["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + addresses = kmd_client.list_keys(wallet_handle) + + matched_address = None + if predicate: + for address in addresses: + account_info = self._client_manager.algod.account_info(address) + if predicate(cast(dict[str, Any], account_info)): + matched_address = address + break + else: + matched_address = next(iter(addresses), None) + + if not matched_address: + return None + + private_key = kmd_client.export_key(wallet_handle, "", matched_address) + return KmdAccount(private_key=private_key, address=sender) + + def get_or_create_wallet_account(self, name: str, fund_with: AlgoAmount | None = None) -> KmdAccount: + """Gets or creates a funded account in a KMD wallet of the given name. + + This is useful to get idempotent accounts from LocalNet without having to specify the private key + (which will change when resetting the LocalNet). + + Args: + name: The name of the wallet to retrieve / create + fund_with: The number of Algos to fund the account with when created (default: 1000) + + Returns: + KmdAccount: An Algorand account with private key loaded + + Example: + ```python + # Idempotently get (if exists) or create (if doesn't exist) an account by name using KMD + # if creating it then fund it with 2 ALGO from the default dispenser account + new_account = kmd_manager.get_or_create_wallet_account("account1", 2) + # This will return the same account as above since the name matches + existing_account = kmd_manager.get_or_create_wallet_account("account1") + ``` + """ + existing = self.get_wallet_account(name) + if existing: + return existing + + kmd_client = self.kmd() + wallet_id = kmd_client.create_wallet(name, "")["id"] + wallet_handle = kmd_client.init_wallet_handle(wallet_id, "") + kmd_client.generate_key(wallet_handle) + + account = self.get_wallet_account(name) + assert account is not None + + logger.info( + f"LocalNet account '{name}' doesn't yet exist; created account {account.address} " + f"with keys stored in KMD and funding with {fund_with} ALGO" + ) + + dispenser = self.get_localnet_dispenser_account() + TransactionComposer( + algod=self._client_manager.algod, + get_signer=lambda _: dispenser.signer, + get_suggested_params=self._client_manager.algod.suggested_params, + ).add_payment( + PaymentParams( + sender=dispenser.address, + receiver=account.address, + amount=fund_with or AlgoAmount.from_algo(1000), + ) + ).send() + return account + + def get_localnet_dispenser_account(self) -> KmdAccount: + """Returns an Algorand account with private key loaded for the default LocalNet dispenser account. + + Returns: + KmdAccount: The default LocalNet dispenser account + + Raises: + Exception: If not running against LocalNet or dispenser account not found + + Example: + ```python + dispenser = kmd_manager.get_localnet_dispenser_account() + ``` + """ + if not self._client_manager.is_local_net(): + raise Exception("Can't get LocalNet dispenser account from non LocalNet network") + + dispenser = self.get_wallet_account( + "unencrypted-default-wallet", + lambda a: a["status"] != "Offline" and a["amount"] > 1_000_000_000, # noqa: PLR2004 + ) + if not dispenser: + raise Exception("Error retrieving LocalNet dispenser account; couldn't find the default account in KMD") + + return dispenser diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 6e75ad5..23c9e1a 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -1,7 +1,6 @@ import base64 import dataclasses import json -import logging from dataclasses import dataclass from typing import Literal @@ -19,7 +18,8 @@ OnUpdate, OperationPerformed, ) -from algokit_utils.applications.app_manager import AppManager, TealTemplateParams +from algokit_utils.applications.app_manager import AppManager, BoxReference, TealTemplateParams +from algokit_utils.config import config from algokit_utils.models.abi import ABIValue from algokit_utils.transactions.transaction_composer import ( AppCreateMethodCall, @@ -27,6 +27,7 @@ AppDeleteMethodCall, AppDeleteParams, AppUpdateMethodCall, + AppUpdateParams, ) from algokit_utils.transactions.transaction_sender import ( AlgorandClientTransactionSender, @@ -34,7 +35,7 @@ APP_DEPLOY_NOTE_DAPP = "algokit_deployer" -logger = logging.getLogger(__name__) +logger = config.logger @dataclass(kw_only=True) @@ -48,10 +49,10 @@ class DeployAppUpdateParams: note: bytes | None = None lease: bytes | None = None rekey_to: str | None = None - boxes: list[tuple[int, bytes]] | None = None - accounts: list[str] | None = None - foreign_apps: list[int] | None = None - foreign_assets: list[int] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None @dataclass(kw_only=True) @@ -64,10 +65,10 @@ class DeployAppDeleteParams: note: bytes | None = None lease: bytes | None = None rekey_to: str | None = None - boxes: list[tuple[int, bytes]] | None = None - accounts: list[str] | None = None - foreign_apps: list[int] | None = None - foreign_assets: list[int] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None @dataclass(kw_only=True) @@ -144,6 +145,14 @@ def _create_deploy_note(self, metadata: AppDeployMetaData) -> bytes: def deploy(self, deployment: AppDeployParams) -> AppDeployResult: # Create new instances with updated notes + logger.info( + f"Idempotently deploying app \"{deployment.metadata.name}\" from creator " + f"{deployment.create_params.sender} using {len(deployment.create_params.approval_program)} bytes of " + f"{'teal code' if isinstance(deployment.create_params.approval_program, str) else 'AVM bytecode'} and " + f"{len(deployment.create_params.clear_state_program)} bytes of " + f"{'teal code' if isinstance(deployment.create_params.clear_state_program, str) else 'AVM bytecode'}", + suppress_log=deployment.suppress_log, + ) note = self._create_deploy_note(deployment.metadata) create_params = dataclasses.replace(deployment.create_params, note=note) update_params = dataclasses.replace(deployment.update_params, note=note) @@ -226,6 +235,20 @@ def deploy(self, deployment: AppDeployParams) -> AppDeployResult: ) if is_schema_break: + logger.warning( + f"Detected a breaking app schema change in app {existing_app.app_id}:", + extra={ + "from": { + "global_ints": existing_app_record.global_ints, + "global_byte_slices": existing_app_record.global_byte_slices, + "local_ints": existing_app_record.local_ints, + "local_byte_slices": existing_app_record.local_byte_slices, + }, + "to": deployment.create_params.schema, + }, + suppress_log=deployment.suppress_log, + ) + return self._handle_schema_break( deployment=deployment, existing_app=existing_app, @@ -427,6 +450,58 @@ def _create_and_delete_app( }, ) + def _update_app( + self, + deployment: AppDeployParams, + existing_app: AppMetaData, + approval_program: bytes, + clear_program: bytes, + ) -> AppDeployResult: + """Update an existing application""" + + if isinstance(deployment.update_params, AppUpdateMethodCall): + result = self._transaction_sender.app_update_method_call( + AppUpdateMethodCall( + **{ + **deployment.update_params.__dict__, + "app_id": existing_app.app_id, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + else: + result = self._transaction_sender.app_update( + AppUpdateParams( + **{ + **deployment.update_params.__dict__, + "app_id": existing_app.app_id, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) + ) + + app_metadata = AppMetaData( + app_id=existing_app.app_id, + app_address=existing_app.app_address, + created_metadata=existing_app.created_metadata, + created_round=existing_app.created_round, + updated_round=result.confirmation.get("confirmed-round", 0) if isinstance(result.confirmation, dict) else 0, + **deployment.metadata.__dict__, + deleted=False, + ) + + self._update_app_lookup(deployment.create_params.sender, app_metadata) + + return AppDeployResult( + **app_metadata.__dict__, + operation_performed=OperationPerformed.Update, + transaction=result.transaction, + confirmation=result.confirmation, + return_value=result.return_value, + ) + def _update_app_lookup(self, sender: str, app_metadata: AppMetaData) -> None: """Update the app lookup cache""" @@ -492,7 +567,9 @@ def get_creator_apps_by_name(self, creator_address: str, ignore_cache: bool = Fa deleted=app.get("deleted", False), ) except Exception as e: - logger.warning(f"Error processing app {app_id} for creator {creator_address}: {e}") + logger.warning( + f"Error processing app {app_id} for creator {creator_address}: {e}", + ) continue lookup = AppLookup(creator=creator_address, apps=app_lookup) diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index a522656..4b1763e 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -214,22 +214,20 @@ def create(self, params: AppFactoryCreateMethodCallParams) -> AppCreateMethodCal ) def deploy_update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: - return AppUpdateMethodCall( - **params.__dict__, - sender=self._factory._get_sender(params.sender), - method=get_arc56_method(params.method, self._factory._app_spec), - args=self._factory._get_create_abi_args_with_default_values(params.method, params.args), - on_complete=OnComplete.UpdateApplicationOC, - ) + params_dict = params.__dict__ + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = OnComplete.UpdateApplicationOC + return AppUpdateMethodCall(**params.__dict__, app_id=0, approval_program="", clear_state_program="") def deploy_delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: - return AppDeleteMethodCall( - **params.__dict__, - sender=self._factory._get_sender(params.sender), - method=get_arc56_method(params.method, self._factory._app_spec), - args=self._factory._get_create_abi_args_with_default_values(params.method, params.args), - on_complete=OnComplete.DeleteApplicationOC, - ) + params_dict = params.__dict__ + params_dict["sender"] = self._factory._get_sender(params.sender) + params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) + params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) + params_dict["on_complete"] = OnComplete.DeleteApplicationOC + return AppDeleteMethodCall(**params_dict, app_id=0) class _AppFactoryBareCreateTransactionAccessor: diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index f8e8cda..88991d1 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -332,14 +332,13 @@ def convert_actions( Converts method configuration into a list of on-complete action literals. Args: - call_config (MethodConfigDict): Configuration dictionary for method actions. + call_config (CallConfig | MethodConfigDict): Configuration dictionary or CallConfig object for method actions. action_type (Literal["CREATE", "CALL"]): The type of action to convert. Returns: - List[Literal['NoOp', 'OptIn', 'DeleteApplication']]: A list of on-complete action literals. + List[OnCompleteAction]: A list of on-complete action literals. """ - - config_action_map: dict[str, OnCompleteAction] = { + config_action_map = { "no_op": "NoOp", "opt_in": "OptIn", "close_out": "CloseOut", @@ -348,11 +347,15 @@ def convert_actions( "delete_application": "DeleteApplication", } - return [ - action - for key, action in config_action_map.items() - if hasattr(call_config, key) and getattr(call_config, key) in ("ALL", action_type) - ] + def get_action_value(key: str) -> str | None: + if isinstance(call_config, dict): + config_value = call_config.get(key) # type: ignore[call-overload] + # Handle legacy CallConfig enum + return config_value.name if hasattr(config_value, "name") else config_value # type: ignore[no-any-return] + # Handle new CallConfig dataclass + return getattr(call_config, key, None) + + return [action for key, action in config_action_map.items() if get_action_value(key) in ("ALL", action_type)] # type: ignore # noqa: PGH003 # Convert structs structs = convert_structs() diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 6066227..1179b0c 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -11,14 +11,7 @@ from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager from algokit_utils.clients.client_manager import AlgoSdkClients, ClientManager -from algokit_utils.network_clients import ( - AlgoClientConfigs, - get_algod_client, - get_algonode_config, - get_default_localnet_config, - get_indexer_client, - get_kmd_client, -) +from algokit_utils.models.network import AlgoClientConfigs from algokit_utils.transactions.transaction_composer import ( AppCallParams, AppMethodCallParams, @@ -200,9 +193,9 @@ def default_local_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_default_localnet_config("algod"), - indexer_config=get_default_localnet_config("indexer"), - kmd_config=get_default_localnet_config("kmd"), + algod_config=ClientManager.get_default_local_net_config("algod"), + indexer_config=ClientManager.get_default_local_net_config("indexer"), + kmd_config=ClientManager.get_default_local_net_config("kmd"), ) ) @@ -215,8 +208,8 @@ def test_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_algonode_config("testnet", "algod", ""), - indexer_config=get_algonode_config("testnet", "indexer", ""), + algod_config=ClientManager.get_algonode_config("testnet", "algod"), + indexer_config=ClientManager.get_algonode_config("testnet", "indexer"), kmd_config=None, ) ) @@ -230,8 +223,8 @@ def main_net() -> "AlgorandClient": """ return AlgorandClient( AlgoClientConfigs( - algod_config=get_algonode_config("mainnet", "algod", ""), - indexer_config=get_algonode_config("mainnet", "indexer", ""), + algod_config=ClientManager.get_algonode_config("mainnet", "algod"), + indexer_config=ClientManager.get_algonode_config("mainnet", "indexer"), kmd_config=None, ) ) @@ -257,13 +250,7 @@ def from_environment() -> "AlgorandClient": :return: The `AlgorandClient` """ - return AlgorandClient( - AlgoSdkClients( - algod=get_algod_client(), - kmd=get_kmd_client(), - indexer=get_indexer_client(), - ) - ) + return AlgorandClient(ClientManager.get_config_from_environment_or_localnet()) @staticmethod def from_config(config: AlgoClientConfigs) -> "AlgorandClient": diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index 43ab921..d7fd916 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -1,4 +1,7 @@ +import os from dataclasses import dataclass +from typing import Literal +from urllib import parse import algosdk from algosdk.atomic_transaction_composer import TransactionSigner @@ -12,12 +15,7 @@ from algokit_utils.applications.app_manager import TealTemplateParams from algokit_utils.clients.dispenser_api_client import TestNetDispenserApiClient from algokit_utils.models.application import Arc56Contract -from algokit_utils.network_clients import ( - AlgoClientConfigs, - get_algod_client, - get_indexer_client, - get_kmd_client, -) +from algokit_utils.models.network import AlgoClientConfig, AlgoClientConfigs from algokit_utils.protocols.application import AlgorandClientProtocol @@ -46,17 +44,30 @@ def genesis_id_is_localnet(genesis_id: str) -> bool: return genesis_id in ["devnet-v1", "sandnet-v1", "dockernet-v1"] +def _get_config_from_environment(environment_prefix: str) -> AlgoClientConfig: + server = os.getenv(f"{environment_prefix}_SERVER") + if server is None: + raise Exception(f"Server environment variable not set: {environment_prefix}_SERVER") + port = os.getenv(f"{environment_prefix}_PORT") + if port: + parsed = parse.urlparse(server) + server = parsed._replace(netloc=f"{parsed.hostname}:{port}").geturl() + return AlgoClientConfig(server, os.getenv(f"{environment_prefix}_TOKEN", "")) + + class ClientManager: def __init__(self, clients_or_configs: AlgoClientConfigs | AlgoSdkClients, algorand_client: AlgorandClientProtocol): if isinstance(clients_or_configs, AlgoSdkClients): _clients = clients_or_configs elif isinstance(clients_or_configs, AlgoClientConfigs): _clients = AlgoSdkClients( - algod=get_algod_client(clients_or_configs.algod_config), - indexer=get_indexer_client(clients_or_configs.indexer_config) + algod=ClientManager.get_algod_client(clients_or_configs.algod_config), + indexer=ClientManager.get_indexer_client(clients_or_configs.indexer_config) if clients_or_configs.indexer_config else None, - kmd=get_kmd_client(clients_or_configs.kmd_config) if clients_or_configs.kmd_config else None, + kmd=ClientManager.get_kmd_client(clients_or_configs.kmd_config) + if clients_or_configs.kmd_config + else None, ) self._algod = _clients.algod self._indexer = _clients.indexer @@ -96,6 +107,15 @@ def network(self) -> NetworkDetail: genesis_hash=sp.gh, ) + def is_local_net(self) -> bool: + return self.network().is_local_net + + def is_test_net(self) -> bool: + return self.network().is_test_net + + def is_main_net(self) -> bool: + return self.network().is_main_net + def get_testnet_dispenser( self, auth_token: str | None = None, request_timeout: int | None = None ) -> TestNetDispenserApiClient: @@ -132,6 +152,145 @@ def get_app_factory( ) ) + @staticmethod + def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: + """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment + + If no configuration provided will use environment variables `ALGOD_SERVER`, `ALGOD_PORT` and `ALGOD_TOKEN`""" + config = config or _get_config_from_environment("ALGOD") + headers = {"X-Algo-API-Token": config.token or ""} + return AlgodClient(config.token or "", config.server, headers) + + @staticmethod + def get_algod_client_from_environment() -> AlgodClient: + return ClientManager.get_algod_client(ClientManager.get_algod_config_from_environment()) + + @staticmethod + def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: + """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment + + If no configuration provided will use environment variables `KMD_SERVER`, `KMD_PORT` and `KMD_TOKEN`""" + config = config or _get_config_from_environment("KMD") + return KMDClient(config.token, config.server) + + @staticmethod + def get_kmd_client_from_environment() -> KMDClient: + return ClientManager.get_kmd_client(ClientManager.get_kmd_config_from_environment()) + + @staticmethod + def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: + """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. + + If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" + config = config or _get_config_from_environment("INDEXER") + headers = {"X-Indexer-API-Token": config.token} + return IndexerClient(config.token, config.server, headers) + + @staticmethod + def get_indexer_client_from_environment() -> IndexerClient: + return ClientManager.get_indexer_client(ClientManager.get_indexer_config_from_environment()) + @staticmethod def genesis_id_is_local_net(genesis_id: str) -> bool: return genesis_id_is_localnet(genesis_id) + + @staticmethod + def get_config_from_environment_or_localnet() -> AlgoClientConfigs: + """Retrieve client configuration from environment variables or fallback to localnet defaults. + + If ALGOD_SERVER is set in environment variables, it will use environment configuration, + otherwise it will use default localnet configuration. + + Returns: + AlgoClientConfigs: Configuration for algod, indexer, and optionally kmd + """ + algod_server = os.getenv("ALGOD_SERVER") + + if algod_server: + # Use environment configuration + algod_config = ClientManager.get_algod_config_from_environment() + + # Only include indexer if INDEXER_SERVER is set + indexer_config = ( + ClientManager.get_indexer_config_from_environment() if os.getenv("INDEXER_SERVER") else None + ) + + # Include KMD config only for local networks (not mainnet/testnet) + kmd_config = ( + ClientManager.get_kmd_config_from_environment() + if not any(net in algod_server.lower() for net in ["mainnet", "testnet"]) + else None + ) + else: + # Use localnet defaults + algod_config = ClientManager.get_default_local_net_config("algod") + indexer_config = ClientManager.get_default_local_net_config("indexer") + kmd_config = ClientManager.get_default_local_net_config("kmd") + + return AlgoClientConfigs( + algod_config=algod_config, + indexer_config=indexer_config, + kmd_config=kmd_config, + ) + + @staticmethod + def get_default_local_net_config(config_or_port: Literal["algod", "indexer", "kmd"] | int) -> AlgoClientConfig: + port = ( + config_or_port + if isinstance(config_or_port, int) + else {"algod": 4001, "indexer": 8980, "kmd": 4002}[config_or_port] + ) + + return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) + + @staticmethod + def get_algod_config_from_environment() -> AlgoClientConfig: + """Retrieve the algod configuration from environment variables. + + Expects ALGOD_SERVER to be defined in environment variables. + ALGOD_PORT and ALGOD_TOKEN are optional. + + Raises: + ValueError: If ALGOD_SERVER environment variable is not set + """ + return _get_config_from_environment("ALGOD") + + @staticmethod + def get_indexer_config_from_environment() -> AlgoClientConfig: + """Retrieve the indexer configuration from environment variables. + + Expects INDEXER_SERVER to be defined in environment variables. + INDEXER_PORT and INDEXER_TOKEN are optional. + + Raises: + ValueError: If INDEXER_SERVER environment variable is not set + """ + return _get_config_from_environment("INDEXER") + + @staticmethod + def get_kmd_config_from_environment() -> AlgoClientConfig: + """Retrieve the kmd configuration from environment variables. + + Expects KMD_SERVER to be defined in environment variables. + KMD_PORT and KMD_TOKEN are optional. + """ + return _get_config_from_environment("KMD") + + @staticmethod + def get_algonode_config( + network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"] + ) -> AlgoClientConfig: + """Returns the Algorand configuration to point to the free tier of the AlgoNode service. + + Args: + network: Which network to connect to - TestNet or MainNet + config: Which algod config to return - Algod or Indexer + + Returns: + AlgoClientConfig: Configuration for the specified network and service + """ + service_type = "api" if config == "algod" else "idx" + return AlgoClientConfig( + server=f"https://{network}-{service_type}.algonode.cloud/", + port=443, + ) diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index fecc383..8c271e9 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -2,6 +2,7 @@ import os from collections.abc import Callable from pathlib import Path +from typing import Any logger = logging.getLogger(__name__) @@ -10,6 +11,46 @@ ALGOKIT_CONFIG_FILENAME = ".algokit.toml" +class AlgoKitLogger: + def __init__(self) -> None: + self._logger = logging.getLogger("algokit") + self._setup_logger() + + def _setup_logger(self) -> None: + formatter = logging.Formatter("%(levelname)s: %(message)s") + handler = logging.StreamHandler() + handler.setFormatter(formatter) + self._logger.addHandler(handler) + self._logger.setLevel(logging.INFO) + + def _get_logger(self, *, suppress_log: bool = False) -> logging.Logger: + if suppress_log: + null_logger = logging.getLogger("null") + null_logger.addHandler(logging.NullHandler()) + return null_logger + return self._logger + + def error(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an error message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).error(message, *args, **kwargs) + + def warning(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a warning message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).warning(message, *args, **kwargs) + + def info(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an info message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).info(message, *args, **kwargs) + + def debug(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a debug message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).debug(message, *args, **kwargs) + + def verbose(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log a verbose message (maps to debug), optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).debug(message, *args, **kwargs) + + class UpdatableConfig: """Class to manage and update configuration settings for the AlgoKit project. @@ -23,6 +64,7 @@ class UpdatableConfig: """ def __init__(self) -> None: + self._logger = AlgoKitLogger() self._debug: bool = False self._project_root: Path | None = None self._trace_all: bool = False @@ -35,12 +77,16 @@ def _configure_project_root(self) -> None: """Configures the project root by searching for a specific file within a depth limit.""" current_path = Path(__file__).resolve() for _ in range(self._max_search_depth): - logger.debug(f"Searching in: {current_path}") + self.logger.debug(f"Searching in: {current_path}") if (current_path / ALGOKIT_CONFIG_FILENAME).exists(): self._project_root = current_path break current_path = current_path.parent + @property + def logger(self) -> AlgoKitLogger: + return self._logger + @property def debug(self) -> bool: """Returns the debug status.""" diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py index 3014b7a..de487b8 100644 --- a/src/algokit_utils/models/account.py +++ b/src/algokit_utils/models/account.py @@ -3,6 +3,8 @@ import algosdk from algosdk.atomic_transaction_composer import AccountTransactionSigner +DISPENSER_ACCOUNT_NAME = "DISPENSER" + @dataclasses.dataclass(kw_only=True) class Account: @@ -15,7 +17,7 @@ class Account: def __post_init__(self) -> None: if not self.address: - self.address = algosdk.account.address_from_private_key(self.private_key) + self.address = algosdk.account.address_from_private_key(self.private_key) # type: ignore[arg-type] @property def public_key(self) -> bytes: diff --git a/src/algokit_utils/models/network.py b/src/algokit_utils/models/network.py new file mode 100644 index 0000000..8ee897e --- /dev/null +++ b/src/algokit_utils/models/network.py @@ -0,0 +1,20 @@ +import dataclasses + + +@dataclasses.dataclass +class AlgoClientConfig: + """Connection details for connecting to an {py:class}`algosdk.v2client.algod.AlgodClient` or + {py:class}`algosdk.v2client.indexer.IndexerClient`""" + + server: str + """URL for the service e.g. `http://localhost:4001` or `https://testnet-api.algonode.cloud`""" + token: str | None = None + """API Token to authenticate with the service""" + port: str | int | None = None + + +@dataclasses.dataclass +class AlgoClientConfigs: + algod_config: AlgoClientConfig + indexer_config: AlgoClientConfig | None + kmd_config: AlgoClientConfig | None diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index cbdd60e..7d815c5 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -352,6 +352,11 @@ class AppDeleteParams( """ app_id: int + args: list[bytes] | None = None + account_references: list[str] | None = None + app_references: list[int] | None = None + asset_references: list[int] | None = None + box_references: list[BoxReference] | None = None on_complete: OnComplete = OnComplete.DeleteApplicationOC @@ -1066,8 +1071,8 @@ def _build_method_call( # noqa: C901, PLR0912 boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] if params.box_references else None, - approval_program=params.approval_program if isinstance(params, AppCreateMethodCall) else None, # type: ignore[arg-type] - clear_program=params.clear_state_program if isinstance(params, AppCreateMethodCall) else None, # type: ignore[arg-type] + approval_program=params.approval_program if hasattr(params, "approval_program") else None, # type: ignore[arg-type] + clear_program=params.clear_state_program if hasattr(params, "clear_state_program") else None, # type: ignore[arg-type] ) return self._build_atc(method_atc) @@ -1236,10 +1241,10 @@ def _build_asset_create( def _build_app_call( self, - params: AppCallParams | AppUpdateParams | AppCreateParams, + params: AppCallParams | AppUpdateParams | AppCreateParams | AppDeleteParams, suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: - app_id = params.app_id if isinstance(params, AppCallParams | AppUpdateMethodCall) else None + app_id = params.app_id if hasattr(params, "app_id") and params.app_id else 0 # type: ignore[] approval_program = None clear_program = None @@ -1399,7 +1404,7 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 case AssetCreateParams(): asset_create = self._build_asset_create(txn, suggested_params) return [TransactionWithSigner(txn=asset_create, signer=signer)] - case AppCallParams() | AppUpdateParams() | AppCreateParams(): + case AppCallParams() | AppUpdateParams() | AppCreateParams() | AppDeleteParams(): app_call = self._build_app_call(txn, suggested_params) return [TransactionWithSigner(txn=app_call, signer=signer)] case AssetConfigParams(): diff --git a/tests/accounts/__init__.py b/tests/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/accounts/test_account_manager.py b/tests/accounts/test_account_manager.py new file mode 100644 index 0000000..e8f1335 --- /dev/null +++ b/tests/accounts/test_account_manager.py @@ -0,0 +1,99 @@ +import algosdk +import pytest + +from algokit_utils import Account +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.models.amount import AlgoAmount +from tests.conftest import get_unique_name + + +@pytest.fixture +def algorand(funded_account: Account) -> AlgorandClient: + client = AlgorandClient.default_local_net() + client.set_signer(sender=funded_account.address, signer=funded_account.signer) + return client + + +def test_new_account_is_retrieved_and_funded(algorand: AlgorandClient) -> None: + # Act + account_name = get_unique_name() + account = algorand.account.from_environment(account_name) + + # Assert + account_info = algorand.account.get_information(account.address) + assert account_info["amount"] > 0 + + +def test_same_account_is_subsequently_retrieved(algorand: AlgorandClient) -> None: + # Arrange + account_name = get_unique_name() + + # Act + account1 = algorand.account.from_environment(account_name) + account2 = algorand.account.from_environment(account_name) + + # Assert - accounts should be different objects but with same underlying keys + assert account1 is not account2 + assert account1.address == account2.address + assert account1.private_key == account2.private_key + + +def test_environment_is_used_in_preference_to_kmd(algorand: AlgorandClient, monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + account_name = get_unique_name() + account1 = algorand.account.from_environment(account_name) + + # Set up environment variable for second account + env_account_name = "TEST_ACCOUNT" + monkeypatch.setenv(f"{env_account_name}_MNEMONIC", algosdk.mnemonic.from_private_key(account1.private_key)) + + # Act + account2 = algorand.account.from_environment(env_account_name) + + # Assert - accounts should be different objects but with same underlying keys + assert account1 is not account2 + assert account1.address == account2.address + assert account1.private_key == account2.private_key + + +def test_random_account_creation(algorand: AlgorandClient) -> None: + # Act + account = algorand.account.random() + + # Assert + assert account.address + assert account.private_key + assert len(account.public_key) == 32 + + +def test_ensure_funded_from_environment(algorand: AlgorandClient) -> None: + # Arrange + account = algorand.account.random() + min_balance = AlgoAmount.from_algos(1) + + # Act + result = algorand.account.ensure_funded_from_environment( + account_to_fund=account.address, + min_spending_balance=min_balance, + ) + + # Assert + assert result is not None + assert result.amount_funded is not None + account_info = algorand.account.get_information(account.address) + assert account_info["amount"] >= min_balance.micro_algos + + +def test_get_account_information(algorand: AlgorandClient) -> None: + # Arrange + account = algorand.account.random() + + # Act + info = algorand.account.get_information(account.address) + + # Assert + assert isinstance(info, dict) + assert "amount" in info + assert "min-balance" in info + assert "address" in info + assert info["address"] == account.address diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index 32fe3b4..0de8b34 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -1,8 +1,9 @@ from pathlib import Path +import algosdk import pytest from algosdk.logic import get_application_address -from algosdk.transaction import ApplicationCreateTxn, OnComplete +from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete from algokit_utils._legacy_v2.deploy import OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import AppClientMethodCallParams @@ -56,7 +57,7 @@ def test_create_app_with_constructor_deploy_time_params(algorand: AlgorandClient random_account = algorand.account.random() dispenser_account = algorand.account.localnet_dispenser() algorand.account.ensure_funded( - account_fo_fund=random_account.address, + account_fo_fund=random_account, dispenser_account=dispenser_account.address, min_spending_balance=AlgoAmount.from_algo(10), min_funding_increment=AlgoAmount.from_algo(1), @@ -136,3 +137,82 @@ def test_deploy_app_create_abi(factory: AppFactory) -> None: assert result.app_id > 0 assert app_client.app_id == result.app_id == result.confirmation["application-index"] # type: ignore[call-overload] assert app_client.app_address == get_application_address(app_client.app_id) + + +def test_deploy_app_update(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + updatable=True, + ) + + _, updated_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.UpdateApp, + ) + + assert updated_app.operation_performed == OperationPerformed.Update + assert created_app.app_id == updated_app.app_id + assert created_app.app_address == updated_app.app_address + assert created_app.confirmation + assert created_app.updatable + assert created_app.updatable == updated_app.updatable + assert created_app.updated_round != updated_app.updated_round + assert created_app.created_round == updated_app.created_round + assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] + + +def test_deploy_app_update_abi(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + updatable=True, + ) + + _, updated_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.UpdateApp, + update_params=AppClientMethodCallParams(method="update_abi", args=["args_io"]), + ) + + assert updated_app.operation_performed == OperationPerformed.Update + assert updated_app.app_id == created_app.app_id + assert updated_app.app_address == created_app.app_address + assert updated_app.confirmation is not None + assert updated_app.created_round == created_app.created_round + assert updated_app.updated_round != updated_app.created_round + assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] + assert isinstance(updated_app.transaction, ApplicationCallTxn) + assert updated_app.transaction.on_complete == OnComplete.UpdateApplicationOC # type: ignore[union-attr] + assert updated_app.return_value == "args_io" + + +def test_deploy_app_replace(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + deletable=True, + ) + + _, replaced_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.ReplaceApp, + ) + + assert replaced_app.operation_performed == OperationPerformed.Replace + assert replaced_app.app_id > created_app.app_id + assert replaced_app.app_address == algosdk.logic.get_application_address(replaced_app.app_id) + assert replaced_app.confirmation is not None + assert replaced_app.delete_return is not None + assert replaced_app.delete_return.confirmation is not None + assert replaced_app.delete_return.transaction.application_id == created_app.app_id # type: ignore[union-attr] + assert replaced_app.delete_return.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] diff --git a/tests/clients/test_algorand_client.py b/tests/clients/test_algorand_client.py index ce0f90d..930644b 100644 --- a/tests/clients/test_algorand_client.py +++ b/tests/clients/test_algorand_client.py @@ -4,7 +4,7 @@ # import pytest # from algokit_utils import Account, ApplicationClient -# from algokit_utils.accounts.account_manager import AddressAndSigner +# from algokit_utils.accounts.account_manager import TransactionSignerAccount # from algokit_utils.clients.algorand_client import ( # AlgorandClient, # AppMethodCallParams, @@ -24,21 +24,21 @@ # @pytest.fixture() -# def alice(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: +# def alice(algorand: AlgorandClient, funded_account: Account) -> TransactionSignerAccount: # acct = algorand.account.random() # algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) # return acct # @pytest.fixture() -# def bob(algorand: AlgorandClient, funded_account: Account) -> AddressAndSigner: +# def bob(algorand: AlgorandClient, funded_account: Account) -> TransactionSignerAccount: # acct = algorand.account.random() # algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) # return acct # @pytest.fixture() -# def app_client(algorand: AlgorandClient, alice: AddressAndSigner) -> ApplicationClient: +# def app_client(algorand: AlgorandClient, alice: TransactionSignerAccount) -> ApplicationClient: # client = ApplicationClient( # algorand.client.algod, # Path(__file__).parent / "app_algorand_client.json", @@ -55,7 +55,7 @@ # return Contract.from_json(json.dumps(json.load(f)["contract"])) -# def test_send_payment(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: +# def test_send_payment(algorand: AlgorandClient, alice: TransactionSignerAccount, bob: TransactionSignerAccount) -> None: # amount = 100_000 # alice_pre_balance = algorand.account.get_information(alice.address)["amount"] @@ -69,7 +69,7 @@ # assert bob_post_balance == bob_pre_balance + amount -# def test_send_asset_create(algorand: AlgorandClient, alice: AddressAndSigner) -> None: +# def test_send_asset_create(algorand: AlgorandClient, alice: TransactionSignerAccount) -> None: # total = 100 # result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) @@ -78,7 +78,7 @@ # assert asset_index > 0 -# def test_asset_opt_in(algorand: AlgorandClient, alice: AddressAndSigner, bob: AddressAndSigner) -> None: +# def test_asset_opt_in(algorand: AlgorandClient, alice: TransactionSignerAccount, bob: TransactionSignerAccount) -> None: # total = 100 # result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) @@ -92,7 +92,7 @@ # DO_MATH_VALUE = 3 -# def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: AddressAndSigner) -> None: +# def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: TransactionSignerAccount) -> None: # atc = AtomicTransactionComposer() # app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") @@ -106,7 +106,7 @@ # def test_add_method_call( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient # ) -> None: # result = ( # algorand.new_group() @@ -125,7 +125,7 @@ # def test_add_method_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient # ) -> None: # pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) # result = ( @@ -145,7 +145,7 @@ # def test_add_method_call_with_method_call_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient # ) -> None: # hello_world_call = AppMethodCallParams( # method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id @@ -167,7 +167,7 @@ # def test_add_method_call_with_method_call_arg_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient # ) -> None: # pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) # txn_arg_call = AppMethodCallParams( @@ -190,7 +190,7 @@ # def test_add_method_call_with_two_method_call_args_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: AddressAndSigner, app_client: ApplicationClient +# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient # ) -> None: # pay_arg_1 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) # txn_arg_call_1 = AppMethodCallParams( From cd2e772b7a41b5db018efd1d07d9d00355477877 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Sat, 7 Dec 2024 02:15:26 +0100 Subject: [PATCH 14/19] chore: wip --- src/algokit_utils/accounts/account_manager.py | 13 +- src/algokit_utils/applications/app_client.py | 30 ++- .../applications/app_deployer.py | 94 +++++--- src/algokit_utils/applications/app_factory.py | 73 +++--- src/algokit_utils/applications/app_manager.py | 14 +- src/algokit_utils/applications/utils.py | 17 +- src/algokit_utils/models/abi.py | 4 +- .../transactions/transaction_composer.py | 221 +++++------------- .../transactions/transaction_sender.py | 7 +- tests/accounts/test_account_manager.py | 17 +- tests/applications/test_app_client.py | 158 ++++++++++++- tests/applications/test_app_factory.py | 177 +++++++++++++- tests/applications/test_app_manager.py | 18 +- tests/assets/test_asset_manager.py | 29 ++- tests/conftest.py | 42 ---- tests/test_transaction_composer.py | 17 +- .../transactions/test_transaction_composer.py | 29 ++- .../transactions/test_transaction_creator.py | 17 +- tests/transactions/test_transaction_sender.py | 43 ++-- 19 files changed, 643 insertions(+), 377 deletions(-) diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index a4e13b5..ed4d045 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -106,6 +106,7 @@ def from_mnemonic(self, mnemonic: str) -> Account: private_key = to_private_key(mnemonic) account = Account(private_key=private_key) self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) return account def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> Account: @@ -115,12 +116,14 @@ def from_environment(self, name: str, fund_with: AlgoAmount | None = None) -> Ac private_key = mnemonic.to_private_key(account_mnemonic) account = Account(private_key=private_key) self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=private_key)) return account if self._client_manager.is_local_net(): kmd_account = self._kmd_account_manager.get_or_create_wallet_account(name, fund_with) account = Account(private_key=kmd_account.private_key) self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) return account raise ValueError(f"Missing environment variable {name.upper()}_MNEMONIC when looking for account {name}") @@ -134,11 +137,13 @@ def from_kmd( account = Account(private_key=kmd_account.private_key) self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) return account - def rekeyed(self, sender: str, account: Account) -> Account: - self._accounts[sender] = account - return account + def rekeyed(self, sender: Account | str, account: Account) -> Account: + sender_address = sender.address if isinstance(sender, Account) else sender + self._accounts[sender_address] = account + return Account(address=sender_address, private_key=account.private_key) def random(self) -> Account: """ @@ -148,12 +153,14 @@ def random(self) -> Account: """ account = Account.new_account() self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=account.private_key)) return account def localnet_dispenser(self) -> Account: kmd_account = self._kmd_account_manager.get_localnet_dispenser_account() account = Account(private_key=kmd_account.private_key) self._accounts[account.address] = account + self.set_signer(account.address, AccountTransactionSigner(private_key=kmd_account.private_key)) return account def dispenser_from_environment(self) -> Account: diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 4cb40c9..2caf9c6 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -4,7 +4,7 @@ import copy import json import os -from dataclasses import dataclass +from dataclasses import dataclass, fields from typing import TYPE_CHECKING, Any, Protocol, TypeVar import algosdk @@ -572,11 +572,29 @@ def delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: ) return AppDeleteMethodCall(**input_params) - def update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: - input_params = self._get_abi_params( - params.__dict__, on_complete=algosdk.transaction.OnComplete.UpdateApplicationOC + def update( + self, params: AppClientMethodCallParams | AppClientMethodCallWithCompilationAndSendParams + ) -> AppUpdateMethodCall: + compile_params = ( + self._client.compile( + app_spec=self._client.app_spec, + app_manager=self._algorand.app, + deploy_time_params=params.deploy_time_params, + updatable=params.updatable, + deletable=params.deletable, + ).__dict__ + if isinstance(params, AppClientMethodCallWithCompilationAndSendParams) + else {} ) - return AppUpdateMethodCall(**input_params) + + input_params = { + **self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.UpdateApplicationOC), + **compile_params, + } + # Filter input_params to include only fields valid for AppUpdateMethodCall + app_update_method_call_fields = {field.name for field in fields(AppUpdateMethodCall)} + filtered_input_params = {k: v for k, v in input_params.items() if k in app_update_method_call_fields} + return AppUpdateMethodCall(**filtered_input_params) def close_out(self, params: AppClientMethodCallParams) -> AppCallMethodCall: input_params = self._get_abi_params(params.__dict__, on_complete=algosdk.transaction.OnComplete.CloseOutOC) @@ -786,7 +804,7 @@ def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionR confirmations=simulate_response.confirmations, group_id=simulate_response.group_id or "", returns=simulate_response.returns, - return_value=simulate_response.returns[-1].return_value, + return_value=simulate_response.returns[-1], ) return self._client._handle_call_errors( diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 23c9e1a..7b3ce5f 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -5,7 +5,7 @@ from typing import Literal import algosdk -from algosdk.atomic_transaction_composer import TransactionSigner +from algosdk.atomic_transaction_composer import ABIResult, TransactionSigner from algosdk.logic import get_application_address from algosdk.transaction import OnComplete, Transaction from algosdk.v2client.indexer import IndexerClient @@ -20,7 +20,6 @@ ) from algokit_utils.applications.app_manager import AppManager, BoxReference, TealTemplateParams from algokit_utils.config import config -from algokit_utils.models.abi import ABIValue from algokit_utils.transactions.transaction_composer import ( AppCreateMethodCall, AppCreateParams, @@ -113,11 +112,15 @@ class AppDeployResult: app_id: int | None = None app_address: str | None = None transaction: Transaction | None = None + tx_id: str | None = None + transactions: list[Transaction] | None = None + tx_ids: list[str] | None = None confirmation: algosdk.v2client.algod.AlgodResponseType | None = None + confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None compiled_approval: dict | None = None compiled_clear: dict | None = None - return_value: ABIValue | None = None - delete_return: ABIValue | None = None + return_value: ABIResult | None = None + delete_return_value: ABIResult | None = None delete_result: ConfirmedTransactionResult | None = None @@ -319,8 +322,12 @@ def _create_app( return AppDeployResult( **app_metadata_dict, + tx_id=result.tx_id, + tx_ids=result.tx_ids, transaction=result.transaction, + transactions=result.transactions, confirmation=result.confirmation, + confirmations=result.confirmations, return_value=result.return_value, ) @@ -342,7 +349,7 @@ def _handle_schema_break( return self._create_app(deployment, approval_program, clear_program) if existing_app.deletable: - return self._create_and_delete_app(deployment, existing_app, approval_program, clear_program) + return self._replace_app(deployment, existing_app, approval_program, clear_program) else: raise ValueError("App is not deletable but onSchemaBreak=ReplaceApp, " "cannot delete and recreate app") @@ -369,13 +376,13 @@ def _handle_update( if deployment.on_update in (OnUpdate.ReplaceApp, "replace"): if existing_app.deletable: - return self._create_and_delete_app(deployment, existing_app, approval_program, clear_program) + return self._replace_app(deployment, existing_app, approval_program, clear_program) else: raise ValueError("App is not deletable but onUpdate=ReplaceApp, " "cannot delete and recreate app") raise ValueError(f"Unsupported onUpdate value: {deployment.on_update}") - def _create_and_delete_app( + def _replace_app( self, deployment: AppDeployParams, existing_app: AppMetaData, @@ -386,33 +393,35 @@ def _create_and_delete_app( # Add create transaction if isinstance(deployment.create_params, AppCreateMethodCall): - create_params = AppCreateMethodCall( - **{ - **deployment.create_params.__dict__, - "approval_program": approval_program, - "clear_state_program": clear_program, - } + composer.add_app_create_method_call( + AppCreateMethodCall( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) ) - composer.add_app_create_method_call(create_params) else: - create_params = AppCreateParams( - **{ - **deployment.create_params.__dict__, - "approval_program": approval_program, - "clear_state_program": clear_program, - } + composer.add_app_create( + AppCreateParams( + **{ + **deployment.create_params.__dict__, + "approval_program": approval_program, + "clear_state_program": clear_program, + } + ) ) - composer.add_app_create(create_params) # Add delete transaction if isinstance(deployment.delete_params, AppDeleteMethodCall): - delete_params = AppDeleteMethodCall( + delete_call_params = AppDeleteMethodCall( **{ **deployment.delete_params.__dict__, "app_id": existing_app.app_id, } ) - composer.add_app_delete_method_call(delete_params) + composer.add_app_delete_method_call(delete_call_params) else: delete_params = AppDeleteParams( **{ @@ -424,30 +433,43 @@ def _create_and_delete_app( result = composer.send() - app_id = int(result.confirmations[0]["application-index"]) + app_id = int(result.confirmations[0]["application-index"]) # type: ignore[call-overload] app_metadata = AppMetaData( app_id=app_id, app_address=get_application_address(app_id), **deployment.metadata.__dict__, created_metadata=deployment.metadata, - created_round=result.confirmations[0]["confirmed-round"], - updated_round=result.confirmations[0]["confirmed-round"], + created_round=result.confirmations[0]["confirmed-round"], # type: ignore[call-overload] + updated_round=result.confirmations[0]["confirmed-round"], # type: ignore[call-overload] deleted=False, ) self._update_app_lookup(deployment.create_params.sender, app_metadata) + app_metadata_dict = app_metadata.__dict__ + app_metadata_dict["operation_performed"] = OperationPerformed.Replace + app_metadata_dict["app_id"] = app_id + app_metadata_dict["app_address"] = get_application_address(app_id) + + # Extract return_value and delete_return_value from ABIResult + return_value = result.returns[0] if result.returns and isinstance(result.returns[0], ABIResult) else None + delete_return_value = ( + result.returns[-1] if len(result.returns) > 1 and isinstance(result.returns[-1], ABIResult) else None + ) + return AppDeployResult( - operation_performed="replace", - app_id=app_id, - app_address=get_application_address(app_id), + **app_metadata_dict, + tx_id=result.tx_ids[0], + tx_ids=result.tx_ids, transaction=result.transactions[0], + transactions=result.transactions, confirmation=result.confirmations[0], - return_value=result.returns[0] if result.returns else None, - delete_return=result.returns[-1] if len(result.returns) > 1 else None, - delete_result={ - "transaction": result.transactions[-1], - "confirmation": result.confirmations[-1], - }, + confirmations=result.confirmations, + return_value=return_value, + delete_return_value=delete_return_value, + delete_result=ConfirmedTransactionResult( + transaction=result.transactions[-1], + confirmation=result.confirmations[-1], + ), ) def _update_app( @@ -498,7 +520,9 @@ def _update_app( **app_metadata.__dict__, operation_performed=OperationPerformed.Update, transaction=result.transaction, + transactions=result.transactions, confirmation=result.confirmation, + confirmations=result.confirmations, return_value=result.return_value, ) diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index 4b1763e..d2c8277 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -6,7 +6,7 @@ import algosdk from algosdk import transaction from algosdk.abi import Method -from algosdk.atomic_transaction_composer import TransactionSigner +from algosdk.atomic_transaction_composer import ABIResult, TransactionSigner from algosdk.source_map import SourceMap from algosdk.transaction import OnComplete, Transaction @@ -22,7 +22,12 @@ AppSourceMaps, ExposedLogicErrorDetails, ) -from algokit_utils.applications.app_deployer import AppDeployParams, DeployAppDeleteParams, DeployAppUpdateParams +from algokit_utils.applications.app_deployer import ( + AppDeployParams, + ConfirmedTransactionResult, + DeployAppDeleteParams, + DeployAppUpdateParams, +) from algokit_utils.applications.app_manager import TealTemplateParams from algokit_utils.applications.utils import ( get_abi_decoded_value, @@ -30,6 +35,7 @@ get_arc56_method, get_arc56_return_value, ) +from algokit_utils.models.abi import ABIStruct, ABIValue from algokit_utils.models.application import ( DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME, @@ -47,7 +53,7 @@ AppUpdateMethodCall, BuiltTransactions, ) -from algokit_utils.transactions.transaction_sender import SendAppTransactionResult +from algokit_utils.transactions.transaction_sender import SendAppCreateTransactionResult, SendAppTransactionResult T = TypeVar("T") @@ -119,11 +125,12 @@ class AppFactoryDeployResult: created_round: int deletable: bool deleted: bool - delete_return: Any | None = None + delete_return_value: ABIValue | ABIStruct | None = None + delete_result: ConfirmedTransactionResult | None = None group_id: str | None = None name: str operation_performed: OperationPerformed - return_value: Any | None = None + return_value: ABIValue | ABIStruct | None = None returns: list[Any] | None = None transaction: Transaction transactions: list[Transaction] @@ -142,7 +149,7 @@ def __init__(self, factory: "AppFactory") -> None: def create(self, params: AppFactoryCreateParams | None = None) -> AppCreateParams: create_args = {} if params: - create_args = {**params.__dict__} + create_args = {**params.__dict__.copy()} del create_args["schema"] del create_args["sender"] del create_args["on_complete"] @@ -214,15 +221,15 @@ def create(self, params: AppFactoryCreateMethodCallParams) -> AppCreateMethodCal ) def deploy_update(self, params: AppClientMethodCallParams) -> AppUpdateMethodCall: - params_dict = params.__dict__ + params_dict = params.__dict__.copy() params_dict["sender"] = self._factory._get_sender(params.sender) params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) params_dict["on_complete"] = OnComplete.UpdateApplicationOC - return AppUpdateMethodCall(**params.__dict__, app_id=0, approval_program="", clear_state_program="") + return AppUpdateMethodCall(**params_dict, app_id=0, approval_program="", clear_state_program="") def deploy_delete(self, params: AppClientMethodCallParams) -> AppDeleteMethodCall: - params_dict = params.__dict__ + params_dict = params.__dict__.copy() params_dict["sender"] = self._factory._get_sender(params.sender) params_dict["method"] = get_arc56_method(params.method, self._factory._app_spec) params_dict["args"] = self._factory._get_create_abi_args_with_default_values(params.method, params.args) @@ -311,7 +318,7 @@ def __init__(self, factory: "AppFactory") -> None: def bare(self) -> _AppFactoryBareSendAccessor: return self._bare - def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, AppFactoryDeployResult]: + def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, SendAppCreateTransactionResult]: updatable = params.updatable if params.updatable is not None else self._factory._updatable deletable = params.deletable if params.deletable is not None else self._factory._deletable deploy_time_params = ( @@ -326,16 +333,13 @@ def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, A ) ) + create_params_dict = params.__dict__.copy() + create_params_dict["updatable"] = updatable + create_params_dict["deletable"] = deletable + create_params_dict["deploy_time_params"] = deploy_time_params result = self._factory._handle_call_errors( lambda: self._algorand.send.app_create_method_call( - self._factory.params.create( - AppFactoryCreateMethodCallParams( - **params.__dict__, - updatable=updatable, - deletable=deletable, - deploy_time_params=deploy_time_params, - ) - ) + self._factory.params.create(AppFactoryCreateMethodCallParams(**create_params_dict)) ) ) @@ -343,7 +347,16 @@ def create(self, params: AppFactoryCreateMethodCallParams) -> tuple[AppClient, A self._factory.get_app_client_by_id( app_id=result.app_id, ), - AppFactoryDeployResult(**{**result.__dict__, **(compiled.__dict__ if compiled else {})}), + SendAppCreateTransactionResult( + **{ + **result.__dict__, + **( + {"compiled_approval": compiled.compiled_approval, "compiled_clear": compiled.compiled_clear} + if compiled + else {} + ), + } + ), ) @@ -492,8 +505,8 @@ def _is_method_call_params( result = {**deploy_result.__dict__, **(compiled.__dict__ if compiled else {})} - if hasattr(result, "return_value"): - if result["operation_performed"] == "update": + if "return_value" in result: + if result["operation_performed"] == OperationPerformed.Update: if update_params and hasattr(update_params, "method"): result["return_value"] = get_arc56_return_value( result["return_value"], @@ -507,18 +520,13 @@ def _is_method_call_params( self._app_spec.structs, ) - if "delete_return" in result and delete_params and hasattr(delete_params, "method"): - result["delete_return"] = get_arc56_return_value( - result["delete_return"], + if "delete_return_value" in result and delete_params and hasattr(delete_params, "method"): + result["delete_return_value"] = get_arc56_return_value( + result["delete_return_value"], get_arc56_method(delete_params.method, self._app_spec), # type: ignore[arg-type] self._app_spec.structs, ) - del result["delete_result"] - result["transactions"] = [] - result["tx_id"] = "" - result["tx_ids"] = [] - return app_client, AppFactoryDeployResult(**result) def get_app_client_by_id( @@ -624,13 +632,12 @@ def _handle_call_errors(self, call: Callable[[], T]) -> T: raise self.expose_logic_error(e) from None def _parse_method_call_return(self, result: SendAppTransactionResult, method: Method) -> SendAppTransactionResult: - return_value = result.return_value - if isinstance(return_value, dict): - return_value = get_arc56_return_value(return_value, method, self._app_spec.structs) return SendAppTransactionResult( **{ **result.__dict__, - "return_value": return_value, + "return_value": get_arc56_return_value(result.return_value, method, self._app_spec.structs) + if isinstance(result.return_value, ABIResult) + else None, } ) diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 09f6028..8cf1ada 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -2,12 +2,12 @@ from collections.abc import Mapping from dataclasses import dataclass from enum import IntEnum -from typing import Any, TypeAlias +from typing import Any, TypeAlias, cast import algosdk import algosdk.atomic_transaction_composer import algosdk.box_reference -from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.atomic_transaction_composer import ABIResult, AccountTransactionSigner from algosdk.box_reference import BoxReference as AlgosdkBoxReference from algosdk.logic import get_application_address from algosdk.v2client import algod @@ -266,7 +266,9 @@ def get_box_reference(box_id: BoxIdentifier | BoxReference) -> tuple[int, bytes] elif isinstance(box_id, bytes): name = box_id elif isinstance(box_id, AccountTransactionSigner): - name = algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_id.private_key)) + name = cast( + bytes, algosdk.encoding.decode_address(algosdk.account.address_from_private_key(box_id.private_key)) + ) else: raise ValueError(f"Invalid box identifier type: {type(box_id)}") @@ -275,7 +277,7 @@ def get_box_reference(box_id: BoxIdentifier | BoxReference) -> tuple[int, bytes] @staticmethod def get_abi_return( confirmation: algosdk.v2client.algod.AlgodResponseType, method: algosdk.abi.Method | None = None - ) -> ABIValue | None: + ) -> ABIResult | None: """Get the ABI return value from a transaction confirmation.""" if not method: return None @@ -291,7 +293,7 @@ def get_abi_return( if not abi_result: return None - return abi_result.return_value # type: ignore[no-any-return] + return abi_result @staticmethod def decode_app_state(state: list[dict[str, Any]]) -> dict[str, AppState]: @@ -350,7 +352,7 @@ def replace_template_variables(program: str, template_values: TealTemplateParams return "\n".join(program_lines) @staticmethod - def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: dict[str, bool]) -> str: + def replace_teal_template_deploy_time_control_params(teal_template_code: str, params: Mapping[str, bool]) -> str: if params.get("updatable") is not None: if UPDATABLE_TEMPLATE_NAME not in teal_template_code: raise ValueError( diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 88991d1..37c08a7 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -3,6 +3,7 @@ from algosdk.abi import Method as AlgorandABIMethod from algosdk.abi import TupleType +from algosdk.atomic_transaction_composer import ABIResult from algokit_utils._legacy_v2.application_specification import ( ApplicationSpecification, @@ -61,10 +62,10 @@ def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> def get_arc56_return_value( - return_value: dict[str, Any] | None, + return_value: ABIResult | None, method: Method | AlgorandABIMethod, structs: dict[str, list[StructField]], -) -> Any: # noqa: ANN401 +) -> ABIValue | ABIStruct | None: """Checks for decode errors on the return value and maps it to the specified type. Args: @@ -92,11 +93,11 @@ def get_arc56_return_value( return None # Handle decode errors - if return_value.get("decode_error"): - raise ValueError(return_value["decode_error"]) + if return_value.decode_error: + raise ValueError(return_value.decode_error) # Get raw return value - raw_value = return_value.get("raw_return_value") + raw_value = return_value.raw_value # Handle AVM types if type_str == "AVMBytes": @@ -104,15 +105,15 @@ def get_arc56_return_value( if type_str == "AVMString" and raw_value: return raw_value.decode("utf-8") if type_str == "AVMUint64" and raw_value: - return ABIType.from_string("uint64").decode(raw_value) + return ABIType.from_string("uint64").decode(raw_value) # type: ignore[no-any-return] # Handle structs if struct and struct in structs: - return_tuple = return_value.get("return_value") + return_tuple = return_value.return_value return get_abi_struct_from_abi_tuple(return_tuple, structs[struct], structs) # Return as-is - return return_value.get("return_value") + return return_value.return_value # type: ignore[no-any-return] def get_abi_encoded_value(value: Any, type_str: str, structs: dict[str, list[StructField]]) -> bytes: # noqa: ANN401, PLR0911 diff --git a/src/algokit_utils/models/abi.py b/src/algokit_utils/models/abi.py index a9c90e3..4e83727 100644 --- a/src/algokit_utils/models/abi.py +++ b/src/algokit_utils/models/abi.py @@ -7,8 +7,8 @@ ABIPrimitiveValue = bool | int | str | bytes | bytearray # NOTE: This is present in js-algorand-sdk, but sadly not in untyped py-algorand-sdk -ABIValue = ABIPrimitiveValue | list["ABIValue"] | tuple["ABIValue"] | dict[str, "ABIValue"] -ABIStruct = dict[str, list[StructField]] +ABIValue: TypeAlias = ABIPrimitiveValue | list["ABIValue"] | tuple["ABIValue"] | dict[str, "ABIValue"] +ABIStruct: TypeAlias = dict[str, list[StructField]] ABIType: TypeAlias = algosdk.abi.ABIType diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 7d815c5..a574baa 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -606,7 +606,7 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 confirmations=confirmations or [], tx_ids=[t.get_txid() for t in transactions_to_send], transactions=transactions_to_send, - returns=[r.return_value for r in result.abi_results], + returns=result.abi_results, ) except AlgodHTTPError as e: @@ -931,8 +931,12 @@ def simulate( fix_signers, ) + confirmation_results = response.simulate_response.get("txn-groups", [{"txn-results": [{"txn-result": {}}]}])[0][ + "txn-results" + ] + return SendAtomicTransactionComposerResults( - confirmations=[], # TODO: extract confirmations, + confirmations=[txn["txn-result"] for txn in confirmation_results], transactions=[txn.txn for txn in atc.txn_list], tx_ids=response.tx_ids, group_id=atc.txn_list[-1].txn.group or "", @@ -1008,53 +1012,55 @@ def _build_method_call( # noqa: C901, PLR0912 arg_offset = 0 if params.args: - for i, arg in enumerate(params.args): + for _, arg in enumerate(params.args): if self._is_abi_value(arg): method_args.append(arg) continue - if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): - match arg: - case ( - AppCreateMethodCall() - | AppCallMethodCall() - | AppUpdateMethodCall() - | AppDeleteMethodCall() - ): - temp_txn_with_signers = self._build_method_call(arg, suggested_params) - method_args.extend(temp_txn_with_signers) - arg_offset += len(temp_txn_with_signers) - 1 - continue - case AppCallParams(): - txn = self._build_app_call(arg, suggested_params) - case PaymentParams(): - txn = self._build_payment(arg, suggested_params) - case AssetOptInParams(): - txn = self._build_asset_transfer( - AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params - ) - case AssetCreateParams(): - txn = self._build_asset_create(arg, suggested_params) - case AssetConfigParams(): - txn = self._build_asset_config(arg, suggested_params) - case AssetDestroyParams(): - txn = self._build_asset_destroy(arg, suggested_params) - case AssetFreezeParams(): - txn = self._build_asset_freeze(arg, suggested_params) - case AssetTransferParams(): - txn = self._build_asset_transfer(arg, suggested_params) - case OnlineKeyRegistrationParams(): - txn = self._build_key_reg(arg, suggested_params) - case _: - raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + if isinstance(arg, TransactionWithSigner): + method_args.append(arg) + continue + if isinstance(arg, algosdk.transaction.Transaction): + # Wrap in TransactionWithSigner method_args.append( - TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + TransactionWithSigner(txn=arg, signer=params.signer or self.get_signer(params.sender)) ) - continue + match arg: + case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): + temp_txn_with_signers = self._build_method_call(arg, suggested_params) + method_args.extend(temp_txn_with_signers) + arg_offset += len(temp_txn_with_signers) - 1 + continue + case AppCallParams(): + txn = self._build_app_call(arg, suggested_params) + case PaymentParams(): + txn = self._build_payment(arg, suggested_params) + case AssetOptInParams(): + txn = self._build_asset_transfer( + AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params + ) + case AssetCreateParams(): + txn = self._build_asset_create(arg, suggested_params) + case AssetConfigParams(): + txn = self._build_asset_config(arg, suggested_params) + case AssetDestroyParams(): + txn = self._build_asset_destroy(arg, suggested_params) + case AssetFreezeParams(): + txn = self._build_asset_freeze(arg, suggested_params) + case AssetTransferParams(): + txn = self._build_asset_transfer(arg, suggested_params) + case OnlineKeyRegistrationParams(): + txn = self._build_key_reg(arg, suggested_params) + case _: + raise ValueError(f"Unsupported method arg transaction type: {arg!s}") + + method_args.append( + TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) + ) - raise ValueError(f"Unsupported method arg: {arg!s}") + continue method_atc = AtomicTransactionComposer() @@ -1071,140 +1077,16 @@ def _build_method_call( # noqa: C901, PLR0912 boxes=[AppManager.get_box_reference(ref) for ref in params.box_references] if params.box_references else None, + foreign_apps=params.app_references, + foreign_assets=params.asset_references, + accounts=params.account_references, approval_program=params.approval_program if hasattr(params, "approval_program") else None, # type: ignore[arg-type] clear_program=params.clear_state_program if hasattr(params, "clear_state_program") else None, # type: ignore[arg-type] + rekey_to=params.rekey_to, ) return self._build_atc(method_atc) - # TODO: reconsider whether atc's add_method_call is the best way to handle passing manually encoded abi args - # def _build_method_call( - # self, params: MethodCallParams, suggested_params: algosdk.transaction.SuggestedParams - # ) -> list[TransactionWithSigner]: - # # Initialize lists to store transactions and encoded arguments - # method_args: list[ABIValue | TransactionWithSigner] = [] - # arg_offset = 0 - - # # Initialize foreign arrays - # accounts = params.account_references[:] if params.account_references else [] - # foreign_apps = params.app_references[:] if params.app_references else [] - # foreign_assets = params.asset_references[:] if params.asset_references else [] - # boxes = params.box_references[:] if params.box_references else [] - - # # Prepare app args starting with method selector - # encoded_args = [] - # encoded_args.append(params.method.get_selector()) - - # # Process method arguments - # if params.args: - # for i, arg in enumerate(params.args): - # if self._is_abi_value(arg): - # method_args.append(arg) - # continue - - # if algosdk.abi.is_abi_transaction_type(params.method.args[i + arg_offset].type): - # match arg: - # case ( - # AppCreateMethodCall() - # | AppCallMethodCall() - # | AppUpdateMethodCall() - # | AppDeleteMethodCall() - # ): - # temp_txn_with_signers = self._build_method_call(arg, suggested_params) - # method_args.extend(temp_txn_with_signers) - # arg_offset += len(temp_txn_with_signers) - 1 - # continue - # case AppCallParams(): - # txn = self._build_app_call(arg, suggested_params) - # case PaymentParams(): - # txn = self._build_payment(arg, suggested_params) - # case AssetOptInParams(): - # txn = self._build_asset_transfer( - # AssetTransferParams(**arg.__dict__, receiver=arg.sender, amount=0), suggested_params - # ) - # case _: - # raise ValueError(f"Unsupported method arg transaction type: {arg!s}") - - # method_args.append( - # TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender)) - # ) - # continue - - # # Handle ABI reference types - # if algosdk.abi.is_abi_reference_type(params.method.args[i + arg_offset].type): - # arg_type = params.method.args[i + arg_offset].type - # if arg_type == algosdk.abi.ABIReferenceType.ACCOUNT: - # address_type = algosdk.abi.AddressType() - # account_arg = address_type.decode(address_type.encode(cast(str | bytes, arg))) - # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( - # account_arg, accounts, params.sender - # ) - # method_args.append(current_arg) - # elif arg_type == algosdk.abi.ABIReferenceType.ASSET: - # asset_arg = int(cast(int, arg)) - # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( - # asset_arg, foreign_assets - # ) - # method_args.append(current_arg) - # elif arg_type == algosdk.abi.ABIReferenceType.APPLICATION: - # app_arg = int(cast(int, arg)) - # current_arg = algosdk.atomic_transaction_composer.populate_foreign_array( - # app_arg, foreign_apps, params.app_id - # ) - # method_args.append(current_arg) - # else: - # raise ValueError(f"Unsupported ABI reference type: {arg_type}") - # continue - - # # Regular ABI value - # method_args.append(arg) - - # # Encode regular ABI arguments - # for i, arg in enumerate(method_args): - # if isinstance(arg, TransactionWithSigner): - # continue - # arg_type = params.method.args[i].type - # if isinstance(arg_type, algosdk.abi.ABIType): - # try: - # encoded_args.append(arg_type.encode(arg)) - # except Exception as e: - # if ( - # isinstance(e, AttributeError) - # and isinstance(arg_type, algosdk.abi.StringType) - # and isinstance(arg, bytes) - # ): - # # Assume user passed a manually abi encoded string, ignore re-encoding and append as raw bytes - # encoded_args.append(arg) - # else: - # raise ValueError(f"Error encoding argument {arg} of type {arg_type}") from e - - # # Create the app call transaction - # txn = algosdk.transaction.ApplicationCallTxn( - # sender=params.sender, - # sp=suggested_params, - # index=params.app_id or 0, - # on_complete=params.on_complete or algosdk.transaction.OnComplete.NoOpOC, - # app_args=encoded_args, - # accounts=accounts, - # foreign_apps=foreign_apps, - # foreign_assets=foreign_assets, - # boxes=[AppManager.get_box_reference(ref) for ref in boxes] if boxes else None, - # note=params.note, - # lease=params.lease, - # ) - - # result = [TransactionWithSigner(txn=txn, signer=params.signer or self.get_signer(params.sender))] - - # # Add any transaction arguments - # for arg in method_args: - # if isinstance(arg, TransactionWithSigner): - # result.append(arg) - - # # Store the method for this transaction - # self.txn_method_map[txn.get_txid()] = params.method - - # return result - def _build_payment( self, params: PaymentParams, suggested_params: algosdk.transaction.SuggestedParams ) -> algosdk.transaction.Transaction: @@ -1392,6 +1274,9 @@ def _build_txn( # noqa: C901, PLR0912, PLR0911 return [txn] case AtomicTransactionComposer(): return self._build_atc(txn) + case algosdk.transaction.Transaction(): + signer = self.get_signer(txn.sender) + return [TransactionWithSigner(txn=txn, signer=signer)] case AppCreateMethodCall() | AppCallMethodCall() | AppUpdateMethodCall() | AppDeleteMethodCall(): return self._build_method_call(txn, suggested_params) diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index cfb4682..cdc2b01 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -5,12 +5,11 @@ import algosdk import algosdk.atomic_transaction_composer -from algosdk.atomic_transaction_composer import AtomicTransactionResponse +from algosdk.atomic_transaction_composer import ABIResult, AtomicTransactionResponse from algosdk.transaction import Transaction from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager -from algokit_utils.models.abi import ABIValue from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, AppCallParams, @@ -43,6 +42,7 @@ class SendSingleTransactionResult: # Fields from SendAtomicTransactionComposerResults group_id: str + tx_id: str | None = None tx_ids: list[str] # Full array of transaction IDs transactions: list[Transaction] confirmations: list[algosdk.v2client.algod.AlgodResponseType] @@ -56,7 +56,7 @@ class SendSingleAssetCreateTransactionResult(SendSingleTransactionResult): @dataclass(frozen=True) class SendAppTransactionResult(SendSingleTransactionResult): - return_value: ABIValue | None = None + return_value: ABIResult | None = None @dataclass(frozen=True) @@ -119,6 +119,7 @@ def send_transaction(params: T) -> SendSingleTransactionResult: **raw_result_dict, confirmation=raw_result.confirmations[-1], transaction=raw_result.transactions[-1], + tx_id=raw_result.tx_ids[-1], ) if post_log: diff --git a/tests/accounts/test_account_manager.py b/tests/accounts/test_account_manager.py index e8f1335..ec56a00 100644 --- a/tests/accounts/test_account_manager.py +++ b/tests/accounts/test_account_manager.py @@ -8,10 +8,19 @@ @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account def test_new_account_is_retrieved_and_funded(algorand: AlgorandClient) -> None: diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 9256526..058d19e 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -1,10 +1,12 @@ import base64 import json +import random from pathlib import Path from typing import Any import algosdk import pytest +from algosdk.atomic_transaction_composer import TransactionSigner, TransactionWithSigner from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_client import ( @@ -14,21 +16,30 @@ FundAppAccountParams, ) from algokit_utils.applications.app_manager import AppManager, BoxReference -from algokit_utils.applications.utils import arc32_to_arc56 +from algokit_utils.applications.utils import arc32_to_arc56, get_arc56_method from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.errors.logic_error import LogicError from algokit_utils.models.abi import ABIType from algokit_utils.models.account import Account from algokit_utils.models.amount import AlgoAmount from algokit_utils.models.application import Arc56Contract -from algokit_utils.transactions.transaction_composer import AppCreateParams +from algokit_utils.transactions.transaction_composer import AppCreateParams, PaymentParams @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture @@ -318,6 +329,135 @@ def test_construct_transaction_with_boxes(test_app_client: AppClient) -> None: assert call2.transactions[0].boxes == [BoxReference(app_id=0, name=b"1")] +def test_construct_transaction_with_abi_encoding_including_transaction( + algorand: AlgorandClient, funded_account: Account, test_app_client: AppClient +) -> None: + # Create a payment transaction with random amount + amount = AlgoAmount.from_micro_algos(random.randint(1, 10000)) + payment_txn = algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=amount, + ) + ) + + # Call the ABI method with the payment transaction + result = test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[payment_txn.transaction, "test"], + ) + ) + + assert result.confirmation + assert len(result.transactions) == 2 # noqa: PLR2004 + return_value = AppManager.get_abi_return( + result.confirmation, get_arc56_method("call_abi_txn", test_app_client.app_spec) + ) + expected_return = f"Sent {amount.micro_algos}. test" + assert result.return_value + assert result.return_value.return_value == expected_return + assert return_value + assert return_value.return_value == result.return_value.return_value + + +def test_sign_all_transactions_in_group_with_abi_call_with_transaction_arg( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + # Create a payment transaction with a random amount + amount = AlgoAmount.from_micro_algos(random.randint(1, 10000)) + txn = algorand.create_transaction.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=amount, + ) + ) + + called_indexes = [] + original_signer = algorand.account.get_signer(funded_account.address) + + class IndexCapturingSigner(TransactionSigner): + def sign_transactions( + self, txn_group: list[algosdk.transaction.Transaction], indexes: list[int] + ) -> list[algosdk.transaction.GenericSignedTransaction]: + called_indexes.extend(indexes) + return original_signer.sign_transactions(txn_group, indexes) + + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[txn, "test"], + sender=funded_account.address, + signer=IndexCapturingSigner(), + ) + ) + + assert called_indexes == [0, 1] + + +def test_sign_transaction_in_group_with_different_signer_if_provided( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + # Generate a new account + test_account = algorand.account.random() + algorand.account.ensure_funded( + account_fo_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(10), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + # Fund the account with 1 Algo + txn = algorand.create_transaction.payment( + PaymentParams( + sender=test_account.address, + receiver=test_account.address, + amount=AlgoAmount.from_algos(random.randint(1, 5)), + ) + ) + + # Call method with transaction and signer + test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_txn", + args=[TransactionWithSigner(txn=txn, signer=test_account.signer), "test"], + ) + ) + + +def test_construct_transaction_with_abi_encoding_including_foreign_references_not_in_signature( + algorand: AlgorandClient, test_app_client: AppClient, funded_account: Account +) -> None: + test_account = algorand.account.random() + algorand.account.ensure_funded( + account_fo_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(10), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + result = test_app_client.send.call( + AppClientMethodCallWithSendParams( + method="call_abi_foreign_refs", + app_references=[345], + account_references=[test_account.address], + asset_references=[567], + ) + ) + + # Assuming the method returns a string matching the format below + expected_return = AppManager.get_abi_return( + result.confirmations[0], + get_arc56_method("call_abi_foreign_refs", test_app_client.app_spec), + ) + assert result.return_value + assert "App: 345, Asset: 567, Account: " in result.return_value.return_value + assert expected_return + assert expected_return.return_value == result.return_value.return_value + + def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> None: # Test global state test_app_client.send.call( @@ -573,11 +713,13 @@ def test_abi_with_default_arg_method( AppClientMethodCallWithSendParams(method=method_signature, args=[defined_value]) ) - assert defined_value_result.return_value == "Local state, defined value" + assert defined_value_result.return_value + assert defined_value_result.return_value.return_value == "Local state, defined value" # Test with default value default_value_result = app_client.send.call(AppClientMethodCallWithSendParams(method=method_signature, args=[None])) - assert default_value_result.return_value == "Local state, banana" + assert default_value_result.return_value + assert default_value_result.return_value.return_value == "Local state, banana" def test_exposing_logic_error(test_app_client_with_sourcemaps: AppClient) -> None: diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index 0de8b34..2fc5a2a 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -6,18 +6,36 @@ from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete from algokit_utils._legacy_v2.deploy import OnSchemaBreak, OnUpdate, OperationPerformed -from algokit_utils.applications.app_client import AppClientMethodCallParams -from algokit_utils.applications.app_factory import AppFactory, AppFactoryCreateWithSendParams +from algokit_utils.applications.app_client import ( + AppClientMethodCallParams, + AppClientMethodCallWithCompilationAndSendParams, + AppClientMethodCallWithSendParams, +) +from algokit_utils.applications.app_factory import ( + AppFactory, + AppFactoryCreateMethodCallParams, + AppFactoryCreateWithSendParams, +) from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import PaymentParams @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture @@ -212,7 +230,146 @@ def test_deploy_app_replace(factory: AppFactory) -> None: assert replaced_app.app_id > created_app.app_id assert replaced_app.app_address == algosdk.logic.get_application_address(replaced_app.app_id) assert replaced_app.confirmation is not None - assert replaced_app.delete_return is not None - assert replaced_app.delete_return.confirmation is not None - assert replaced_app.delete_return.transaction.application_id == created_app.app_id # type: ignore[union-attr] - assert replaced_app.delete_return.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] + assert replaced_app.delete_result is not None + assert replaced_app.delete_result.confirmation is not None + assert len(replaced_app.transactions) == 2 # noqa: PLR2004 + assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) + assert replaced_app.delete_result.transaction.index == created_app.app_id # type: ignore[union-attr] + assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] + + +def test_deploy_app_replace_abi(factory: AppFactory) -> None: + _, created_app = factory.deploy( + deploy_time_params={ + "VALUE": 1, + }, + deletable=True, + populate_app_call_resources=False, + ) + + _, replaced_app = factory.deploy( + deploy_time_params={ + "VALUE": 2, + }, + on_update=OnUpdate.ReplaceApp, + create_params=AppClientMethodCallParams(method="create_abi", args=["arg_io"]), + delete_params=AppClientMethodCallParams(method="delete_abi", args=["arg2_io"]), + ) + + assert replaced_app.operation_performed == OperationPerformed.Replace + assert replaced_app.app_id > created_app.app_id + assert replaced_app.app_address == algosdk.logic.get_application_address(replaced_app.app_id) + assert replaced_app.confirmation is not None + assert replaced_app.delete_result is not None + assert replaced_app.delete_result.confirmation is not None + assert len(replaced_app.transactions) == 2 + assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) + assert replaced_app.delete_result.transaction.index == created_app.app_id # type: ignore[union-attr] + assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] + assert replaced_app.return_value == "arg_io" + assert replaced_app.delete_return_value == "arg2_io" + + +def test_create_then_call_app(factory: AppFactory) -> None: + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 1, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + call = app_client.send.call(AppClientMethodCallWithSendParams(method="call_abi", args=["test"])) + + assert call.return_value + assert call.return_value.return_value == "Hello, test" + + +def test_call_app_with_rekey(funded_account: Account, algorand: AlgorandClient, factory: AppFactory) -> None: + rekey_to = algorand.account.random() + + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 1, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + app_client.send.opt_in(AppClientMethodCallWithSendParams(method="opt_in", rekey_to=rekey_to.address)) + + # If the rekey didn't work this will throw + rekeyed_account = algorand.account.rekeyed(funded_account.address, rekey_to) + algorand.send.payment( + PaymentParams(amount=AlgoAmount.from_algo(0), sender=rekeyed_account.address, receiver=funded_account.address) + ) + + +def test_create_app_with_abi(factory: AppFactory) -> None: + _, call_return = factory.send.create( + AppFactoryCreateMethodCallParams( + method="create_abi", + args=["string_io"], + deploy_time_params={ + "UPDATABLE": 0, + "DELETABLE": 0, + "VALUE": 1, + }, + ) + ) + + assert call_return.return_value + # Fix return value issues + assert call_return.return_value.return_value == "string_io" + + +def test_update_app_with_abi(factory: AppFactory) -> None: + deploy_time_params = { + "UPDATABLE": 1, + "DELETABLE": 0, + "VALUE": 1, + } + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params=deploy_time_params, + ) + ) + + call_return = app_client.send.update( + AppClientMethodCallWithCompilationAndSendParams( + method="update_abi", + args=["string_io"], + deploy_time_params=deploy_time_params, + ) + ) + + assert call_return.return_value is not None + assert call_return.return_value.return_value == "string_io" + # TODO: fix this + # assert call_return.compiled_approval is not None + + +def test_delete_app_with_abi(factory: AppFactory) -> None: + app_client, _ = factory.send.bare.create( + AppFactoryCreateWithSendParams( + deploy_time_params={ + "UPDATABLE": 0, + "DELETABLE": 1, + "VALUE": 1, + }, + ) + ) + + call_return = app_client.send.delete( + AppClientMethodCallWithSendParams( + method="delete_abi", + args=["string_io"], + ) + ) + + assert call_return.return_value is not None + assert call_return.return_value.return_value == "string_io" diff --git a/tests/applications/test_app_manager.py b/tests/applications/test_app_manager.py index c10508c..57313d3 100644 --- a/tests/applications/test_app_manager.py +++ b/tests/applications/test_app_manager.py @@ -3,14 +3,24 @@ from algokit_utils.applications.app_manager import AppManager from algokit_utils.clients.algorand_client import AlgorandClient from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount from tests.conftest import check_output_stability @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account def test_template_substitution() -> None: diff --git a/tests/assets/test_asset_manager.py b/tests/assets/test_asset_manager.py index 8d2c6e8..2d5ea4e 100644 --- a/tests/assets/test_asset_manager.py +++ b/tests/assets/test_asset_manager.py @@ -1,8 +1,7 @@ -import algosdk import pytest from algosdk.atomic_transaction_composer import AccountTransactionSigner -from algokit_utils import Account, get_account +from algokit_utils import Account from algokit_utils.assets.asset_manager import ( AccountAssetInformation, AssetInformation, @@ -14,24 +13,32 @@ AssetCreateParams, PaymentParams, ) -from tests.conftest import get_unique_name @pytest.fixture -def sender(funded_account: Account) -> Account: - return funded_account +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() @pytest.fixture -def receiver(algod_client: algosdk.v2client.algod.AlgodClient) -> Account: - return get_account(algod_client, get_unique_name()) +def sender(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def receiver(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + return new_account def test_get_by_id(algorand: AlgorandClient, sender: Account) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 1031d11..231828d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,18 +18,11 @@ ApplicationSpecification, EnsureBalanceParameters, ensure_funded, - get_account, - get_algod_client, - get_indexer_client, - get_kmd_client_from_algod_client, replace_template_variables, ) -from legacy_v2_tests import app_client_test if TYPE_CHECKING: - from algosdk.kmd import KMDClient from algosdk.v2client.algod import AlgodClient - from algosdk.v2client.indexer import IndexerClient @pytest.fixture(autouse=True, scope="session") @@ -127,41 +120,6 @@ def is_opted_in(client_fixture: ApplicationClient) -> bool: return any(x for x in apps_local_state if x["id"] == client_fixture.app_id) -@pytest.fixture(scope="session") -def algod_client() -> "AlgodClient": - return get_algod_client() - - -@pytest.fixture(scope="session") -def kmd_client(algod_client: "AlgodClient") -> "KMDClient": - return get_kmd_client_from_algod_client(algod_client) - - -@pytest.fixture(scope="session") -def indexer_client() -> "IndexerClient": - return get_indexer_client() - - -@pytest.fixture -def creator(algod_client: "AlgodClient") -> Account: - creator_name = get_unique_name() - return get_account(algod_client, creator_name) - - -@pytest.fixture(scope="session") -def funded_account(algod_client: "AlgodClient") -> Account: - creator_name = get_unique_name() - return get_account(algod_client, creator_name) - - -@pytest.fixture(scope="session") -def app_spec() -> ApplicationSpecification: - app_spec = app_client_test.app.build() - path = Path(__file__).parent.parent / "legacy_hello_world" / "app_client_test.json" - path.write_text(app_spec.to_json()) - return read_spec("app_client_test.json", deletable=True, updatable=True, template_values={"VERSION": 1}) - - def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int | None) -> int: if total is None: total = math.floor(random.random() * 100) + 20 diff --git a/tests/test_transaction_composer.py b/tests/test_transaction_composer.py index ac8fd56..a7096c8 100644 --- a/tests/test_transaction_composer.py +++ b/tests/test_transaction_composer.py @@ -27,10 +27,19 @@ @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture diff --git a/tests/transactions/test_transaction_composer.py b/tests/transactions/test_transaction_composer.py index 0d3b75d..9217cc0 100644 --- a/tests/transactions/test_transaction_composer.py +++ b/tests/transactions/test_transaction_composer.py @@ -31,10 +31,19 @@ @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture @@ -82,7 +91,7 @@ def test_add_asset_create(algorand: AlgorandClient, funded_account: Account) -> composer.add_asset_create(params) built = composer.build_transactions() - response = composer.execute(max_rounds_to_wait=20) + response = composer.send(max_rounds_to_wait=20) created_asset = algorand.client.algod.asset_info( algorand.client.algod.pending_transaction_info(response.tx_ids[0])["asset-index"] # type: ignore[call-overload] )["params"] @@ -138,7 +147,7 @@ def test_add_asset_config(algorand: AlgorandClient, funded_account: Account, fun assert txn.index == asset_before_config_index assert txn.manager == funded_secondary_account.address - composer.execute(max_rounds_to_wait=20) + composer.send(max_rounds_to_wait=20) updated_asset = algorand.client.algod.asset_info(asset_id=asset_before_config_index)["params"] # type: ignore[call-overload] assert updated_asset["manager"] == funded_secondary_account.address @@ -165,7 +174,7 @@ def test_add_app_create(algorand: AlgorandClient, funded_account: Account) -> No assert txn.sender == funded_account.address assert txn.approval_program == b"\x06\x81\x01" assert txn.clear_program == b"\x06\x81\x01" - composer.execute(max_rounds_to_wait=20) + composer.send(max_rounds_to_wait=20) def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Account) -> None: @@ -183,7 +192,7 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco schema={"global_ints": 0, "global_bytes": 0, "local_ints": 0, "local_bytes": 0}, ) ) - response = composer.execute() + response = composer.send() app_id = algorand.client.algod.pending_transaction_info(response.tx_ids[0])["application-index"] # type: ignore[call-overload] composer = TransactionComposer( @@ -204,8 +213,8 @@ def test_add_app_call_method_call(algorand: AlgorandClient, funded_account: Acco assert isinstance(built.transactions[0], ApplicationCallTxn) txn = built.transactions[0] assert txn.sender == funded_account.address - response = composer.execute(max_rounds_to_wait=20) - assert response.returns[-1] == "Hello, world" + response = composer.send(max_rounds_to_wait=20) + assert response.returns[-1].return_value == "Hello, world" def test_simulate(algorand: AlgorandClient, funded_account: Account) -> None: diff --git a/tests/transactions/test_transaction_creator.py b/tests/transactions/test_transaction_creator.py index cd034db..3c94404 100644 --- a/tests/transactions/test_transaction_creator.py +++ b/tests/transactions/test_transaction_creator.py @@ -35,10 +35,19 @@ @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index 5c4ea01..dd207bc 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -16,7 +16,6 @@ from algokit_utils import ( Account, - get_account, ) from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_manager import AppManager @@ -39,14 +38,22 @@ TransactionComposer, ) from algokit_utils.transactions.transaction_sender import AlgorandClientTransactionSender -from tests.conftest import get_unique_name @pytest.fixture -def algorand(funded_account: Account) -> AlgorandClient: - client = AlgorandClient.default_local_net() - client.set_signer(sender=funded_account.address, signer=funded_account.signer) - return client +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account @pytest.fixture @@ -55,8 +62,13 @@ def sender(funded_account: Account) -> Account: @pytest.fixture -def receiver(algod_client: "algosdk.v2client.algod.AlgodClient") -> Account: - return get_account(algod_client, get_unique_name()) +def receiver(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + return new_account @pytest.fixture @@ -94,20 +106,18 @@ def test_hello_world_arc32_app_id( @pytest.fixture -def transaction_sender( - algod_client: "algosdk.v2client.algod.AlgodClient", sender: Account -) -> AlgorandClientTransactionSender: +def transaction_sender(algorand: AlgorandClient, sender: Account) -> AlgorandClientTransactionSender: def new_group() -> TransactionComposer: return TransactionComposer( - algod=algod_client, + algod=algorand.client.algod, get_signer=lambda _: sender.signer, ) return AlgorandClientTransactionSender( new_group=new_group, - asset_manager=AssetManager(algod_client, new_group), - app_manager=AppManager(algod_client), - algod_client=algod_client, + asset_manager=AssetManager(algorand.client.algod, new_group), + app_manager=AppManager(algorand.client.algod), + algod_client=algorand.client.algod, ) @@ -413,7 +423,8 @@ def test_app_call_method_call( ) result = transaction_sender.app_call_method_call(params) - assert result.return_value == "Hello2, test" + assert result.return_value + assert result.return_value.return_value == "Hello2, test" @patch("logging.Logger.debug") From 77dbd8afc5d34a90192c8111fdf8aeb70bd60ea3 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Sun, 8 Dec 2024 23:44:07 +0100 Subject: [PATCH 15/19] chore: wip --- ...new_client_missing_source_map.approved.txt | 6 +- .../_legacy_v2/application_client.py | 6 +- src/algokit_utils/_legacy_v2/logic_error.py | 6 +- src/algokit_utils/applications/app_client.py | 38 ++++++++- src/algokit_utils/errors/logic_error.py | 24 +++++- src/algokit_utils/models/application.py | 10 +++ tests/applications/test_app_factory.py | 85 ++++++++++++++++++- .../arc56_app_spec.json} | 0 8 files changed, 157 insertions(+), 18 deletions(-) rename tests/artifacts/{arc_56_templates/templates_arc56_draft.json => testing_app_arc56/arc56_app_spec.json} (100%) diff --git a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt index 598d4c2..206749c 100644 --- a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt +++ b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt @@ -2,6 +2,6 @@ Txn {txn} had error 'assert failed pc=743' at PC 743: Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map \ No newline at end of file + 1.Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2.Set approval_source_map from a previously compiled approval program OR + 3.Import a previously exported source map using import_source_map diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index 254041b..002fc0b 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -915,9 +915,9 @@ def _check_app_id(self) -> None: if self.app_id == 0: raise Exception( "ApplicationClient is not associated with an app instance, to resolve either:\n" - "1.) provide an app_id on construction OR\n" - "2.) provide a creator address so an app can be searched for OR\n" - "3.) create an app first using create or deploy methods" + "1.provide an app_id on construction OR\n" + "2.provide a creator address so an app can be searched for OR\n" + "3.create an app first using create or deploy methods" ) def _resolve_method( diff --git a/src/algokit_utils/_legacy_v2/logic_error.py b/src/algokit_utils/_legacy_v2/logic_error.py index a3fb9ac..a556d90 100644 --- a/src/algokit_utils/_legacy_v2/logic_error.py +++ b/src/algokit_utils/_legacy_v2/logic_error.py @@ -77,9 +77,9 @@ def trace(self, lines: int = 5) -> str: return """ Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map""" + 1. Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2. Set approval_source_map from a previously compiled approval program OR + 3. Import a previously exported source map using import_source_map""" program_lines = copy(self.lines) program_lines[self.line_no] += "\t\t<-- Error" diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index 2caf9c6..c3556b0 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Protocol, TypeVar import algosdk +from algosdk.source_map import SourceMap from algosdk.transaction import OnComplete, Transaction from algokit_utils._legacy_v2.application_specification import ApplicationSpecification @@ -45,7 +46,6 @@ from collections.abc import Callable from algosdk.atomic_transaction_composer import TransactionSigner - from algosdk.source_map import SourceMap from algokit_utils.applications.app_manager import ( AppManager, @@ -65,6 +65,12 @@ T = TypeVar("T") # For generic return type in _handle_call_errors +def camel_to_snake_case(name: str) -> str: + import re + + return re.sub(r"(? int: # noqa: C901 """Calculate the offset after constant blocks in TEAL program. @@ -878,7 +884,9 @@ def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) return arc32_to_arc56(spec) elif isinstance(spec, dict): - return Arc56Contract(**spec) + # normalize field names to lowercase to python camel + transformed_spec = {camel_to_snake_case(k): v for k, v in spec.items()} + return Arc56Contract(**transformed_spec) else: raise ValueError("Invalid app spec format") @@ -1100,8 +1108,30 @@ def export_source_maps(self) -> AppSourceMaps: ) def import_source_maps(self, source_maps: AppSourceMaps) -> None: - self._approval_source_map = source_maps.approval_source_map - self._clear_source_map = source_maps.clear_source_map + if not source_maps.approval_source_map: + raise ValueError("Approval source map is required") + if not source_maps.clear_source_map: + raise ValueError("Clear source map is required") + + if not isinstance(source_maps.approval_source_map, dict | SourceMap): + raise ValueError( + "Approval source map supplied is of invalid type. Must be a raw dict or `algosdk.source_map.SourceMap`" + ) + if not isinstance(source_maps.clear_source_map, dict | SourceMap): + raise ValueError( + "Clear source map supplied is of invalid type. Must be a raw dict or `algosdk.source_map.SourceMap`" + ) + + self._approval_source_map = ( + SourceMap(source_map=source_maps.approval_source_map) + if isinstance(source_maps.approval_source_map, dict) + else source_maps.approval_source_map + ) + self._clear_source_map = ( + SourceMap(source_map=source_maps.clear_source_map) + if isinstance(source_maps.clear_source_map, dict) + else source_maps.clear_source_map + ) def get_local_state(self, address: str) -> dict[str, AppState]: return self._state_accessor.get_local_state(address) diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py index b3d9c12..2951457 100644 --- a/src/algokit_utils/errors/logic_error.py +++ b/src/algokit_utils/errors/logic_error.py @@ -19,6 +19,8 @@ ".*transaction (?P[A-Z0-9]+): logic eval error: (?P.*). Details: .*pc=(?P[0-9]+).*" ) +DEFAULT_BLAST_RADIUS = 5 + class LogicErrorData(TypedDict): transaction_id: str @@ -49,6 +51,8 @@ def parse_logic_error( class LogicError(Exception): + _blast_radius = DEFAULT_BLAST_RADIUS + def __init__( self, *, @@ -60,6 +64,7 @@ def __init__( pc: int, logic_error: Exception | None = None, traces: list[SimulationTrace] | None = None, + blast_radius: int | None = None, ): self.logic_error = logic_error self.logic_error_str = logic_error_str @@ -70,8 +75,19 @@ def __init__( self.message = message self.pc = pc self.traces = traces - self.line_no = self.source_map.get_line_for_pc(self.pc) if self.source_map else None + self._blast_radius = blast_radius or self._blast_radius + + if self.line_no and self.line_no > 0: + start = max(0, self.line_no - self._blast_radius) + stop = min(len(self.program), self.line_no + self._blast_radius) + + stack_lines = self.program.splitlines()[start:stop] + + middle_index = len(stack_lines) // 2 + stack_lines[middle_index] += " <--- Error" + + self.stack = "\n".join(stack_lines) def __str__(self) -> str: return ( @@ -85,9 +101,9 @@ def trace(self, lines: int = 5) -> str: return """ Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.) Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.) Set approval_source_map from a previously compiled approval program OR - 3.) Import a previously exported source map using import_source_map""" + 1.Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2.Set approval_source_map from a previously compiled approval program OR + 3.Import a previously exported source map using import_source_map""" program_lines = copy(self.lines) program_lines[self.line_no] += "\t\t<-- Error" diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index c04312c..87943a5 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -210,6 +210,16 @@ class Arc56Contract: template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None + def __init__(self, **kwargs: Any) -> None: + if isinstance(kwargs.get("state"), dict): + kwargs["state"] = Arc56ContractState(**kwargs["state"]) + if isinstance(kwargs.get("methods"), list): + kwargs["methods"] = [Method(**method) for method in kwargs["methods"]] + if isinstance(kwargs.get("source_info"), dict): + kwargs["source_info"] = {k: ProgramSourceInfo(**v) for k, v in kwargs["source_info"].items()} + + super().__init__(**kwargs) + @dataclass(kw_only=True, frozen=True) class AppState: diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index 2fc5a2a..fca3fbf 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -5,11 +5,12 @@ from algosdk.logic import get_application_address from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete -from algokit_utils._legacy_v2.deploy import OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import ( + AppClient, AppClientMethodCallParams, AppClientMethodCallWithCompilationAndSendParams, AppClientMethodCallWithSendParams, + AppClientParams, ) from algokit_utils.applications.app_factory import ( AppFactory, @@ -17,6 +18,7 @@ AppFactoryCreateWithSendParams, ) from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.errors.logic_error import LogicError from algokit_utils.models.account import Account from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import PaymentParams @@ -49,6 +51,18 @@ def factory(algorand: AlgorandClient, funded_account: Account, app_spec: str) -> return algorand.client.get_app_factory(app_spec=app_spec, default_sender=funded_account.address) +@pytest.fixture +def arc56_factory( + algorand: AlgorandClient, + funded_account: Account, +) -> AppFactory: + """Create AppFactory fixture""" + arc56_raw_spec = ( + Path(__file__).parent.parent / "artifacts" / "testing_app_arc56" / "arc56_app_spec.json" + ).read_text() + return algorand.client.get_app_factory(app_spec=arc56_raw_spec, default_sender=funded_account.address) + + def test_create_app(factory: AppFactory) -> None: """Test creating an app using the factory""" app_client, result = factory.send.bare.create( @@ -373,3 +387,72 @@ def test_delete_app_with_abi(factory: AppFactory) -> None: assert call_return.return_value is not None assert call_return.return_value.return_value == "string_io" + + +def test_export_import_sourcemaps( + factory: AppFactory, + algorand: AlgorandClient, + funded_account: Account, +) -> None: + # Export source maps from original client + client, app = factory.deploy(deploy_time_params={"VALUE": 1}) + old_sourcemaps = client.export_source_maps() + + # Create new client instance + new_client = AppClient( + AppClientParams( + app_id=app.app_id, + default_sender=funded_account.address, + default_signer=funded_account.signer, + algorand=algorand, + app_spec=client.app_spec, + ) + ) + + # Test error handling before importing source maps + with pytest.raises(LogicError) as exc_info: + new_client.send.call(AppClientMethodCallWithSendParams(method="error")) + + assert "assert failed" in exc_info.value.message + + # Import source maps into new client + new_client.import_source_maps(old_sourcemaps) + + # Test error handling after importing source maps + with pytest.raises(LogicError) as exc_info: + new_client.send.call(AppClientMethodCallWithSendParams(method="error")) + + error = exc_info.value + assert error.stack == ( + "// error\n" + "error_7:\n" + "proto 0 0\n" + "intc_0 // 0\n" + "// Deliberate error\n" + "assert <--- Error\n" + "retsub\n\n" + "// create\n" + "create_8:" + ) + assert error.pc == 885 + assert error.message == "assert failed pc=885" + assert len(error.transaction_id) == 52 + + +def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( + arc56_factory: AppFactory, +) -> None: + client, _ = arc56_factory.deploy( + create_params=AppClientMethodCallParams(method="createApplication"), + deploy_time_params={ + "bytes64TmplVar": "0" * 64, + "uint64TmplVar": 123, + "bytes32TmplVar": "0" * 32, + "bytesTmplVar": "foo", + }, + ) + + with pytest.raises(Exception) as exc_info: + client.send.call(AppClientMethodCallWithSendParams(method="throwError")) + + assert "this is an error" in str(exc_info.value) diff --git a/tests/artifacts/arc_56_templates/templates_arc56_draft.json b/tests/artifacts/testing_app_arc56/arc56_app_spec.json similarity index 100% rename from tests/artifacts/arc_56_templates/templates_arc56_draft.json rename to tests/artifacts/testing_app_arc56/arc56_app_spec.json From aad3ca1125fbfb9bf2820785084d114a268dcbbb Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Mon, 9 Dec 2024 00:56:21 +0100 Subject: [PATCH 16/19] chore: wip --- poetry.lock | 734 +++++++++--------- pyproject.toml | 4 +- src/algokit_utils/applications/app_client.py | 48 +- src/algokit_utils/applications/app_manager.py | 3 +- src/algokit_utils/applications/utils.py | 38 +- src/algokit_utils/errors/logic_error.py | 26 +- src/algokit_utils/models/application.py | 283 ++++++- tests/applications/test_app_client.py | 2 +- tests/applications/test_app_factory.py | 51 +- 9 files changed, 752 insertions(+), 437 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2413884..e173428 100644 --- a/poetry.lock +++ b/poetry.lock @@ -13,35 +13,35 @@ files = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.7.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" files = [ - {file = "anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d"}, - {file = "anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c"}, + {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"}, + {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21.0b1)"] +doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] trio = ["trio (>=0.26.1)"] [[package]] name = "astroid" -version = "3.3.5" +version = "3.3.6" description = "An abstract syntax tree for Python with inference support." optional = false python-versions = ">=3.9.0" files = [ - {file = "astroid-3.3.5-py3-none-any.whl", hash = "sha256:a9d1c946ada25098d790e079ba2a1b112157278f3fb7e718ae6a9252f5835dc8"}, - {file = "astroid-3.3.5.tar.gz", hash = "sha256:5cfc40ae9f68311075d27ef68a4841bdc5cc7f6cf86671b49f00607d30188e2d"}, + {file = "astroid-3.3.6-py3-none-any.whl", hash = "sha256:db676dc4f3ae6bfe31cda227dc60e03438378d7a896aec57422c95634e8d722f"}, + {file = "astroid-3.3.6.tar.gz", hash = "sha256:6aaea045f938c735ead292204afdb977a36e989522b7833ef6fea94de743f442"}, ] [package.dependencies] @@ -104,13 +104,13 @@ files = [ [[package]] name = "cachecontrol" -version = "0.14.0" +version = "0.14.1" description = "httplib2 caching for requests" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "cachecontrol-0.14.0-py3-none-any.whl", hash = "sha256:f5bf3f0620c38db2e5122c0726bdebb0d16869de966ea6a2befe92470b740ea0"}, - {file = "cachecontrol-0.14.0.tar.gz", hash = "sha256:7db1195b41c81f8274a7bbd97c956f44e8348265a1bc7641c37dfebc39f0c938"}, + {file = "cachecontrol-0.14.1-py3-none-any.whl", hash = "sha256:65e3abd62b06382ce3894df60dde9e0deb92aeb734724f68fa4f3b91e97206b9"}, + {file = "cachecontrol-0.14.1.tar.gz", hash = "sha256:06ef916a1e4eb7dba9948cdfc9c76e749db2e02104a9a1277e8b642591a0f717"}, ] [package.dependencies] @@ -119,7 +119,7 @@ msgpack = ">=0.5.2,<2.0.0" requests = ">=2.16.0" [package.extras] -dev = ["CacheControl[filecache,redis]", "black", "build", "cherrypy", "furo", "mypy", "pytest", "pytest-cov", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] filecache = ["filelock (>=3.8.0)"] redis = ["redis (>=2.10.5)"] @@ -379,73 +379,73 @@ files = [ [[package]] name = "coverage" -version = "7.6.3" +version = "7.6.9" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.9" files = [ - {file = "coverage-7.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6da42bbcec130b188169107ecb6ee7bd7b4c849d24c9370a0c884cf728d8e976"}, - {file = "coverage-7.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c222958f59b0ae091f4535851cbb24eb57fc0baea07ba675af718fb5302dddb2"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab84a8b698ad5a6c365b08061920138e7a7dd9a04b6feb09ba1bfae68346ce6d"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70a6756ce66cd6fe8486c775b30889f0dc4cb20c157aa8c35b45fd7868255c5c"}, - {file = "coverage-7.6.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c2e6fa98032fec8282f6b27e3f3986c6e05702828380618776ad794e938f53a"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:921fbe13492caf6a69528f09d5d7c7d518c8d0e7b9f6701b7719715f29a71e6e"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6d99198203f0b9cb0b5d1c0393859555bc26b548223a769baf7e321a627ed4fc"}, - {file = "coverage-7.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:87cd2e29067ea397a47e352efb13f976eb1b03e18c999270bb50589323294c6e"}, - {file = "coverage-7.6.3-cp310-cp310-win32.whl", hash = "sha256:a3328c3e64ea4ab12b85999eb0779e6139295bbf5485f69d42cf794309e3d007"}, - {file = "coverage-7.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:bca4c8abc50d38f9773c1ec80d43f3768df2e8576807d1656016b9d3eeaa96fd"}, - {file = "coverage-7.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c51ef82302386d686feea1c44dbeef744585da16fcf97deea2a8d6c1556f519b"}, - {file = "coverage-7.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0ca37993206402c6c35dc717f90d4c8f53568a8b80f0bf1a1b2b334f4d488fba"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c77326300b839c44c3e5a8fe26c15b7e87b2f32dfd2fc9fee1d13604347c9b38"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e484e479860e00da1f005cd19d1c5d4a813324e5951319ac3f3eefb497cc549"}, - {file = "coverage-7.6.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c6c0f4d53ef603397fc894a895b960ecd7d44c727df42a8d500031716d4e8d2"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:37be7b5ea3ff5b7c4a9db16074dc94523b5f10dd1f3b362a827af66a55198175"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43b32a06c47539fe275106b376658638b418c7cfdfff0e0259fbf877e845f14b"}, - {file = "coverage-7.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee77c7bef0724165e795b6b7bf9c4c22a9b8468a6bdb9c6b4281293c6b22a90f"}, - {file = "coverage-7.6.3-cp311-cp311-win32.whl", hash = "sha256:43517e1f6b19f610a93d8227e47790722c8bf7422e46b365e0469fc3d3563d97"}, - {file = "coverage-7.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:04f2189716e85ec9192df307f7c255f90e78b6e9863a03223c3b998d24a3c6c6"}, - {file = "coverage-7.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27bd5f18d8f2879e45724b0ce74f61811639a846ff0e5c0395b7818fae87aec6"}, - {file = "coverage-7.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d546cfa78844b8b9c1c0533de1851569a13f87449897bbc95d698d1d3cb2a30f"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9975442f2e7a5cfcf87299c26b5a45266ab0696348420049b9b94b2ad3d40234"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:583049c63106c0555e3ae3931edab5669668bbef84c15861421b94e121878d3f"}, - {file = "coverage-7.6.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2341a78ae3a5ed454d524206a3fcb3cec408c2a0c7c2752cd78b606a2ff15af4"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a4fb91d5f72b7e06a14ff4ae5be625a81cd7e5f869d7a54578fc271d08d58ae3"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e279f3db904e3b55f520f11f983cc8dc8a4ce9b65f11692d4718ed021ec58b83"}, - {file = "coverage-7.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa23ce39661a3e90eea5f99ec59b763b7d655c2cada10729ed920a38bfc2b167"}, - {file = "coverage-7.6.3-cp312-cp312-win32.whl", hash = "sha256:52ac29cc72ee7e25ace7807249638f94c9b6a862c56b1df015d2b2e388e51dbd"}, - {file = "coverage-7.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:40e8b1983080439d4802d80b951f4a93d991ef3261f69e81095a66f86cf3c3c6"}, - {file = "coverage-7.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9134032f5aa445ae591c2ba6991d10136a1f533b1d2fa8f8c21126468c5025c6"}, - {file = "coverage-7.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99670790f21a96665a35849990b1df447993880bb6463a0a1d757897f30da929"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc7d6b380ca76f5e817ac9eef0c3686e7834c8346bef30b041a4ad286449990"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7b26757b22faf88fcf232f5f0e62f6e0fd9e22a8a5d0d5016888cdfe1f6c1c4"}, - {file = "coverage-7.6.3-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c59d6a4a4633fad297f943c03d0d2569867bd5372eb5684befdff8df8522e39"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f263b18692f8ed52c8de7f40a0751e79015983dbd77b16906e5b310a39d3ca21"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:79644f68a6ff23b251cae1c82b01a0b51bc40c8468ca9585c6c4b1aeee570e0b"}, - {file = "coverage-7.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:71967c35828c9ff94e8c7d405469a1fb68257f686bca7c1ed85ed34e7c2529c4"}, - {file = "coverage-7.6.3-cp313-cp313-win32.whl", hash = "sha256:e266af4da2c1a4cbc6135a570c64577fd3e6eb204607eaff99d8e9b710003c6f"}, - {file = "coverage-7.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:ea52bd218d4ba260399a8ae4bb6b577d82adfc4518b93566ce1fddd4a49d1dce"}, - {file = "coverage-7.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8d4c6ea0f498c7c79111033a290d060c517853a7bcb2f46516f591dab628ddd3"}, - {file = "coverage-7.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:331b200ad03dbaa44151d74daeb7da2cf382db424ab923574f6ecca7d3b30de3"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54356a76b67cf8a3085818026bb556545ebb8353951923b88292556dfa9f812d"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebec65f5068e7df2d49466aab9128510c4867e532e07cb6960075b27658dca38"}, - {file = "coverage-7.6.3-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d33a785ea8354c480515e781554d3be582a86297e41ccbea627a5c632647f2cd"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f7ddb920106bbbbcaf2a274d56f46956bf56ecbde210d88061824a95bdd94e92"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:70d24936ca6c15a3bbc91ee9c7fc661132c6f4c9d42a23b31b6686c05073bde5"}, - {file = "coverage-7.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c30e42ea11badb147f0d2e387115b15e2bd8205a5ad70d6ad79cf37f6ac08c91"}, - {file = "coverage-7.6.3-cp313-cp313t-win32.whl", hash = "sha256:365defc257c687ce3e7d275f39738dcd230777424117a6c76043459db131dd43"}, - {file = "coverage-7.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:23bb63ae3f4c645d2d82fa22697364b0046fbafb6261b258a58587441c5f7bd0"}, - {file = "coverage-7.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:da29ceabe3025a1e5a5aeeb331c5b1af686daab4ff0fb4f83df18b1180ea83e2"}, - {file = "coverage-7.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df8c05a0f574d480947cba11b947dc41b1265d721c3777881da2fb8d3a1ddfba"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1e3b40b82236d100d259854840555469fad4db64f669ab817279eb95cd535c"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4adeb878a374126f1e5cf03b87f66279f479e01af0e9a654cf6d1509af46c40"}, - {file = "coverage-7.6.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43d6a66e33b1455b98fc7312b124296dad97a2e191c80320587234a77b1b736e"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1990b1f4e2c402beb317840030bb9f1b6a363f86e14e21b4212e618acdfce7f6"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:12f9515d875859faedb4144fd38694a761cd2a61ef9603bf887b13956d0bbfbb"}, - {file = "coverage-7.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99ded130555c021d99729fabd4ddb91a6f4cc0707df4b1daf912c7850c373b13"}, - {file = "coverage-7.6.3-cp39-cp39-win32.whl", hash = "sha256:c3a79f56dee9136084cf84a6c7c4341427ef36e05ae6415bf7d787c96ff5eaa3"}, - {file = "coverage-7.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:aac7501ae73d4a02f4b7ac8fcb9dc55342ca98ffb9ed9f2dfb8a25d53eda0e4d"}, - {file = "coverage-7.6.3-pp39.pp310-none-any.whl", hash = "sha256:b9853509b4bf57ba7b1f99b9d866c422c9c5248799ab20e652bbb8a184a38181"}, - {file = "coverage-7.6.3.tar.gz", hash = "sha256:bb7d5fe92bd0dc235f63ebe9f8c6e0884f7360f88f3411bfed1350c872ef2054"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:85d9636f72e8991a1706b2b55b06c27545448baf9f6dbf51c4004609aacd7dcb"}, + {file = "coverage-7.6.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:608a7fd78c67bee8936378299a6cb9f5149bb80238c7a566fc3e6717a4e68710"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96d636c77af18b5cb664ddf12dab9b15a0cfe9c0bde715da38698c8cea748bfa"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d75cded8a3cff93da9edc31446872d2997e327921d8eed86641efafd350e1df1"}, + {file = "coverage-7.6.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7b15f589593110ae767ce997775d645b47e5cbbf54fd322f8ebea6277466cec"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:44349150f6811b44b25574839b39ae35291f6496eb795b7366fef3bd3cf112d3"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d891c136b5b310d0e702e186d70cd16d1119ea8927347045124cb286b29297e5"}, + {file = "coverage-7.6.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:db1dab894cc139f67822a92910466531de5ea6034ddfd2b11c0d4c6257168073"}, + {file = "coverage-7.6.9-cp310-cp310-win32.whl", hash = "sha256:41ff7b0da5af71a51b53f501a3bac65fb0ec311ebed1632e58fc6107f03b9198"}, + {file = "coverage-7.6.9-cp310-cp310-win_amd64.whl", hash = "sha256:35371f8438028fdccfaf3570b31d98e8d9eda8bb1d6ab9473f5a390969e98717"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:932fc826442132dde42ee52cf66d941f581c685a6313feebed358411238f60f9"}, + {file = "coverage-7.6.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:085161be5f3b30fd9b3e7b9a8c301f935c8313dcf928a07b116324abea2c1c2c"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc660a77e1c2bf24ddbce969af9447a9474790160cfb23de6be4fa88e3951c7"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c69e42c892c018cd3c8d90da61d845f50a8243062b19d228189b0224150018a9"}, + {file = "coverage-7.6.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0824a28ec542a0be22f60c6ac36d679e0e262e5353203bea81d44ee81fe9c6d4"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4401ae5fc52ad8d26d2a5d8a7428b0f0c72431683f8e63e42e70606374c311a1"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98caba4476a6c8d59ec1eb00c7dd862ba9beca34085642d46ed503cc2d440d4b"}, + {file = "coverage-7.6.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ee5defd1733fd6ec08b168bd4f5387d5b322f45ca9e0e6c817ea6c4cd36313e3"}, + {file = "coverage-7.6.9-cp311-cp311-win32.whl", hash = "sha256:f2d1ec60d6d256bdf298cb86b78dd715980828f50c46701abc3b0a2b3f8a0dc0"}, + {file = "coverage-7.6.9-cp311-cp311-win_amd64.whl", hash = "sha256:0d59fd927b1f04de57a2ba0137166d31c1a6dd9e764ad4af552912d70428c92b"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8"}, + {file = "coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3"}, + {file = "coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6"}, + {file = "coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f"}, + {file = "coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692"}, + {file = "coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:899b8cd4781c400454f2f64f7776a5d87bbd7b3e7f7bda0cb18f857bb1334664"}, + {file = "coverage-7.6.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:61f70dc68bd36810972e55bbbe83674ea073dd1dcc121040a08cdf3416c5349c"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a289d23d4c46f1a82d5db4abeb40b9b5be91731ee19a379d15790e53031c014"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e216d8044a356fc0337c7a2a0536d6de07888d7bcda76febcb8adc50bdbbd00"}, + {file = "coverage-7.6.9-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c026eb44f744acaa2bda7493dad903aa5bf5fc4f2554293a798d5606710055d"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e77363e8425325384f9d49272c54045bbed2f478e9dd698dbc65dbc37860eb0a"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:777abfab476cf83b5177b84d7486497e034eb9eaea0d746ce0c1268c71652077"}, + {file = "coverage-7.6.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:447af20e25fdbe16f26e84eb714ba21d98868705cb138252d28bc400381f6ffb"}, + {file = "coverage-7.6.9-cp313-cp313-win32.whl", hash = "sha256:d872ec5aeb086cbea771c573600d47944eea2dcba8be5f3ee649bfe3cb8dc9ba"}, + {file = "coverage-7.6.9-cp313-cp313-win_amd64.whl", hash = "sha256:fd1213c86e48dfdc5a0cc676551db467495a95a662d2396ecd58e719191446e1"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9e7484d286cd5a43744e5f47b0b3fb457865baf07bafc6bee91896364e1419"}, + {file = "coverage-7.6.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1cf0872ee455c03e5674b5bca5e3e68e159379c1af0903e89f5eba9ccc3a"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d10e07aa2b91835d6abec555ec8b2733347956991901eea6ffac295f83a30e4"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13a9e2d3ee855db3dd6ea1ba5203316a1b1fd8eaeffc37c5b54987e61e4194ae"}, + {file = "coverage-7.6.9-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c38bf15a40ccf5619fa2fe8f26106c7e8e080d7760aeccb3722664c8656b030"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d5275455b3e4627c8e7154feaf7ee0743c2e7af82f6e3b561967b1cca755a0be"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8f8770dfc6e2c6a2d4569f411015c8d751c980d17a14b0530da2d7f27ffdd88e"}, + {file = "coverage-7.6.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8d2dfa71665a29b153a9681edb1c8d9c1ea50dfc2375fb4dac99ea7e21a0bcd9"}, + {file = "coverage-7.6.9-cp313-cp313t-win32.whl", hash = "sha256:5e6b86b5847a016d0fbd31ffe1001b63355ed309651851295315031ea7eb5a9b"}, + {file = "coverage-7.6.9-cp313-cp313t-win_amd64.whl", hash = "sha256:97ddc94d46088304772d21b060041c97fc16bdda13c6c7f9d8fcd8d5ae0d8611"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:adb697c0bd35100dc690de83154627fbab1f4f3c0386df266dded865fc50a902"}, + {file = "coverage-7.6.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:be57b6d56e49c2739cdf776839a92330e933dd5e5d929966fbbd380c77f060be"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1592791f8204ae9166de22ba7e6705fa4ebd02936c09436a1bb85aabca3e599"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e12ae8cc979cf83d258acb5e1f1cf2f3f83524d1564a49d20b8bec14b637f08"}, + {file = "coverage-7.6.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb5555cff66c4d3d6213a296b360f9e1a8e323e74e0426b6c10ed7f4d021e464"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:b9389a429e0e5142e69d5bf4a435dd688c14478a19bb901735cdf75e57b13845"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:592ac539812e9b46046620341498caf09ca21023c41c893e1eb9dbda00a70cbf"}, + {file = "coverage-7.6.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a27801adef24cc30871da98a105f77995e13a25a505a0161911f6aafbd66e678"}, + {file = "coverage-7.6.9-cp39-cp39-win32.whl", hash = "sha256:8e3c3e38930cfb729cb8137d7f055e5a473ddaf1217966aa6238c88bd9fd50e6"}, + {file = "coverage-7.6.9-cp39-cp39-win_amd64.whl", hash = "sha256:e28bf44afa2b187cc9f41749138a64435bf340adfcacb5b2290c070ce99839d4"}, + {file = "coverage-7.6.9-pp39.pp310-none-any.whl", hash = "sha256:f3ca78518bc6bc92828cd11867b121891d75cae4ea9e908d72030609b996db1b"}, + {file = "coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d"}, ] [package.dependencies] @@ -456,51 +456,53 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.1" +version = "44.0.0" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, - {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, - {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, - {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, - {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, - {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, - {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, - {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, - {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, - {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, - {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, - {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, - {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, ] [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] -nox = ["nox"] -pep8test = ["check-sdist", "click", "mypy", "ruff"] -sdist = ["build"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] test-randomorder = ["pytest-randomly"] [[package]] @@ -538,20 +540,20 @@ files = [ [[package]] name = "deprecated" -version = "1.2.14" +version = "1.2.15" description = "Python @deprecated decorator to deprecate old python classes, functions or methods." optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, + {file = "Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320"}, + {file = "deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d"}, ] [package.dependencies] wrapt = ">=1.10,<2" [package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] +dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "jinja2 (>=3.0.3,<3.1.0)", "setuptools", "sphinx (<2)", "tox"] [[package]] name = "distlib" @@ -765,13 +767,13 @@ socks = ["socksio (==1.*)"] [[package]] name = "identify" -version = "2.6.1" +version = "2.6.3" description = "File identification library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0"}, - {file = "identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98"}, + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, ] [package.extras] @@ -939,13 +941,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "keyring" -version = "25.4.1" +version = "25.5.0" description = "Store and access your passwords safely." optional = false python-versions = ">=3.8" files = [ - {file = "keyring-25.4.1-py3-none-any.whl", hash = "sha256:5426f817cf7f6f007ba5ec722b1bcad95a75b27d780343772ad76b17cb47b0bf"}, - {file = "keyring-25.4.1.tar.gz", hash = "sha256:b07ebc55f3e8ed86ac81dd31ef14e81ace9dd9c3d4b5d77a6e9a2016d0d71a1b"}, + {file = "keyring-25.5.0-py3-none-any.whl", hash = "sha256:e67f8ac32b04be4714b42fe84ce7dad9c40985b9ca827c592cc303e7c26d9741"}, + {file = "keyring-25.5.0.tar.gz", hash = "sha256:4c753b3ec91717fe713c4edd522d625889d8973a349b0e582622f49766de58e6"}, ] [package.dependencies] @@ -968,13 +970,13 @@ type = ["pygobject-stubs", "pytest-mypy", "shtab", "types-pywin32"] [[package]] name = "license-expression" -version = "30.3.1" +version = "30.4.0" description = "license-expression is a comprehensive utility library to parse, compare, simplify and normalize license expressions (such as SPDX license expressions) using boolean logic." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "license_expression-30.3.1-py3-none-any.whl", hash = "sha256:97904b9185c7bbb1e98799606fa7424191c375e70ba63a524b6f7100e42ddc46"}, - {file = "license_expression-30.3.1.tar.gz", hash = "sha256:60d5bec1f3364c256a92b9a08583d7ea933c7aa272c8d36d04144a89a3858c01"}, + {file = "license_expression-30.4.0-py3-none-any.whl", hash = "sha256:7c8f240c6e20d759cb8455e49cb44a923d9e25c436bf48d7e5b8eea660782c04"}, + {file = "license_expression-30.4.0.tar.gz", hash = "sha256:6464397f8ed4353cc778999caec43b099f8d8d5b335f282e26a9eb9435522f05"}, ] [package.dependencies] @@ -1030,72 +1032,72 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] [[package]] name = "markupsafe" -version = "3.0.1" +version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" files = [ - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:db842712984e91707437461930e6011e60b39136c7331e971952bb30465bc1a1"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3ffb4a8e7d46ed96ae48805746755fadd0909fea2306f93d5d8233ba23dda12a"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67c519635a4f64e495c50e3107d9b4075aec33634272b5db1cde839e07367589"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48488d999ed50ba8d38c581d67e496f955821dc183883550a6fbc7f1aefdc170"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f31ae06f1328595d762c9a2bf29dafd8621c7d3adc130cbb46278079758779ca"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80fcbf3add8790caddfab6764bde258b5d09aefbe9169c183f88a7410f0f6dea"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3341c043c37d78cc5ae6e3e305e988532b072329639007fd408a476642a89fd6"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cb53e2a99df28eee3b5f4fea166020d3ef9116fdc5764bc5117486e6d1211b25"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win32.whl", hash = "sha256:db15ce28e1e127a0013dfb8ac243a8e392db8c61eae113337536edb28bdc1f97"}, - {file = "MarkupSafe-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:4ffaaac913c3f7345579db4f33b0020db693f302ca5137f106060316761beea9"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:26627785a54a947f6d7336ce5963569b5d75614619e75193bdb4e06e21d447ad"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b954093679d5750495725ea6f88409946d69cfb25ea7b4c846eef5044194f583"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:973a371a55ce9ed333a3a0f8e0bcfae9e0d637711534bcb11e130af2ab9334e7"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:244dbe463d5fb6d7ce161301a03a6fe744dac9072328ba9fc82289238582697b"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d98e66a24497637dd31ccab090b34392dddb1f2f811c4b4cd80c230205c074a3"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad91738f14eb8da0ff82f2acd0098b6257621410dcbd4df20aaa5b4233d75a50"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7044312a928a66a4c2a22644147bc61a199c1709712069a344a3fb5cfcf16915"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a4792d3b3a6dfafefdf8e937f14906a51bd27025a36f4b188728a73382231d91"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win32.whl", hash = "sha256:fa7d686ed9883f3d664d39d5a8e74d3c5f63e603c2e3ff0abcba23eac6542635"}, - {file = "MarkupSafe-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ba25a71ebf05b9bb0e2ae99f8bc08a07ee8e98c612175087112656ca0f5c8bf"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8ae369e84466aa70f3154ee23c1451fda10a8ee1b63923ce76667e3077f2b0c4"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40f1e10d51c92859765522cbd79c5c8989f40f0419614bcdc5015e7b6bf97fc5"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a4cb365cb49b750bdb60b846b0c0bc49ed62e59a76635095a179d440540c346"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee3941769bd2522fe39222206f6dd97ae83c442a94c90f2b7a25d847d40f4729"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62fada2c942702ef8952754abfc1a9f7658a4d5460fabe95ac7ec2cbe0d02abc"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c2d64fdba74ad16138300815cfdc6ab2f4647e23ced81f59e940d7d4a1469d9"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb532dd9900381d2e8f48172ddc5a59db4c445a11b9fab40b3b786da40d3b56b"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0f84af7e813784feb4d5e4ff7db633aba6c8ca64a833f61d8e4eade234ef0c38"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win32.whl", hash = "sha256:cbf445eb5628981a80f54087f9acdbf84f9b7d862756110d172993b9a5ae81aa"}, - {file = "MarkupSafe-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:a10860e00ded1dd0a65b83e717af28845bb7bd16d8ace40fe5531491de76b79f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e81c52638315ff4ac1b533d427f50bc0afc746deb949210bc85f05d4f15fd772"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:312387403cd40699ab91d50735ea7a507b788091c416dd007eac54434aee51da"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ae99f31f47d849758a687102afdd05bd3d3ff7dbab0a8f1587981b58a76152a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c97ff7fedf56d86bae92fa0a646ce1a0ec7509a7578e1ed238731ba13aabcd1c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7420ceda262dbb4b8d839a4ec63d61c261e4e77677ed7c66c99f4e7cb5030dd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d42d132cff577c92bfba536aefcfea7e26efb975bd455db4e6602f5c9f45e7"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4c8817557d0de9349109acb38b9dd570b03cc5014e8aabf1cbddc6e81005becd"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a54c43d3ec4cf2a39f4387ad044221c66a376e58c0d0e971d47c475ba79c6b5"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win32.whl", hash = "sha256:c91b394f7601438ff79a4b93d16be92f216adb57d813a78be4446fe0f6bc2d8c"}, - {file = "MarkupSafe-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:fe32482b37b4b00c7a52a07211b479653b7fe4f22b2e481b9a9b099d8a430f2f"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:17b2aea42a7280db02ac644db1d634ad47dcc96faf38ab304fe26ba2680d359a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:852dc840f6d7c985603e60b5deaae1d89c56cb038b577f6b5b8c808c97580f1d"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0778de17cff1acaeccc3ff30cd99a3fd5c50fc58ad3d6c0e0c4c58092b859396"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:800100d45176652ded796134277ecb13640c1a537cad3b8b53da45aa96330453"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d06b24c686a34c86c8c1fba923181eae6b10565e4d80bdd7bc1c8e2f11247aa4"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:33d1c36b90e570ba7785dacd1faaf091203d9942bc036118fab8110a401eb1a8"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:beeebf760a9c1f4c07ef6a53465e8cfa776ea6a2021eda0d0417ec41043fe984"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bbde71a705f8e9e4c3e9e33db69341d040c827c7afa6789b14c6e16776074f5a"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win32.whl", hash = "sha256:82b5dba6eb1bcc29cc305a18a3c5365d2af06ee71b123216416f7e20d2a84e5b"}, - {file = "MarkupSafe-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:730d86af59e0e43ce277bb83970530dd223bf7f2a838e086b50affa6ec5f9295"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4935dd7883f1d50e2ffecca0aa33dc1946a94c8f3fdafb8df5c330e48f71b132"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e9393357f19954248b00bed7c56f29a25c930593a77630c719653d51e7669c2a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40621d60d0e58aa573b68ac5e2d6b20d44392878e0bfc159012a5787c4e35bc8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f94190df587738280d544971500b9cafc9b950d32efcb1fba9ac10d84e6aa4e6"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6a387d61fe41cdf7ea95b38e9af11cfb1a63499af2759444b99185c4ab33f5b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8ad4ad1429cd4f315f32ef263c1342166695fad76c100c5d979c45d5570ed58b"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e24bfe89c6ac4c31792793ad9f861b8f6dc4546ac6dc8f1c9083c7c4f2b335cd"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2a4b34a8d14649315c4bc26bbfa352663eb51d146e35eef231dd739d54a5430a"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win32.whl", hash = "sha256:242d6860f1fd9191aef5fae22b51c5c19767f93fb9ead4d21924e0bcb17619d8"}, - {file = "MarkupSafe-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:93e8248d650e7e9d49e8251f883eed60ecbc0e8ffd6349e18550925e31bd029b"}, - {file = "markupsafe-3.0.1.tar.gz", hash = "sha256:3e683ee4f5d0fa2dde4db77ed8dd8a876686e3fc417655c2ece9a90576905344"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, ] [[package]] @@ -1304,27 +1306,35 @@ testing-docutils = ["pygments", "pytest (>=7,<8)", "pytest-param-files (>=0.3.4, [[package]] name = "nh3" -version = "0.2.18" +version = "0.2.19" description = "Python bindings to the ammonia HTML sanitization library." optional = false python-versions = "*" files = [ - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:14c5a72e9fe82aea5fe3072116ad4661af5cf8e8ff8fc5ad3450f123e4925e86"}, - {file = "nh3-0.2.18-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7b7c2a3c9eb1a827d42539aa64091640bd275b81e097cd1d8d82ef91ffa2e811"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c64511469005058cd17cc1537578eac40ae9f7200bedcfd1fc1a05f4f8c200"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0411beb0589eacb6734f28d5497ca2ed379eafab8ad8c84b31bb5c34072b7164"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5f36b271dae35c465ef5e9090e1fdaba4a60a56f0bb0ba03e0932a66f28b9189"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34c03fa78e328c691f982b7c03d4423bdfd7da69cd707fe572f544cf74ac23ad"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19aaba96e0f795bd0a6c56291495ff59364f4300d4a39b29a0abc9cb3774a84b"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de3ceed6e661954871d6cd78b410213bdcb136f79aafe22aa7182e028b8c7307"}, - {file = "nh3-0.2.18-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6955369e4d9f48f41e3f238a9e60f9410645db7e07435e62c6a9ea6135a4907f"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f0eca9ca8628dbb4e916ae2491d72957fdd35f7a5d326b7032a345f111ac07fe"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:3a157ab149e591bb638a55c8c6bcb8cdb559c8b12c13a8affaba6cedfe51713a"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:c8b3a1cebcba9b3669ed1a84cc65bf005728d2f0bc1ed2a6594a992e817f3a50"}, - {file = "nh3-0.2.18-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36c95d4b70530b320b365659bb5034341316e6a9b30f0b25fa9c9eff4c27a204"}, - {file = "nh3-0.2.18-cp37-abi3-win32.whl", hash = "sha256:a7f1b5b2c15866f2db413a3649a8fe4fd7b428ae58be2c0f6bca5eefd53ca2be"}, - {file = "nh3-0.2.18-cp37-abi3-win_amd64.whl", hash = "sha256:8ce0f819d2f1933953fca255db2471ad58184a60508f03e6285e5114b6254844"}, - {file = "nh3-0.2.18.tar.gz", hash = "sha256:94a166927e53972a9698af9542ace4e38b9de50c34352b962f4d9a7d4c927af4"}, + {file = "nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6"}, + {file = "nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0"}, + {file = "nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9"}, + {file = "nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55"}, + {file = "nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc"}, + {file = "nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3"}, + {file = "nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc"}, + {file = "nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9"}, + {file = "nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0"}, + {file = "nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48"}, + {file = "nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121"}, + {file = "nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804"}, ] [[package]] @@ -1340,13 +1350,13 @@ files = [ [[package]] name = "packageurl-python" -version = "0.15.6" +version = "0.16.0" description = "A purl aka. Package URL parser and builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packageurl_python-0.15.6-py3-none-any.whl", hash = "sha256:a40210652c89022772a6c8340d6066f7d5dc67132141e5284a4db7a27d0a8ab0"}, - {file = "packageurl_python-0.15.6.tar.gz", hash = "sha256:cbc89afd15d5f4d05db4f1b61297e5b97a43f61f28799f6d282aff467ed2ee96"}, + {file = "packageurl_python-0.16.0-py3-none-any.whl", hash = "sha256:5c3872638b177b0f1cf01c3673017b7b27ebee485693ae12a8bed70fa7fa7c35"}, + {file = "packageurl_python-0.16.0.tar.gz", hash = "sha256:69e3bf8a3932fe9c2400f56aaeb9f86911ecee2f9398dbe1b58ec34340be365d"}, ] [package.extras] @@ -1357,13 +1367,13 @@ test = ["pytest"] [[package]] name = "packaging" -version = "24.1" +version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, ] [[package]] @@ -1379,13 +1389,13 @@ files = [ [[package]] name = "pip" -version = "24.2" +version = "24.3.1" description = "The PyPA recommended tool for installing Python packages." optional = false python-versions = ">=3.8" files = [ - {file = "pip-24.2-py3-none-any.whl", hash = "sha256:2cd581cf58ab7fcfca4ce8efa6dcacd0de5bf8d0a3eb9ec927e07405f4d9e2a2"}, - {file = "pip-24.2.tar.gz", hash = "sha256:5b5e490b5e9cb275c879595064adce9ebd31b854e3e803740b72f9ccf34a45b8"}, + {file = "pip-24.3.1-py3-none-any.whl", hash = "sha256:3790624780082365f47549d032f3770eeb2b1e8bd1f7b2e02dace1afa361b4ed"}, + {file = "pip-24.3.1.tar.gz", hash = "sha256:ebcb60557f2aefabc2e0f918751cd24ea0d56d8ec5445fe1807f1d2109660b99"}, ] [[package]] @@ -1451,13 +1461,13 @@ testing = ["aboutcode-toolkit (>=6.0.0)", "black", "pytest (>=6,!=7.0.0)", "pyte [[package]] name = "pkginfo" -version = "1.11.2" +version = "1.12.0" description = "Query metadata from sdists / bdists / installed packages." optional = false python-versions = ">=3.8" files = [ - {file = "pkginfo-1.11.2-py3-none-any.whl", hash = "sha256:9ec518eefccd159de7ed45386a6bb4c6ca5fa2cb3bd9b71154fae44f6f1b36a3"}, - {file = "pkginfo-1.11.2.tar.gz", hash = "sha256:c6bc916b8298d159e31f2c216e35ee5b86da7da18874f879798d0a1983537c86"}, + {file = "pkginfo-1.12.0-py3-none-any.whl", hash = "sha256:dcd589c9be4da8973eceffa247733c144812759aa67eaf4bbf97016a02f39088"}, + {file = "pkginfo-1.12.0.tar.gz", hash = "sha256:8ad91a0445a036782b9366ef8b8c2c50291f83a553478ba8580c73d3215700cf"}, ] [package.extras] @@ -1989,13 +1999,13 @@ idna2008 = ["idna"] [[package]] name = "rich" -version = "13.9.2" +version = "13.9.4" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false python-versions = ">=3.8.0" files = [ - {file = "rich-13.9.2-py3-none-any.whl", hash = "sha256:8c82a3d3f8dcfe9e734771313e606b39d8247bb6b826e196f4914b333b743cf1"}, - {file = "rich-13.9.2.tar.gz", hash = "sha256:51a2c62057461aaf7152b4d611168f93a9fc73068f8ded2790f29fe2b5366d0c"}, + {file = "rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90"}, + {file = "rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098"}, ] [package.dependencies] @@ -2008,29 +2018,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.7.1" +version = "0.8.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"}, - {file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"}, - {file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"}, - {file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"}, - {file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"}, - {file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"}, - {file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"}, - {file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"}, - {file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"}, - {file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"}, - {file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"}, - {file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"}, + {file = "ruff-0.8.2-py3-none-linux_armv6l.whl", hash = "sha256:c49ab4da37e7c457105aadfd2725e24305ff9bc908487a9bf8d548c6dad8bb3d"}, + {file = "ruff-0.8.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec016beb69ac16be416c435828be702ee694c0d722505f9c1f35e1b9c0cc1bf5"}, + {file = "ruff-0.8.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f05cdf8d050b30e2ba55c9b09330b51f9f97d36d4673213679b965d25a785f3c"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60f578c11feb1d3d257b2fb043ddb47501ab4816e7e221fbb0077f0d5d4e7b6f"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbd5cf9b0ae8f30eebc7b360171bd50f59ab29d39f06a670b3e4501a36ba5897"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b402ddee3d777683de60ff76da801fa7e5e8a71038f57ee53e903afbcefdaa58"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:705832cd7d85605cb7858d8a13d75993c8f3ef1397b0831289109e953d833d29"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32096b41aaf7a5cc095fa45b4167b890e4c8d3fd217603f3634c92a541de7248"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e769083da9439508833cfc7c23e351e1809e67f47c50248250ce1ac52c21fb93"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fe716592ae8a376c2673fdfc1f5c0c193a6d0411f90a496863c99cd9e2ae25d"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:81c148825277e737493242b44c5388a300584d73d5774defa9245aaef55448b0"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d261d7850c8367704874847d95febc698a950bf061c9475d4a8b7689adc4f7fa"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1ca4e3a87496dc07d2427b7dd7ffa88a1e597c28dad65ae6433ecb9f2e4f022f"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:729850feed82ef2440aa27946ab39c18cb4a8889c1128a6d589ffa028ddcfc22"}, + {file = "ruff-0.8.2-py3-none-win32.whl", hash = "sha256:ac42caaa0411d6a7d9594363294416e0e48fc1279e1b0e948391695db2b3d5b1"}, + {file = "ruff-0.8.2-py3-none-win_amd64.whl", hash = "sha256:2aae99ec70abf43372612a838d97bfe77d45146254568d94926e8ed5bbb409ea"}, + {file = "ruff-0.8.2-py3-none-win_arm64.whl", hash = "sha256:fb88e2a506b70cfbc2de6fae6681c4f944f7dd5f2fe87233a7233d888bad73e8"}, + {file = "ruff-0.8.2.tar.gz", hash = "sha256:b84f4f414dda8ac7f75075c1fa0b905ac0ff25361f42e6d5da681a465e0f78e5"}, ] [[package]] @@ -2076,33 +2086,33 @@ files = [ [[package]] name = "setuptools" -version = "75.2.0" +version = "75.6.0" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"}, - {file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"}, + {file = "setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d"}, + {file = "setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"] -core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] enabler = ["pytest-enabler (>=2.2)"] -test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] -type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -2396,13 +2406,43 @@ files = [ [[package]] name = "tomli" -version = "2.0.2" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" files = [ - {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, - {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] @@ -2418,20 +2458,21 @@ files = [ [[package]] name = "tqdm" -version = "4.66.5" +version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" files = [ - {file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"}, - {file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"}, + {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, + {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, ] [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} [package.extras] -dev = ["pytest (>=6)", "pytest-cov", "pytest-timeout", "pytest-xdist"] +dev = ["nbval", "pytest (>=6)", "pytest-asyncio (>=0.24)", "pytest-cov", "pytest-timeout"] +discord = ["requests"] notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] @@ -2461,13 +2502,13 @@ urllib3 = ">=1.26.0" [[package]] name = "types-deprecated" -version = "1.2.9.20240311" +version = "1.2.15.20241117" description = "Typing stubs for Deprecated" optional = false python-versions = ">=3.8" files = [ - {file = "types-Deprecated-1.2.9.20240311.tar.gz", hash = "sha256:0680e89989a8142707de8103f15d182445a533c1047fd9b7e8c5459101e9b90a"}, - {file = "types_Deprecated-1.2.9.20240311-py3-none-any.whl", hash = "sha256:d7793aaf32ff8f7e49a8ac781de4872248e0694c4b75a7a8a186c51167463f9d"}, + {file = "types-Deprecated-1.2.15.20241117.tar.gz", hash = "sha256:924002c8b7fddec51ba4949788a702411a2e3636cd9b2a33abd8ee119701d77e"}, + {file = "types_Deprecated-1.2.15.20241117-py3-none-any.whl", hash = "sha256:a0cc5e39f769fc54089fd8e005416b55d74aa03f6964d2ed1a0b0b2e28751884"}, ] [[package]] @@ -2514,13 +2555,13 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "virtualenv" -version = "20.26.6" +version = "20.28.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2"}, - {file = "virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48"}, + {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, + {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, ] [package.dependencies] @@ -2545,13 +2586,13 @@ files = [ [[package]] name = "wheel" -version = "0.44.0" +version = "0.45.1" description = "A built-package format for Python" optional = false python-versions = ">=3.8" files = [ - {file = "wheel-0.44.0-py3-none-any.whl", hash = "sha256:2376a90c98cc337d18623527a97c31797bd02bad0033d41547043a1cbfbe448f"}, - {file = "wheel-0.44.0.tar.gz", hash = "sha256:a29c3f2817e95ab89aa4660681ad547c0e9547f20e75b0562fe7723c9a2a9d49"}, + {file = "wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248"}, + {file = "wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729"}, ] [package.extras] @@ -2559,92 +2600,87 @@ test = ["pytest (>=6.0.0)", "setuptools (>=65)"] [[package]] name = "wrapt" -version = "1.16.0" +version = "1.17.0" description = "Module for decorators, wrappers and monkey patching." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, + {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, + {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, + {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, + {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, + {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, + {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, + {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, + {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, + {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, + {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, + {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, + {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, + {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, + {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, + {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, + {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, + {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, + {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, + {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, + {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, + {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, + {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, + {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, + {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, + {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, + {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, + {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, + {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, + {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, + {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, + {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, + {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, + {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, + {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, + {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, + {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, + {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, ] [[package]] name = "zipp" -version = "3.20.2" +version = "3.21.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, ] [package.extras] @@ -2658,4 +2694,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "d0cadf4d47cd2c81976e3752e840c0be5e9702cd6c8a47028df6838073654572" +content-hash = "726e13c507aac04c65d86a3ad85222f7c218eaed40689343445e9ff8574e8ec2" diff --git a/pyproject.toml b/pyproject.toml index 1477aaa..723a20f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ deprecated = "^1.2.14" [tool.poetry.group.dev.dependencies] pytest = "^7.2.0" -ruff = ">=0.1.6,<0.7.2" +ruff = ">=0.1.6,<=0.8.2" pip-audit = "^2.5.6" pytest-mock = "^3.11.1" mypy = "^1.5.1" @@ -141,7 +141,7 @@ pythonpath = ["src", "tests"] [tool.mypy] files = ["src", "tests"] -exclude = ["dist", "tests/artifacts"] +exclude = ["dist", "tests/artifacts", "src/algokit_utils/_legacy_v2"] python_version = "3.10" warn_unused_ignores = true warn_redundant_casts = true diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index c3556b0..f2bf696 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -59,18 +59,12 @@ from algokit_utils.transactions.transaction_composer import TransactionComposer # TEAL opcodes for constant blocks -BYTE_CBLOCK = 0x20 # bytecblock opcode -INT_CBLOCK = 0x21 # intcblock opcode +BYTE_CBLOCK = 38 # bytecblock opcode +INT_CBLOCK = 32 # intcblock opcode T = TypeVar("T") # For generic return type in _handle_call_errors -def camel_to_snake_case(name: str) -> str: - import re - - return re.sub(r"(? int: # noqa: C901 """Calculate the offset after constant blocks in TEAL program. @@ -885,8 +879,7 @@ def normalise_app_spec(app_spec: Arc56Contract | ApplicationSpecification | str) return arc32_to_arc56(spec) elif isinstance(spec, dict): # normalize field names to lowercase to python camel - transformed_spec = {camel_to_snake_case(k): v for k, v in spec.items()} - return Arc56Contract(**transformed_spec) + return Arc56Contract.from_json(spec) else: raise ValueError("Invalid app spec format") @@ -987,7 +980,7 @@ def is_base64(s: str) -> bool: ) @staticmethod - def expose_logic_error_static( + def expose_logic_error_static( # noqa: C901 e: Exception, app_spec: Arc56Contract, details: ExposedLogicErrorDetails ) -> Exception: """Takes an error that may include a logic error and re-exposes it with source info.""" @@ -1031,6 +1024,17 @@ def expose_logic_error_static( if app_spec.source else None ) + custom_get_line_for_pc = None + + def get_line_for_pc(input_pc: int) -> int | None: + if not program_source_info: + return None + teal = [line.teal for line in program_source_info.source_info if input_pc - cblocks_offset in line.pc] + return teal[0] if teal else None + + if not source_map: + custom_get_line_for_pc = get_line_for_pc + if program_source: e = LogicError( logic_error_str=str(e), @@ -1040,6 +1044,7 @@ def expose_logic_error_static( message=error_details["message"], pc=error_details["pc"], logic_error=e, + get_line_for_pc=custom_get_line_for_pc, traces=None, ) @@ -1145,7 +1150,7 @@ def get_box_names(self) -> list[BoxName]: def get_box_value(self, name: BoxIdentifier) -> bytes: return self._algorand.app.get_box_value(self._app_id, name) - def get_box_value_from_abi_type(self, name: BoxIdentifier, abi_type: ABIType) -> Any: + def get_box_value_from_abi_type(self, name: BoxIdentifier, abi_type: ABIType) -> ABIValue: return self._algorand.app.get_box_value_from_abi_type(self._app_id, name, abi_type) def get_box_values(self, filter_func: Callable[[BoxName], bool] | None = None) -> list[BoxValue]: @@ -1290,7 +1295,7 @@ def _get_abi_args_with_default_values( # noqa: C901, PLR0912 method = get_arc56_method(method_name_or_signature, self._app_spec) result = [] - for i, method_arg in enumerate(method.args): + for i, method_arg in enumerate(method.arc56_args): # Get provided arg value if any arg_value = args[i] if args and i < len(args) else None @@ -1316,8 +1321,13 @@ def _get_abi_args_with_default_values( # noqa: C901, PLR0912 # Get method return value default_method = get_arc56_method(default_value.data, self._app_spec) empty_args = [None] * len(default_method.args) - call_result = self.send.app_call_method_call( - {"method": default_value.data, "args": empty_args, "sender": sender} + call_result = self._algorand.send.app_call_method_call( + AppCallMethodCall( + app_id=self._app_id, + method=algosdk.abi.Method.from_signature(default_value.data), + args=empty_args, + sender=sender, + ) ) if not call_result.return_value: @@ -1328,12 +1338,12 @@ def _get_abi_args_with_default_values( # noqa: C901, PLR0912 result.append( get_abi_tuple_from_abi_struct( call_result.return_value, - self._app_spec.structs[default_method.returns.struct], + self._app_spec.structs[str(default_method.arc56_returns.type)], self._app_spec.structs, ) ) else: - result.append(call_result.return_value) + result.append(call_result.return_value.return_value) case "local" | "global": # Get state value @@ -1374,7 +1384,9 @@ def _get_abi_args_with_default_values( # noqa: C901, PLR0912 def _get_abi_params(self, params: dict[str, Any], on_complete: algosdk.transaction.OnComplete) -> dict[str, Any]: sender = self._get_sender(params.get("sender")) method = get_arc56_method(params["method"], self._app_spec) - args = self._get_abi_args_with_default_values(params["method"], params.get("args"), sender) + args = self._get_abi_args_with_default_values( + method_name_or_signature=params["method"], args=params.get("args"), sender=sender + ) return { **params, "appId": self._app_id, diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 8cf1ada..9ebe9ee 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -10,6 +10,7 @@ from algosdk.atomic_transaction_composer import ABIResult, AccountTransactionSigner from algosdk.box_reference import BoxReference as AlgosdkBoxReference from algosdk.logic import get_application_address +from algosdk.source_map import SourceMap from algosdk.v2client import algod from algokit_utils.models.abi import ABIType, ABIValue @@ -169,7 +170,7 @@ def compile_teal(self, teal_code: str) -> CompiledTeal: compiled=compiled["result"], compiled_hash=compiled["hash"], compiled_base64_to_bytes=base64.b64decode(compiled["result"]), - source_map=compiled.get("sourcemap"), + source_map=SourceMap(compiled.get("sourcemap", {})), ) self._compilation_results[teal_code] = result return result diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 37c08a7..9926ff5 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -14,10 +14,11 @@ ) from algokit_utils.models.abi import ABIStruct, ABIType, ABIValue from algokit_utils.models.application import ( + ABIArgumentType, + ABITypeAlias, Arc56Contract, Arc56ContractState, Arc56Method, - ARCType, CallConfig, DefaultValue, Method, @@ -51,12 +52,13 @@ def get_arc56_method(method_name_or_signature: str, app_spec: Arc56Contract) -> # Find by signature method = None for m in app_spec.methods: - abi_method = AlgorandABIMethod.undictify(m.dictify()) + abi_method = AlgorandABIMethod.undictify(m.to_dict()) if abi_method.get_signature() == method_name_or_signature: method = m break - if method is None: - raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") + + if method is None: + raise ValueError(f"Unable to find method {method_name_or_signature} in {app_spec.name} app.") return Arc56Method(method) @@ -139,18 +141,22 @@ def get_abi_encoded_value(value: Any, type_str: str, structs: dict[str, list[Str return abi_type.encode(value) -def get_abi_decoded_value(value: bytes | int | str, type_str: str, structs: dict[str, list[StructField]]) -> ABIValue: - if type_str == "AVMBytes" or not isinstance(value, bytes): +def get_abi_decoded_value( + value: bytes | int | str, type_str: str | ABITypeAlias | ABIArgumentType, structs: dict[str, list[StructField]] +) -> ABIValue: + type_value = str(type_str) + + if type_value == "AVMBytes" or not isinstance(value, bytes): return value - if type_str == "AVMString": + if type_value == "AVMString": return value.decode("utf-8") - if type_str == "AVMUint64": + if type_value == "AVMUint64": return ABIType.from_string("uint64").decode(value) # type: ignore[no-any-return] - if type_str in structs: - tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_str], structs) + if type_value in structs: + tuple_type = get_abi_tuple_type_from_abi_struct_definition(structs[type_value], structs) decoded_tuple = tuple_type.decode(value) - return get_abi_struct_from_abi_tuple(decoded_tuple, structs[type_str], structs) - return ABIType.from_string(type_str).decode(value) # type: ignore[no-any-return] + return get_abi_struct_from_abi_tuple(decoded_tuple, structs[type_value], structs) + return ABIType.from_string(type_value).decode(value) # type: ignore[no-any-return] def get_abi_tuple_from_abi_struct( @@ -231,9 +237,9 @@ def convert_structs() -> dict[StructName, list[StructField]]: fields = [ StructField( name=name, - type=type, + type=type_, ) - for name, type in struct["elements"] + for name, type_ in struct["elements"] ] structs[struct["name"]] = fields return structs @@ -294,7 +300,7 @@ def convert_method(method: AlgorandABIMethod) -> Method: args.append(method_arg) method_returns = MethodReturns( - type=method.returns.type, + type=str(method.returns.type), struct=hint.structs.get("output", {}).get("name") if hint and hint.structs else None, # type: ignore[call-overload] desc=method.returns.desc, ) @@ -399,7 +405,7 @@ def get_action_value(key: str) -> str | None: } return Arc56Contract( - arcs=[ARCType.ARC56], + arcs=[], name=app_spec.contract.name, desc=app_spec.contract.desc, structs=structs, diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py index 2951457..a421a34 100644 --- a/src/algokit_utils/errors/logic_error.py +++ b/src/algokit_utils/errors/logic_error.py @@ -1,5 +1,7 @@ +import base64 import dataclasses import re +from collections.abc import Callable from copy import copy from typing import TYPE_CHECKING, TypedDict @@ -64,28 +66,40 @@ def __init__( pc: int, logic_error: Exception | None = None, traces: list[SimulationTrace] | None = None, + get_line_for_pc: Callable[[int], int | None] | None = None, blast_radius: int | None = None, ): self.logic_error = logic_error self.logic_error_str = logic_error_str - self.program = program + try: + self.program = base64.b64decode(program).decode("utf-8") + except Exception: + self.program = program self.source_map = source_map - self.lines = program.split("\n") + self.lines = self.program.split("\n") self.transaction_id = transaction_id self.message = message self.pc = pc self.traces = traces - self.line_no = self.source_map.get_line_for_pc(self.pc) if self.source_map else None + self.line_no = ( + self.source_map.get_line_for_pc(self.pc) + if self.source_map + else get_line_for_pc(self.pc) + if get_line_for_pc + else None + ) + self.stack = "" self._blast_radius = blast_radius or self._blast_radius if self.line_no and self.line_no > 0: - start = max(0, self.line_no - self._blast_radius) - stop = min(len(self.program), self.line_no + self._blast_radius) + line_no = self.line_no - 1 + start = max(0, line_no - self._blast_radius) + stop = min(len(self.program), line_no + self._blast_radius) stack_lines = self.program.splitlines()[start:stop] middle_index = len(stack_lines) // 2 - stack_lines[middle_index] += " <--- Error" + stack_lines[middle_index] = stack_lines[middle_index] + " <--- Error" self.stack = "\n".join(stack_lines) diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 87943a5..27b137b 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -1,9 +1,8 @@ +import json from dataclasses import asdict, dataclass, field, is_dataclass -from enum import IntEnum -from typing import Any, Literal +from typing import Any, Literal, TypeAlias import algosdk -from algosdk.abi import ABIType as AlgosdkABIType UPDATABLE_TEMPLATE_NAME = "TMPL_UPDATABLE" """The name of the TEAL template variable for deploy-time immutability control.""" @@ -15,8 +14,9 @@ # ===== ARCs ===== # Define type aliases -ABITypeAlias = str -StructName = str +ABITypeAlias: TypeAlias = str +ABIArgumentType: TypeAlias = algosdk.abi.ABIType | algosdk.abi.ABITransactionType | algosdk.abi.ABIReferenceType +StructName: TypeAlias = str AVMBytes = Literal["AVMBytes"] AVMString = Literal["AVMString"] AVMUint64 = Literal["AVMUint64"] @@ -25,6 +25,43 @@ DefaultValueSource = Literal["box", "global", "local", "literal", "method"] +def convert_key_to_snake_case(name: str) -> str: + import re + + return re.sub(r"(? Any: # noqa: ANN401 + if isinstance(obj, dict): + return {convert_key_to_snake_case(k): convert_keys_to_snake_case(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [convert_keys_to_snake_case(item) for item in obj] + return obj + + +class SerializableBaseClass: + """ + A base class that provides a generic `dictify` method to convert dataclass instances + into dictionaries recursively. + """ + + def to_dict(self) -> dict[str, Any]: + def serialize(obj: Any) -> dict[str, Any] | list[Any] | Any: # noqa: ANN401 + if is_dataclass(obj) and not isinstance(obj, type): + return {k: serialize(v) for k, v in asdict(obj).items()} + elif isinstance(obj, list): + return [serialize(item) for item in obj] + elif isinstance(obj, dict): + return {k: serialize(v) for k, v in obj.items()} + else: + return obj + + result = serialize(self) + if not isinstance(result, dict): + raise TypeError("Serialized object is not a dictionary.") + return result + + @dataclass class CallConfig: no_op: str | None = None @@ -35,11 +72,6 @@ class CallConfig: delete_application: str | None = None -class ARCType(IntEnum): - ARC56 = 56 - ARC32 = 32 - - @dataclass(kw_only=True) class StructField: name: str @@ -71,7 +103,7 @@ class DefaultValue: @dataclass(kw_only=True) class MethodArg: - type: AlgosdkABIType + type: ABITypeAlias struct: StructName | None = None name: str | None = None desc: str | None = None @@ -80,7 +112,7 @@ class MethodArg: @dataclass class MethodReturns: - type: AlgosdkABIType + type: ABITypeAlias struct: StructName | None = None desc: str | None = None @@ -109,7 +141,7 @@ class Recommendations: @dataclass(kw_only=True) -class Method: +class Method(SerializableBaseClass): name: str desc: str | None = None args: list[MethodArg] = field(default_factory=list) @@ -119,21 +151,6 @@ class Method: events: list["Event"] | None = None recommendations: Recommendations | None = None - def dictify(self) -> dict[str, Any]: - def serialize(obj: Any) -> Any: - if is_dataclass(obj): - return {k: serialize(v) for k, v in asdict(obj).items()} - elif isinstance(obj, list): - return [serialize(item) for item in obj] - elif isinstance(obj, dict): - return {k: serialize(v) for k, v in obj.items()} - elif isinstance(obj, AlgosdkABIType): - return str(obj) - else: - return obj - - return serialize(self) # type: ignore[no-any-return] - @dataclass(kw_only=True) class EventArg: @@ -177,6 +194,14 @@ class ProgramSourceInfo: source_info: list[SourceInfoDetail] pc_offset_method: Literal["none", "cblocks"] + @staticmethod + def from_json(source_info: str | dict) -> "ProgramSourceInfo": + if "source_info" not in source_info: + raise ValueError("source_info is required") + source_dict: dict = json.loads(source_info) if isinstance(source_info, str) else source_info + parsed_source_dict = [SourceInfoDetail(**detail) for detail in source_dict["source_info"]] + return ProgramSourceInfo(source_info=parsed_source_dict, pc_offset_method=source_dict["pc_offset_method"]) + @dataclass(kw_only=True) class Arc56ContractState: @@ -185,16 +210,93 @@ class Arc56ContractState: schemas: dict[str, dict[str, int]] -# Wraps algosdk.abi.Method -class Arc56Method(algosdk.abi.Method): - def __init__(self, method: Method): +@dataclass(kw_only=True) +class Arc56MethodArg: + """Represents an ARC-56 method argument with ABI type conversion.""" + + name: str | None = None + desc: str | None = None + struct: StructName | None = None + default_value: DefaultValue | None = None + type: ABIArgumentType + + @classmethod + def from_method_arg(cls, arg: MethodArg, converted_type: ABIArgumentType) -> "Arc56MethodArg": + """Create an Arc56MethodArg from a MethodArg with converted type.""" + return cls( + name=arg.name, + desc=arg.desc, + struct=arg.struct, + default_value=arg.default_value, + type=converted_type, + ) + + +@dataclass(kw_only=True) +class Arc56MethodReturnType: + """Represents an ARC-56 method return type with ABI type conversion.""" + + type: algosdk.abi.ABIType | Literal["void"] # Can be 'void' or ABIType + struct: StructName | None = None + desc: str | None = None + + +class Arc56Method(SerializableBaseClass, algosdk.abi.Method): + def __init__(self, method: Method) -> None: + # First, create the parent class with original arguments super().__init__(name=method.name, args=method.args, returns=method.returns, desc=method.desc) # type: ignore[arg-type] self.method = method + # Store our custom Arc56MethodArg list separately + self._arc56_args = [ + Arc56MethodArg.from_method_arg( + arg, + algosdk.abi.ABIType.from_string(arg.type), + ) + for arg in method.args + ] + + # Convert returns similar to TypeScript implementation, including struct support + converted_return_type: Literal["void"] | algosdk.abi.ABIType + if method.returns.type == "void": + converted_return_type = "void" + else: + converted_return_type = algosdk.abi.ABIType.from_string(str(method.returns.type)) + + self._arc56_returns = Arc56MethodReturnType( + type=converted_return_type, + struct=method.returns.struct, + desc=method.returns.desc, + ) + + def _is_transaction_or_reference_type(self, type_str: str) -> bool: + return type_str in [ + algosdk.constants.ASSETCONFIG_TXN, + algosdk.constants.PAYMENT_TXN, + algosdk.constants.KEYREG_TXN, + algosdk.constants.ASSETFREEZE_TXN, + algosdk.constants.ASSETTRANSFER_TXN, + algosdk.constants.APPCALL_TXN, + algosdk.constants.STATEPROOF_TXN, + algosdk.abi.ABIReferenceType.APPLICATION, + algosdk.abi.ABIReferenceType.ASSET, + algosdk.abi.ABIReferenceType.ACCOUNT, + ] + + @property + def arc56_args(self) -> list[Arc56MethodArg]: + """Get the ARC-56 specific argument representations.""" + return self._arc56_args + + @property + def arc56_returns(self) -> Arc56MethodReturnType: + """Get the ARC-56 specific returns type, including struct information.""" + return self._arc56_returns + @dataclass(kw_only=True) -class Arc56Contract: - arcs: list[ARCType] +class Arc56Contract(SerializableBaseClass): + arcs: list[int] name: str desc: str | None = None networks: dict[str, dict[str, int]] | None = None @@ -210,15 +312,112 @@ class Arc56Contract: template_variables: dict[str, dict[str, ABITypeAlias | AVMType | StructName | str]] | None = None scratch_variables: dict[str, dict[str, int | ABITypeAlias | AVMType | StructName]] | None = None - def __init__(self, **kwargs: Any) -> None: - if isinstance(kwargs.get("state"), dict): - kwargs["state"] = Arc56ContractState(**kwargs["state"]) - if isinstance(kwargs.get("methods"), list): - kwargs["methods"] = [Method(**method) for method in kwargs["methods"]] - if isinstance(kwargs.get("source_info"), dict): - kwargs["source_info"] = {k: ProgramSourceInfo(**v) for k, v in kwargs["source_info"].items()} - - super().__init__(**kwargs) + @staticmethod + def from_json(application_spec: str | dict) -> "Arc56Contract": + """Convert a JSON dictionary into an Arc56Contract instance. + + Args: + json_data (dict): The JSON data representing an Arc56Contract + + Returns: + Arc56Contract: The constructed Arc56Contract instance + """ + # Convert networks if present + json_data = json.loads(application_spec) if isinstance(application_spec, str) else application_spec + json_data = convert_keys_to_snake_case(json_data) + networks = json_data.get("networks") + + # Convert structs + structs = { + name: [StructField(**field) if isinstance(field, dict) else field for field in struct_fields] + for name, struct_fields in json_data.get("structs", {}).items() + } + + # Convert methods + methods = [] + for method_data in json_data.get("methods", []): + # Convert method args + args = [MethodArg(**arg) for arg in method_data.get("args", [])] + + # Convert method returns + returns_data = method_data.get("returns", {"type": "void"}) + returns = MethodReturns(**returns_data) + + # Convert method actions + actions_data = method_data.get("actions", {"create": [], "call": []}) + actions = MethodActions(**actions_data) + + # Convert events if present + events = None + if "events" in method_data: + events = [Event(**event) for event in method_data["events"]] + + # Convert recommendations if present + recommendations = None + if "recommendations" in method_data: + recommendations = Recommendations(**method_data["recommendations"]) + + methods.append( + Method( + name=method_data["name"], + desc=method_data.get("desc"), + args=args, + returns=returns, + actions=actions, + readonly=method_data.get("readonly", False), + events=events, + recommendations=recommendations, + ) + ) + + # Convert state + state_data = json_data["state"] + state = Arc56ContractState( + keys={ + category: {name: StorageKey(**key_data) for name, key_data in keys.items()} + for category, keys in state_data.get("keys", {}).items() + }, + maps={ + category: {name: StorageMap(**map_data) for name, map_data in maps.items()} + for category, maps in state_data.get("maps", {}).items() + }, + schemas=state_data.get("schema", {}), + ) + + # Convert compiler info if present + compiler_info = None + if "compiler_info" in json_data: + compiler_version = CompilerVersion(**json_data["compiler_info"]["compiler_version"]) + compiler_info = CompilerInfo( + compiler=json_data["compiler_info"]["compiler"], compiler_version=compiler_version + ) + + # Convert events if present + events = None + if "events" in json_data: + events = [Event(**event) for event in json_data["events"]] + + source_info = {} + if "source_info" in json_data: + source_info = {key: ProgramSourceInfo.from_json(val) for key, val in json_data["source_info"].items()} + + return Arc56Contract( + arcs=json_data.get("arcs", []), + name=json_data["name"], + desc=json_data.get("desc"), + networks=networks, + structs=structs, + methods=methods, + state=state, + bare_actions=json_data.get("bare_actions", {}), + source_info=source_info, + source=json_data.get("source"), + byte_code=json_data.get("byte_code"), + compiler_info=compiler_info, + events=events, + template_variables=json_data.get("template_variables"), + scratch_variables=json_data.get("scratch_variables"), + ) @dataclass(kw_only=True, frozen=True) diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 058d19e..6f6489b 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -182,7 +182,7 @@ def testing_app_puya_arc32_app_id( "global_ints": global_schema.num_uints, "local_bytes": local_schema.num_byte_slices, "local_ints": local_schema.num_uints, - }, # type: ignore[arg-type] + }, # type: ignore[] ) ) return response.app_id diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index fca3fbf..a108a1f 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -5,6 +5,7 @@ from algosdk.logic import get_application_address from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete +from algokit_utils import OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import ( AppClient, AppClientMethodCallParams, @@ -452,7 +453,53 @@ def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( }, ) - with pytest.raises(Exception) as exc_info: + with pytest.raises(LogicError) as exc_info: client.send.call(AppClientMethodCallWithSendParams(method="throwError")) - assert "this is an error" in str(exc_info.value) + assert "this is an error" in exc_info.value.stack + + +def test_arc56_undefined_error_message_with_dynamic_template_vars_cblock_offset( + arc56_factory: AppFactory, + algorand: AlgorandClient, + funded_account: Account, +) -> None: + # Deploy app with template parameters + client, result = arc56_factory.deploy( + create_params=AppClientMethodCallParams(method="createApplication"), + deploy_time_params={ + "bytes64TmplVar": "0" * 64, + "uint64TmplVar": 0, + "bytes32TmplVar": "0" * 32, + "bytesTmplVar": "foo", + }, + ) + app_id = result.app_id + + # Create new client without source map from compilation + app_client = AppClient( + AppClientParams( + app_id=app_id, + default_sender=funded_account.address, + default_signer=funded_account.signer, + algorand=algorand, + app_spec=client.app_spec, + ) + ) + + # Test error handling + with pytest.raises(LogicError) as exc_info: + app_client.send.call(AppClientMethodCallWithSendParams(method="tmpl")) + + expected_error = """log + +// tests/example-contracts/arc56_templates/templates.algo.ts:14 +// assert(this.uint64TmplVar) +intc 1 // TMPL_uint64TmplVar +assert <--- Error +retsub + +// specificLengthTemplateVar()void +*abi_route_specificLengthTemplateVar:""".splitlines() + + assert expected_error == [t.strip() for t in exc_info.value.stack.splitlines()] From 8f49afc9056527db5a644718c669b99c0a66d85e Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 10 Dec 2024 03:07:00 +0100 Subject: [PATCH 17/19] chore: mypy and ruff tweaks wip --- .github/workflows/check-python.yaml | 8 +- .pre-commit-config.yaml | 1 + legacy_v2_tests/test_account.py | 1 - legacy_v2_tests/test_app.py | 1 + legacy_v2_tests/test_app_client.py | 1 + ...new_client_missing_source_map.approved.txt | 6 +- .../test_app_client_clear_state.py | 4 +- legacy_v2_tests/test_app_client_close_out.py | 4 +- legacy_v2_tests/test_app_client_create.py | 6 +- legacy_v2_tests/test_app_client_delete.py | 4 +- legacy_v2_tests/test_app_client_deploy.py | 4 +- legacy_v2_tests/test_app_client_opt_in.py | 4 +- legacy_v2_tests/test_app_client_prepare.py | 3 +- legacy_v2_tests/test_app_client_resolve.py | 1 - .../test_app_client_signer_sender.py | 5 +- .../test_app_client_template_values.py | 2 +- legacy_v2_tests/test_app_client_update.py | 2 +- legacy_v2_tests/test_asset.py | 3 +- legacy_v2_tests/test_debug_utils.py | 16 +- legacy_v2_tests/test_deploy.py | 1 - legacy_v2_tests/test_deploy_scenarios.py | 8 +- legacy_v2_tests/test_dispenser_api_client.py | 3 +- legacy_v2_tests/test_transfer.py | 18 +- pyproject.toml | 2 - src/algokit_utils/__init__.py | 110 ++++----- src/algokit_utils/_legacy_v2/_transfer.py | 2 +- .../_legacy_v2/application_specification.py | 6 +- src/algokit_utils/_legacy_v2/deploy.py | 16 +- .../_legacy_v2/network_clients.py | 4 +- src/algokit_utils/applications/app_client.py | 6 +- .../applications/app_deployer.py | 6 +- src/algokit_utils/applications/app_factory.py | 16 +- src/algokit_utils/applications/app_manager.py | 2 +- src/algokit_utils/applications/utils.py | 16 +- src/algokit_utils/clients/algorand_client.py | 12 +- src/algokit_utils/errors/logic_error.py | 17 -- src/algokit_utils/models/account.py | 2 +- src/algokit_utils/models/application.py | 14 +- src/algokit_utils/protocols/application.py | 3 +- .../transactions/transaction_composer.py | 7 +- src/algokit_utils/transactions/utils.py | 4 +- tests/accounts/test_account_manager.py | 2 +- tests/applications/test_app_client.py | 38 +-- tests/applications/test_app_factory.py | 49 ++-- tests/clients/test_algorand_client.py | 223 ------------------ tests/transactions/test_transaction_sender.py | 13 +- 46 files changed, 214 insertions(+), 462 deletions(-) diff --git a/.github/workflows/check-python.yaml b/.github/workflows/check-python.yaml index e8464b7..0fc454d 100644 --- a/.github/workflows/check-python.yaml +++ b/.github/workflows/check-python.yaml @@ -37,10 +37,10 @@ jobs: # stop the build if there are files that don't meet formatting requirements poetry run ruff format --check . - - name: Check linting with Ruff - run: | - # stop the build if there are Python syntax errors or undefined names - poetry run ruff check . + # - name: Check linting with Ruff + # run: | + # # stop the build if there are Python syntax errors or undefined names + # poetry run ruff check . - name: Check types with mypy run: poetry run mypy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10c320a..74c3fe4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,3 +33,4 @@ repos: additional_dependencies: [] minimum_pre_commit_version: "2.9.2" files: "^(src|tests)/" + exclude: "^tests/artifacts/" diff --git a/legacy_v2_tests/test_account.py b/legacy_v2_tests/test_account.py index bb0ee27..e1ee222 100644 --- a/legacy_v2_tests/test_account.py +++ b/legacy_v2_tests/test_account.py @@ -1,7 +1,6 @@ from typing import TYPE_CHECKING from algokit_utils import get_account - from legacy_v2_tests.conftest import get_unique_name if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app.py b/legacy_v2_tests/test_app.py index 07e258a..1b79f70 100644 --- a/legacy_v2_tests/test_app.py +++ b/legacy_v2_tests/test_app.py @@ -1,4 +1,5 @@ import pytest + from algokit_utils import AppDeployMetaData diff --git a/legacy_v2_tests/test_app_client.py b/legacy_v2_tests/test_app_client.py index 8782617..b656514 100644 --- a/legacy_v2_tests/test_app_client.py +++ b/legacy_v2_tests/test_app_client.py @@ -1,4 +1,5 @@ import pytest + from algokit_utils import ( DeploymentFailedError, get_next_version, diff --git a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt index 206749c..70d16cc 100644 --- a/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt +++ b/legacy_v2_tests/test_app_client_call.approvals/test_readonly_call_with_error_with_new_client_missing_source_map.approved.txt @@ -2,6 +2,6 @@ Txn {txn} had error 'assert failed pc=743' at PC 743: Could not determine TEAL source line for the error as no approval source map was provided, to receive a trace of the error please provide an approval SourceMap. Either by: - 1.Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR - 2.Set approval_source_map from a previously compiled approval program OR - 3.Import a previously exported source map using import_source_map + 1. Providing template_values when creating the ApplicationClient, so a SourceMap can be obtained automatically OR + 2. Set approval_source_map from a previously compiled approval program OR + 3. Import a previously exported source map using import_source_map \ No newline at end of file diff --git a/legacy_v2_tests/test_app_client_clear_state.py b/legacy_v2_tests/test_app_client_clear_state.py index f26a709..1d2f652 100644 --- a/legacy_v2_tests/test_app_client_clear_state.py +++ b/legacy_v2_tests/test_app_client_clear_state.py @@ -2,12 +2,12 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, ) - from legacy_v2_tests.conftest import is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_close_out.py b/legacy_v2_tests/test_app_client_close_out.py index 5ee5e9c..81ac5ea 100644 --- a/legacy_v2_tests/test_app_client_close_out.py +++ b/legacy_v2_tests/test_app_client_close_out.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_create.py b/legacy_v2_tests/test_app_client_create.py index 1da7bbf..00fd969 100644 --- a/legacy_v2_tests/test_app_client_create.py +++ b/legacy_v2_tests/test_app_client_create.py @@ -2,6 +2,9 @@ from typing import TYPE_CHECKING import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer, TransactionSigner +from algosdk.transaction import ApplicationCallTxn, GenericSignedTransaction, OnComplete, Transaction + from algokit_utils import ( Account, ApplicationClient, @@ -10,9 +13,6 @@ get_account, get_app_id_from_tx_id, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner, AtomicTransactionComposer, TransactionSigner -from algosdk.transaction import ApplicationCallTxn, GenericSignedTransaction, OnComplete, Transaction - from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_delete.py b/legacy_v2_tests/test_app_client_delete.py index 353bbfa..d5df42c 100644 --- a/legacy_v2_tests/test_app_client_delete.py +++ b/legacy_v2_tests/test_app_client_delete.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_deploy.py b/legacy_v2_tests/test_app_client_deploy.py index 4eed49b..e51392b 100644 --- a/legacy_v2_tests/test_app_client_deploy.py +++ b/legacy_v2_tests/test_app_client_deploy.py @@ -1,6 +1,7 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( ABICreateCallArgs, Account, @@ -9,7 +10,6 @@ TransferParameters, transfer, ) - from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: @@ -17,7 +17,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_opt_in.py b/legacy_v2_tests/test_app_client_opt_in.py index 816e96f..afc1fb1 100644 --- a/legacy_v2_tests/test_app_client_opt_in.py +++ b/legacy_v2_tests/test_app_client_opt_in.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability, is_opted_in if TYPE_CHECKING: @@ -15,7 +15,7 @@ from algosdk.v2client.indexer import IndexerClient -@pytest.fixture() +@pytest.fixture def client_fixture( algod_client: "AlgodClient", indexer_client: "IndexerClient", diff --git a/legacy_v2_tests/test_app_client_prepare.py b/legacy_v2_tests/test_app_client_prepare.py index 6c6355b..affacd5 100644 --- a/legacy_v2_tests/test_app_client_prepare.py +++ b/legacy_v2_tests/test_app_client_prepare.py @@ -1,11 +1,12 @@ import base64 from typing import TYPE_CHECKING +from algosdk.atomic_transaction_composer import AccountTransactionSigner + from algokit_utils import ( ApplicationClient, ApplicationSpecification, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient diff --git a/legacy_v2_tests/test_app_client_resolve.py b/legacy_v2_tests/test_app_client_resolve.py index 6c6023f..d7e8b1d 100644 --- a/legacy_v2_tests/test_app_client_resolve.py +++ b/legacy_v2_tests/test_app_client_resolve.py @@ -5,7 +5,6 @@ ApplicationClient, DefaultArgumentDict, ) - from legacy_v2_tests.conftest import read_spec if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_signer_sender.py b/legacy_v2_tests/test_app_client_signer_sender.py index d6c383c..cfdef0a 100644 --- a/legacy_v2_tests/test_app_client_signer_sender.py +++ b/legacy_v2_tests/test_app_client_signer_sender.py @@ -3,12 +3,13 @@ from typing import TYPE_CHECKING, Any import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner + from algokit_utils import ( ApplicationClient, ApplicationSpecification, get_sender_from_signer, ) -from algosdk.atomic_transaction_composer import AccountTransactionSigner, TransactionSigner if TYPE_CHECKING: from algosdk import transaction @@ -30,7 +31,7 @@ def sign_transactions( @pytest.mark.parametrize("override_signer", [CustomSigner(), AccountTransactionSigner(fake_key), None]) @pytest.mark.parametrize("default_sender", ["default_sender", None]) @pytest.mark.parametrize("default_signer", [CustomSigner(), AccountTransactionSigner(fake_key), None]) -def test_resolve_signer_sender( # noqa: PLR0913 +def test_resolve_signer_sender( *, algod_client: "AlgodClient", app_spec: ApplicationSpecification, diff --git a/legacy_v2_tests/test_app_client_template_values.py b/legacy_v2_tests/test_app_client_template_values.py index 5b27f32..a01f53d 100644 --- a/legacy_v2_tests/test_app_client_template_values.py +++ b/legacy_v2_tests/test_app_client_template_values.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -import algokit_utils import pytest +import algokit_utils from legacy_v2_tests.conftest import get_unique_name, read_spec if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_app_client_update.py b/legacy_v2_tests/test_app_client_update.py index 60cd10d..4dc082e 100644 --- a/legacy_v2_tests/test_app_client_update.py +++ b/legacy_v2_tests/test_app_client_update.py @@ -1,13 +1,13 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, ApplicationClient, ApplicationSpecification, LogicError, ) - from legacy_v2_tests.conftest import check_output_stability if TYPE_CHECKING: diff --git a/legacy_v2_tests/test_asset.py b/legacy_v2_tests/test_asset.py index 3d75fa8..c26906f 100644 --- a/legacy_v2_tests/test_asset.py +++ b/legacy_v2_tests/test_asset.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING import pytest + from algokit_utils import ( Account, EnsureBalanceParameters, @@ -19,7 +20,7 @@ from legacy_v2_tests.conftest import assure_funds, generate_test_asset, get_unique_name -@pytest.fixture() +@pytest.fixture def to_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) diff --git a/legacy_v2_tests/test_debug_utils.py b/legacy_v2_tests/test_debug_utils.py index 9b6d8ca..b827ecd 100644 --- a/legacy_v2_tests/test_debug_utils.py +++ b/legacy_v2_tests/test_debug_utils.py @@ -3,6 +3,13 @@ from unittest.mock import Mock import pytest +from algosdk.atomic_transaction_composer import ( + AccountTransactionSigner, + AtomicTransactionComposer, + TransactionWithSigner, +) +from algosdk.transaction import PaymentTxn + from algokit_utils._debugging import ( AVMDebuggerSourceMap, PersistSourceMapInput, @@ -14,20 +21,13 @@ from algokit_utils.application_specification import ApplicationSpecification from algokit_utils.common import Program from algokit_utils.models import Account -from algosdk.atomic_transaction_composer import ( - AccountTransactionSigner, - AtomicTransactionComposer, - TransactionWithSigner, -) -from algosdk.transaction import PaymentTxn - from legacy_v2_tests.conftest import check_output_stability, get_unique_name if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient -@pytest.fixture() +@pytest.fixture def client_fixture(algod_client: "AlgodClient", app_spec: ApplicationSpecification) -> ApplicationClient: creator_name = get_unique_name() creator = get_account(algod_client, creator_name) diff --git a/legacy_v2_tests/test_deploy.py b/legacy_v2_tests/test_deploy.py index 51708f5..4d2cf8c 100644 --- a/legacy_v2_tests/test_deploy.py +++ b/legacy_v2_tests/test_deploy.py @@ -2,7 +2,6 @@ replace_template_variables, ) from algokit_utils._legacy_v2.deploy import strip_comments - from legacy_v2_tests.conftest import check_output_stability diff --git a/legacy_v2_tests/test_deploy_scenarios.py b/legacy_v2_tests/test_deploy_scenarios.py index 309fe4a..c230ce3 100644 --- a/legacy_v2_tests/test_deploy_scenarios.py +++ b/legacy_v2_tests/test_deploy_scenarios.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest + from algokit_utils import ( Account, ApplicationClient, @@ -19,7 +20,6 @@ get_indexer_client, get_localnet_default_account, ) - from legacy_v2_tests.conftest import check_output_stability, get_specs, get_unique_name, read_spec logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def __init__( self.creator = creator self.app_name = get_unique_name() - def deploy( # noqa: PLR0913 + def deploy( self, app_spec: ApplicationSpecification, *, @@ -128,12 +128,12 @@ def creator(creator_name: str) -> Account: return get_account(get_algod_client(), creator_name) -@pytest.fixture() +@pytest.fixture def app_name() -> str: return get_unique_name() -@pytest.fixture() +@pytest.fixture def deploy_fixture( caplog: pytest.LogCaptureFixture, request: pytest.FixtureRequest, creator_name: str, creator: Account ) -> DeployFixture: diff --git a/legacy_v2_tests/test_dispenser_api_client.py b/legacy_v2_tests/test_dispenser_api_client.py index baa2e1d..ac7fa0f 100644 --- a/legacy_v2_tests/test_dispenser_api_client.py +++ b/legacy_v2_tests/test_dispenser_api_client.py @@ -1,13 +1,14 @@ import json import pytest +from pytest_httpx import HTTPXMock + from algokit_utils.dispenser_api import ( DISPENSER_ASSETS, DispenserApiConfig, DispenserAssetName, TestNetDispenserApiClient, ) -from pytest_httpx import HTTPXMock class TestDispenserApiTestnetClient: diff --git a/legacy_v2_tests/test_transfer.py b/legacy_v2_tests/test_transfer.py index 8253a5e..335fcf1 100644 --- a/legacy_v2_tests/test_transfer.py +++ b/legacy_v2_tests/test_transfer.py @@ -3,6 +3,11 @@ import algosdk import httpx import pytest +from algosdk.atomic_transaction_composer import AccountTransactionSigner +from algosdk.transaction import PaymentTxn +from algosdk.util import algos_to_microalgos +from pytest_httpx import HTTPXMock + from algokit_utils import ( Account, EnsureBalanceParameters, @@ -19,11 +24,6 @@ ) from algokit_utils.dispenser_api import DispenserApiConfig from algokit_utils.network_clients import get_algod_client, get_algonode_config -from algosdk.atomic_transaction_composer import AccountTransactionSigner -from algosdk.transaction import PaymentTxn -from algosdk.util import algos_to_microalgos -from pytest_httpx import HTTPXMock - from legacy_v2_tests.conftest import assure_funds, check_output_stability, generate_test_asset, get_unique_name from legacy_v2_tests.test_network_clients import DEFAULT_TOKEN @@ -35,12 +35,12 @@ MINIMUM_BALANCE = 100_000 # see https://developer.algorand.org/docs/get-details/accounts/#minimum-balance -@pytest.fixture() +@pytest.fixture def to_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) -@pytest.fixture() +@pytest.fixture def rekeyed_from_account(algod_client: "AlgodClient", kmd_client: "KMDClient") -> Account: account = create_kmd_wallet_account(kmd_client, get_unique_name()) rekey_account = create_kmd_wallet_account(kmd_client, get_unique_name()) @@ -68,7 +68,7 @@ def rekeyed_from_account(algod_client: "AlgodClient", kmd_client: "KMDClient") - return Account(address=account.address, private_key=rekey_account.private_key) -@pytest.fixture() +@pytest.fixture def transaction_signer_from_account( kmd_client: "KMDClient", algod_client: "AlgodClient", @@ -87,7 +87,7 @@ def transaction_signer_from_account( return AccountTransactionSigner(private_key=account.private_key) -@pytest.fixture() +@pytest.fixture def clawback_account(kmd_client: "KMDClient") -> Account: return create_kmd_wallet_account(kmd_client, get_unique_name()) diff --git a/pyproject.toml b/pyproject.toml index 723a20f..31e0784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,8 +93,6 @@ lint.select = [ "RUF", # Ruff-specific rules ] lint.ignore = [ - "ANN101", # no type for self - "ANN102", # no type for cls "RET505", # allow else after return "SIM108", # allow if-else in place of ternary "E111", # indentation is not a multiple of four diff --git a/src/algokit_utils/__init__.py b/src/algokit_utils/__init__.py index d89bad9..5b3a164 100644 --- a/src/algokit_utils/__init__.py +++ b/src/algokit_utils/__init__.py @@ -92,93 +92,93 @@ from algokit_utils.models.application import DELETABLE_TEMPLATE_NAME, UPDATABLE_TEMPLATE_NAME __all__ = [ - # ==== LEGACY V2 EXPORTS BEGIN ==== - "create_kmd_wallet_account", - "get_account_from_mnemonic", - "get_or_create_kmd_wallet_account", - "get_localnet_default_account", - "get_dispenser_account", - "get_kmd_wallet_account", - "get_account", - "UPDATABLE_TEMPLATE_NAME", "DELETABLE_TEMPLATE_NAME", + "DISPENSER_ACCESS_TOKEN_KEY", + "DISPENSER_REQUEST_TIMEOUT", "NOTE_PREFIX", - "DeploymentFailedError", - "AppReference", - "AppDeployMetaData", - "AppMetaData", - "AppLookup", - "get_creator_apps", - "replace_template_variables", + "UPDATABLE_TEMPLATE_NAME", "ABIArgsDict", "ABICallArgs", "ABICallArgsDict", "ABICreateCallArgs", "ABICreateCallArgsDict", "ABIMethod", + "ABITransactionResponse", + "Account", + "AlgoClientConfig", + "AppDeployMetaData", + "AppLookup", + "AppMetaData", + "AppReference", + "AppSpecStateDict", + "ApplicationClient", + "ApplicationSpecification", + "CallConfig", + "CommonCallParameters", + "CommonCallParametersDict", "CreateCallParameters", "CreateCallParametersDict", "CreateTransactionParameters", - "CommonCallParameters", - "CommonCallParametersDict", + "DefaultArgumentDict", + "DefaultArgumentType", "DeployCallArgs", - "DeployCreateCallArgs", "DeployCallArgsDict", + "DeployCreateCallArgs", "DeployCreateCallArgsDict", + "DeployResponse", + "DeploymentFailedError", + "DispenserFundResponse", + "DispenserLimitResponse", + "EnsureBalanceParameters", + "EnsureFundedResponse", + "LogicError", + "MethodConfigDict", + "MethodHints", + "OnCompleteActionName", "OnCompleteCallParameters", "OnCompleteCallParametersDict", - "TransactionParameters", - "TransactionParametersDict", - "ApplicationClient", - "DeployResponse", - "OnUpdate", "OnSchemaBreak", + "OnUpdate", "OperationPerformed", + "PersistSourceMapInput", + "Program", "TemplateValueDict", "TemplateValueMapping", - "Program", - "execute_atc_with_logic_error", - "get_app_id_from_tx_id", - "get_next_version", - "get_sender_from_signer", - "num_extra_program_pages", - "AppSpecStateDict", - "ApplicationSpecification", - "CallConfig", - "DefaultArgumentDict", - "DefaultArgumentType", - "MethodConfigDict", - "OnCompleteActionName", - "MethodHints", - "LogicError", - "ABITransactionResponse", - "Account", + "TestNetDispenserApiClient", + "TransactionParameters", + "TransactionParametersDict", "TransactionResponse", - "AlgoClientConfig", + "TransferAssetParameters", + "TransferParameters", + # ==== LEGACY V2 EXPORTS BEGIN ==== + "create_kmd_wallet_account", + "ensure_funded", + "execute_atc_with_logic_error", + "get_account", + "get_account_from_mnemonic", "get_algod_client", "get_algonode_config", + "get_app_id_from_tx_id", + "get_creator_apps", "get_default_localnet_config", + "get_dispenser_account", "get_indexer_client", "get_kmd_client_from_algod_client", + "get_kmd_wallet_account", + "get_localnet_default_account", + "get_next_version", + "get_or_create_kmd_wallet_account", + "get_sender_from_signer", "is_localnet", "is_mainnet", "is_testnet", - "TestNetDispenserApiClient", - "DispenserFundResponse", - "DispenserLimitResponse", - "DISPENSER_ACCESS_TOKEN_KEY", - "DISPENSER_REQUEST_TIMEOUT", - "EnsureBalanceParameters", - "EnsureFundedResponse", - "TransferParameters", - "ensure_funded", - "transfer", - "TransferAssetParameters", - "transfer_asset", + "num_extra_program_pages", "opt_in", "opt_out", "persist_sourcemaps", - "PersistSourceMapInput", + "replace_template_variables", "simulate_and_persist_response", + "transfer", + "transfer_asset", # ==== LEGACY V2 EXPORTS END ==== ] diff --git a/src/algokit_utils/_legacy_v2/_transfer.py b/src/algokit_utils/_legacy_v2/_transfer.py index 6b59cd4..28de779 100644 --- a/src/algokit_utils/_legacy_v2/_transfer.py +++ b/src/algokit_utils/_legacy_v2/_transfer.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient -__all__ = ["TransferParameters", "transfer", "TransferAssetParameters", "transfer_asset"] +__all__ = ["TransferAssetParameters", "TransferParameters", "transfer", "transfer_asset"] logger = logging.getLogger(__name__) diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index 887f9c1..ae192d3 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -11,14 +11,14 @@ from typing_extensions import deprecated __all__ = [ + "AppSpecStateDict", + "ApplicationSpecification", "CallConfig", "DefaultArgumentDict", "DefaultArgumentType", "MethodConfigDict", - "OnCompleteActionName", "MethodHints", - "ApplicationSpecification", - "AppSpecStateDict", + "OnCompleteActionName", ] diff --git a/src/algokit_utils/_legacy_v2/deploy.py b/src/algokit_utils/_legacy_v2/deploy.py index 62c17ad..6a73ba4 100644 --- a/src/algokit_utils/_legacy_v2/deploy.py +++ b/src/algokit_utils/_legacy_v2/deploy.py @@ -36,26 +36,26 @@ __all__ = [ - "UPDATABLE_TEMPLATE_NAME", "DELETABLE_TEMPLATE_NAME", "NOTE_PREFIX", + "UPDATABLE_TEMPLATE_NAME", "ABICallArgs", - "ABICreateCallArgs", "ABICallArgsDict", + "ABICreateCallArgs", "ABICreateCallArgsDict", - "DeploymentFailedError", - "AppReference", "AppDeployMetaData", - "AppMetaData", "AppLookup", + "AppMetaData", + "AppReference", "DeployCallArgs", - "DeployCreateCallArgs", "DeployCallArgsDict", + "DeployCreateCallArgs", "DeployCreateCallArgsDict", - "Deployer", "DeployResponse", - "OnUpdate", + "Deployer", + "DeploymentFailedError", "OnSchemaBreak", + "OnUpdate", "OperationPerformed", "TemplateValueDict", "TemplateValueMapping", diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index ab4a4db..9db2c17 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -10,16 +10,16 @@ __all__ = [ "AlgoClientConfig", + "AlgoClientConfigs", "get_algod_client", "get_algonode_config", "get_default_localnet_config", "get_indexer_client", + "get_kmd_client", "get_kmd_client_from_algod_client", "is_localnet", "is_mainnet", "is_testnet", - "AlgoClientConfigs", - "get_kmd_client", ] diff --git a/src/algokit_utils/applications/app_client.py b/src/algokit_utils/applications/app_client.py index f2bf696..4c71d17 100644 --- a/src/algokit_utils/applications/app_client.py +++ b/src/algokit_utils/applications/app_client.py @@ -772,10 +772,8 @@ def close_out(self, params: AppClientMethodCallWithSendParams) -> SendAppTransac ) def call(self, params: AppClientMethodCallWithSendParams) -> SendAppTransactionResult: - is_read_only_call = ( - params.on_complete == algosdk.transaction.OnComplete.NoOpOC - or not params.on_complete - and get_arc56_method(params.method, self._app_spec).method.readonly + is_read_only_call = params.on_complete == algosdk.transaction.OnComplete.NoOpOC or ( + not params.on_complete and get_arc56_method(params.method, self._app_spec).method.readonly ) if is_read_only_call: diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 7b3ce5f..8ef03a3 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -204,8 +204,8 @@ def deploy(self, deployment: AppDeployParams) -> AppDeployResult: # Get existing app metadata apps = deployment.existing_deployments or self.get_creator_apps_by_name( - deployment.create_params.sender, - deployment.ignore_cache, + creator_address=deployment.create_params.sender, + ignore_cache=deployment.ignore_cache, ) existing_app = apps.apps.get(deployment.metadata.name) @@ -538,7 +538,7 @@ def _update_app_lookup(self, sender: str, app_metadata: AppMetaData) -> None: else: lookup.apps[app_metadata.name] = app_metadata - def get_creator_apps_by_name(self, creator_address: str, ignore_cache: bool = False) -> AppLookup: + def get_creator_apps_by_name(self, *, creator_address: str, ignore_cache: bool = False) -> AppLookup: """Get apps created by an account""" if not ignore_cache and creator_address in self._app_lookups: diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index d2c8277..ab4ecc0 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -442,7 +442,7 @@ def _is_method_call_params( update_args: DeployAppUpdateParams | AppUpdateMethodCall if _is_method_call_params(update_params): - update_args = self.params.deploy_update(update_params) # type: ignore[arg-type] + update_args = self.params.deploy_update(update_params) else: update_args = DeployAppUpdateParams( **self.params.bare.deploy_update( @@ -452,7 +452,7 @@ def _is_method_call_params( delete_args: DeployAppDeleteParams | AppDeleteMethodCall if _is_method_call_params(delete_params): - delete_args = self.params.deploy_delete(delete_params) # type: ignore[arg-type] + delete_args = self.params.deploy_delete(delete_params) else: delete_args = DeployAppDeleteParams( **self.params.bare.deploy_delete( @@ -507,23 +507,23 @@ def _is_method_call_params( if "return_value" in result: if result["operation_performed"] == OperationPerformed.Update: - if update_params and hasattr(update_params, "method"): + if update_params and isinstance(update_params, AppClientMethodCallParams): result["return_value"] = get_arc56_return_value( result["return_value"], - get_arc56_method(update_params.method, self._app_spec), # type: ignore[arg-type] + get_arc56_method(update_params.method, self._app_spec), self._app_spec.structs, ) - elif create_params and hasattr(create_params, "method"): + elif create_params and isinstance(create_params, AppClientMethodCallParams): result["return_value"] = get_arc56_return_value( result["return_value"], - get_arc56_method(create_params.method, self._app_spec), # type: ignore[arg-type] + get_arc56_method(create_params.method, self._app_spec), self._app_spec.structs, ) - if "delete_return_value" in result and delete_params and hasattr(delete_params, "method"): + if "delete_return_value" in result and delete_params and isinstance(delete_params, AppClientMethodCallParams): result["delete_return_value"] = get_arc56_return_value( result["delete_return_value"], - get_arc56_method(delete_params.method, self._app_spec), # type: ignore[arg-type] + get_arc56_method(delete_params.method, self._app_spec), self._app_spec.structs, ) diff --git a/src/algokit_utils/applications/app_manager.py b/src/algokit_utils/applications/app_manager.py index 9ebe9ee..e282ede 100644 --- a/src/algokit_utils/applications/app_manager.py +++ b/src/algokit_utils/applications/app_manager.py @@ -246,7 +246,7 @@ def get_box_value_from_abi_type(self, app_id: int, box_name: BoxIdentifier, abi_ value = self.get_box_value(app_id, box_name) try: parse_to_tuple = isinstance(abi_type, algosdk.abi.TupleType) - decoded_value = abi_type.decode(base64.b64decode(value)) # type: ignore[no-any-return] + decoded_value = abi_type.decode(base64.b64decode(value)) return tuple(decoded_value) if parse_to_tuple else decoded_value except Exception as e: raise ValueError(f"Failed to decode box value {value.decode('utf-8')} with ABI type {abi_type}") from e diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index 9926ff5..c226ef6 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -306,8 +306,8 @@ def convert_method(method: AlgorandABIMethod) -> Method: ) method_actions = MethodActions( - create=convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], # type: ignore[arg-type] - call=convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], # type: ignore[arg-type] + create=convert_actions(hint.call_config, "CREATE") if hint and hint.call_config else [], # type: ignore # noqa: PGH003 + call=convert_actions(hint.call_config, "CALL") if hint and hint.call_config else [], ) return Method( @@ -374,12 +374,16 @@ def get_action_value(key: str) -> str | None: state = Arc56ContractState( schemas={ "global": { - "ints": app_spec.global_state_schema.num_uints, # type: ignore[attr-defined] - "bytes": app_spec.global_state_schema.num_byte_slices, # type: ignore[attr-defined] + "ints": int(app_spec.global_state_schema.num_uints) if app_spec.global_state_schema.num_uints else 0, + "bytes": int(app_spec.global_state_schema.num_byte_slices) + if app_spec.global_state_schema.num_byte_slices + else 0, }, "local": { - "ints": app_spec.local_state_schema.num_uints, # type: ignore[attr-defined] - "bytes": app_spec.local_state_schema.num_byte_slices, # type: ignore[attr-defined] + "ints": int(app_spec.local_state_schema.num_uints) if app_spec.local_state_schema.num_uints else 0, + "bytes": int(app_spec.local_state_schema.num_byte_slices) + if app_spec.local_state_schema.num_byte_slices + else 0, }, }, keys={ diff --git a/src/algokit_utils/clients/algorand_client.py b/src/algokit_utils/clients/algorand_client.py index 1179b0c..eb4ef73 100644 --- a/src/algokit_utils/clients/algorand_client.py +++ b/src/algokit_utils/clients/algorand_client.py @@ -30,16 +30,16 @@ __all__ = [ "AlgorandClient", - "AssetCreateParams", - "AssetOptInParams", + "AppCallParams", "AppMethodCallParams", - "PaymentParams", - "AssetFreezeParams", "AssetConfigParams", + "AssetCreateParams", "AssetDestroyParams", - "AppCallParams", - "OnlineKeyRegistrationParams", + "AssetFreezeParams", + "AssetOptInParams", "AssetTransferParams", + "OnlineKeyRegistrationParams", + "PaymentParams", ] diff --git a/src/algokit_utils/errors/logic_error.py b/src/algokit_utils/errors/logic_error.py index a421a34..24fb40a 100644 --- a/src/algokit_utils/errors/logic_error.py +++ b/src/algokit_utils/errors/logic_error.py @@ -53,8 +53,6 @@ def parse_logic_error( class LogicError(Exception): - _blast_radius = DEFAULT_BLAST_RADIUS - def __init__( self, *, @@ -67,7 +65,6 @@ def __init__( logic_error: Exception | None = None, traces: list[SimulationTrace] | None = None, get_line_for_pc: Callable[[int], int | None] | None = None, - blast_radius: int | None = None, ): self.logic_error = logic_error self.logic_error_str = logic_error_str @@ -88,20 +85,6 @@ def __init__( if get_line_for_pc else None ) - self.stack = "" - self._blast_radius = blast_radius or self._blast_radius - - if self.line_no and self.line_no > 0: - line_no = self.line_no - 1 - start = max(0, line_no - self._blast_radius) - stop = min(len(self.program), line_no + self._blast_radius) - - stack_lines = self.program.splitlines()[start:stop] - - middle_index = len(stack_lines) // 2 - stack_lines[middle_index] = stack_lines[middle_index] + " <--- Error" - - self.stack = "\n".join(stack_lines) def __str__(self) -> str: return ( diff --git a/src/algokit_utils/models/account.py b/src/algokit_utils/models/account.py index de487b8..f83cc1e 100644 --- a/src/algokit_utils/models/account.py +++ b/src/algokit_utils/models/account.py @@ -17,7 +17,7 @@ class Account: def __post_init__(self) -> None: if not self.address: - self.address = algosdk.account.address_from_private_key(self.private_key) # type: ignore[arg-type] + self.address = str(algosdk.account.address_from_private_key(self.private_key)) @property def public_key(self) -> bytes: diff --git a/src/algokit_utils/models/application.py b/src/algokit_utils/models/application.py index 27b137b..6ab5d0f 100644 --- a/src/algokit_utils/models/application.py +++ b/src/algokit_utils/models/application.py @@ -49,6 +49,8 @@ def to_dict(self) -> dict[str, Any]: def serialize(obj: Any) -> dict[str, Any] | list[Any] | Any: # noqa: ANN401 if is_dataclass(obj) and not isinstance(obj, type): return {k: serialize(v) for k, v in asdict(obj).items()} + elif isinstance(obj, algosdk.abi.ABIType): + return str(obj) elif isinstance(obj, list): return [serialize(item) for item in obj] elif isinstance(obj, dict): @@ -244,14 +246,22 @@ class Arc56MethodReturnType: class Arc56Method(SerializableBaseClass, algosdk.abi.Method): def __init__(self, method: Method) -> None: # First, create the parent class with original arguments - super().__init__(name=method.name, args=method.args, returns=method.returns, desc=method.desc) # type: ignore[arg-type] + super().__init__( + name=method.name, + args=method.args, # type: ignore[arg-type] + returns=algosdk.abi.Returns(arg_type=method.returns.type, desc=method.returns.desc), + desc=method.desc, + ) self.method = method # Store our custom Arc56MethodArg list separately + self._arc56_args = [ Arc56MethodArg.from_method_arg( arg, - algosdk.abi.ABIType.from_string(arg.type), + algosdk.abi.ABIType.from_string(arg.type) + if not self._is_transaction_or_reference_type(arg.type) and isinstance(arg.type, str) + else arg.type, # type: ignore[arg-type] ) for arg in method.args ] diff --git a/src/algokit_utils/protocols/application.py b/src/algokit_utils/protocols/application.py index a1ba596..c478216 100644 --- a/src/algokit_utils/protocols/application.py +++ b/src/algokit_utils/protocols/application.py @@ -5,12 +5,11 @@ from typing_extensions import runtime_checkable -from algokit_utils.applications.app_deployer import AppDeployer - if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient from algosdk.v2client.indexer import IndexerClient + from algokit_utils.applications.app_deployer import AppDeployer from algokit_utils.applications.app_manager import AppManager from algokit_utils.clients.client_manager import ClientManager from algokit_utils.transactions.transaction_composer import TransactionComposer diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index a574baa..8f9a18b 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -558,9 +558,8 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 # Build transactions transactions_with_signer = atc.build_group() - if ( - populate_resources - or config.populate_app_call_resource + if populate_resources or ( + config.populate_app_call_resource and any(isinstance(t.txn, algosdk.transaction.ApplicationCallTxn) for t in transactions_with_signer) ): atc = populate_app_call_resources(atc, algod) @@ -1126,7 +1125,7 @@ def _build_app_call( params: AppCallParams | AppUpdateParams | AppCreateParams | AppDeleteParams, suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: - app_id = params.app_id if hasattr(params, "app_id") and params.app_id else 0 # type: ignore[] + app_id = getattr(params, "app_id", 0) approval_program = None clear_program = None diff --git a/src/algokit_utils/transactions/utils.py b/src/algokit_utils/transactions/utils.py index 75cc69b..de1f2ef 100644 --- a/src/algokit_utils/transactions/utils.py +++ b/src/algokit_utils/transactions/utils.py @@ -12,7 +12,7 @@ MAX_APP_CALL_FOREIGN_REFERENCES = 8 -def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer: +def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClient) -> AtomicTransactionComposer: # noqa: C901, PLR0915, PLR0912 """ Populate application call resources based on simulation results. """ @@ -64,7 +64,7 @@ def populate_app_call_resources(atc: AtomicTransactionComposer, algod: AlgodClie app_txn.foreign_assets = foreign_assets app_txn.boxes = boxes - def populate_group_resource( + def populate_group_resource( # noqa: C901, PLR0915 txns: list[TransactionWithSigner], reference: str | BoxReference | dict[str, Any] | int, ref_type: str ) -> None: """Helper function to populate group-level resources""" diff --git a/tests/accounts/test_account_manager.py b/tests/accounts/test_account_manager.py index ec56a00..80902fb 100644 --- a/tests/accounts/test_account_manager.py +++ b/tests/accounts/test_account_manager.py @@ -72,7 +72,7 @@ def test_random_account_creation(algorand: AlgorandClient) -> None: # Assert assert account.address assert account.private_key - assert len(account.public_key) == 32 + assert len(account.public_key) == 32 # noqa: PLR2004 def test_ensure_funded_from_environment(algorand: AlgorandClient) -> None: diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 6f6489b..7b30dc3 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -66,11 +66,11 @@ def hello_world_arc32_app_id( approval_program=hello_world_arc32_app_spec.approval_program, clear_state_program=hello_world_arc32_app_spec.clear_program, schema={ - "global_ints": global_schema.num_uints, - "global_bytes": global_schema.num_byte_slices, - "local_ints": local_schema.num_uints, - "local_bytes": local_schema.num_byte_slices, - }, # type: ignore[arg-type] + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + }, ) ) return response.app_id @@ -108,11 +108,11 @@ def testing_app_arc32_app_id( approval_program=approval, clear_state_program=testing_app_arc32_app_spec.clear_program, schema={ - "global_bytes": global_schema.num_byte_slices, - "global_ints": global_schema.num_uints, - "local_bytes": local_schema.num_byte_slices, - "local_ints": local_schema.num_uints, - }, # type: ignore[arg-type] + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + }, ) ) return response.app_id @@ -178,11 +178,11 @@ def testing_app_puya_arc32_app_id( approval_program=testing_app_puya_arc32_app_spec.approval_program, clear_state_program=testing_app_puya_arc32_app_spec.clear_program, schema={ - "global_bytes": global_schema.num_byte_slices, - "global_ints": global_schema.num_uints, - "local_bytes": local_schema.num_byte_slices, - "local_ints": local_schema.num_uints, - }, # type: ignore[] + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + }, ) ) return response.app_id @@ -334,7 +334,7 @@ def test_construct_transaction_with_abi_encoding_including_transaction( ) -> None: # Create a payment transaction with random amount amount = AlgoAmount.from_micro_algos(random.randint(1, 10000)) - payment_txn = algorand.send.payment( + payment_txn = algorand.create_transaction.payment( PaymentParams( sender=funded_account.address, receiver=funded_account.address, @@ -346,7 +346,7 @@ def test_construct_transaction_with_abi_encoding_including_transaction( result = test_app_client.send.call( AppClientMethodCallWithSendParams( method="call_abi_txn", - args=[payment_txn.transaction, "test"], + args=[payment_txn, "test"], ) ) @@ -472,7 +472,7 @@ def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> assert hasattr(global_state["bytes2"], "value_raw") assert sorted(global_state.keys()) == ["bytes1", "bytes2", "int1", "int2", "value"] assert global_state["int1"].value == 1 - assert global_state["int2"].value == 2 + assert global_state["int2"].value == 2 # noqa: PLR2004 assert global_state["bytes1"].value == "asdf" assert global_state["bytes2"].value_raw == bytes([1, 2, 3, 4]) @@ -489,7 +489,7 @@ def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> assert "local_bytes2" in local_state assert sorted(local_state.keys()) == ["local_bytes1", "local_bytes2", "local_int1", "local_int2"] assert local_state["local_int1"].value == 1 - assert local_state["local_int2"].value == 2 + assert local_state["local_int2"].value == 2 # noqa: PLR2004 assert local_state["local_bytes1"].value == "asdf" assert local_state["local_bytes2"].value_raw == bytes([1, 2, 3, 4]) diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index a108a1f..739fc24 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -222,7 +222,7 @@ def test_deploy_app_update_abi(factory: AppFactory) -> None: assert updated_app.updated_round != updated_app.created_round assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] assert isinstance(updated_app.transaction, ApplicationCallTxn) - assert updated_app.transaction.on_complete == OnComplete.UpdateApplicationOC # type: ignore[union-attr] + assert updated_app.transaction.on_complete == OnComplete.UpdateApplicationOC assert updated_app.return_value == "args_io" @@ -249,8 +249,8 @@ def test_deploy_app_replace(factory: AppFactory) -> None: assert replaced_app.delete_result.confirmation is not None assert len(replaced_app.transactions) == 2 # noqa: PLR2004 assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) - assert replaced_app.delete_result.transaction.index == created_app.app_id # type: ignore[union-attr] - assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] + assert replaced_app.delete_result.transaction.index == created_app.app_id + assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC def test_deploy_app_replace_abi(factory: AppFactory) -> None: @@ -277,10 +277,10 @@ def test_deploy_app_replace_abi(factory: AppFactory) -> None: assert replaced_app.confirmation is not None assert replaced_app.delete_result is not None assert replaced_app.delete_result.confirmation is not None - assert len(replaced_app.transactions) == 2 + assert len(replaced_app.transactions) == 2 # noqa: PLR2004 assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) - assert replaced_app.delete_result.transaction.index == created_app.app_id # type: ignore[union-attr] - assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC # type: ignore[union-attr] + assert replaced_app.delete_result.transaction.index == created_app.app_id + assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC assert replaced_app.return_value == "arg_io" assert replaced_app.delete_return_value == "arg2_io" @@ -424,20 +424,13 @@ def test_export_import_sourcemaps( new_client.send.call(AppClientMethodCallWithSendParams(method="error")) error = exc_info.value - assert error.stack == ( - "// error\n" - "error_7:\n" - "proto 0 0\n" - "intc_0 // 0\n" - "// Deliberate error\n" - "assert <--- Error\n" - "retsub\n\n" - "// create\n" - "create_8:" + assert ( + error.trace().strip() + == "// error\n\terror_7:\n\tproto 0 0\n\tintc_0 // 0\n\t// Deliberate error\n\tassert\t\t<-- Error\n\tretsub\n\t\n\t// create\n\tcreate_8:" # noqa: E501 ) - assert error.pc == 885 + assert error.pc == 885 # noqa: PLR2004 assert error.message == "assert failed pc=885" - assert len(error.transaction_id) == 52 + assert len(error.transaction_id) == 52 # noqa: PLR2004 def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( @@ -453,11 +446,9 @@ def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( }, ) - with pytest.raises(LogicError) as exc_info: + with pytest.raises(Exception, match="this is an error"): client.send.call(AppClientMethodCallWithSendParams(method="throwError")) - assert "this is an error" in exc_info.value.stack - def test_arc56_undefined_error_message_with_dynamic_template_vars_cblock_offset( arc56_factory: AppFactory, @@ -491,15 +482,7 @@ def test_arc56_undefined_error_message_with_dynamic_template_vars_cblock_offset( with pytest.raises(LogicError) as exc_info: app_client.send.call(AppClientMethodCallWithSendParams(method="tmpl")) - expected_error = """log - -// tests/example-contracts/arc56_templates/templates.algo.ts:14 -// assert(this.uint64TmplVar) -intc 1 // TMPL_uint64TmplVar -assert <--- Error -retsub - -// specificLengthTemplateVar()void -*abi_route_specificLengthTemplateVar:""".splitlines() - - assert expected_error == [t.strip() for t in exc_info.value.stack.splitlines()] + assert ( + exc_info.value.trace().strip() + == "// tests/example-contracts/arc56_templates/templates.algo.ts:14\n\t\t// assert(this.uint64TmplVar)\n\t\tintc 1 // TMPL_uint64TmplVar\n\t\tassert\n\t\tretsub\t\t<-- Error\n\t\n\t// specificLengthTemplateVar()void\n\t*abi_route_specificLengthTemplateVar:\n\t\t// execute specificLengthTemplateVar()void" # noqa: E501 + ) diff --git a/tests/clients/test_algorand_client.py b/tests/clients/test_algorand_client.py index 930644b..e69de29 100644 --- a/tests/clients/test_algorand_client.py +++ b/tests/clients/test_algorand_client.py @@ -1,223 +0,0 @@ -# TODO: Update tests for latest version of algokit-utils -# import json -# from pathlib import Path - -# import pytest -# from algokit_utils import Account, ApplicationClient -# from algokit_utils.accounts.account_manager import TransactionSignerAccount -# from algokit_utils.clients.algorand_client import ( -# AlgorandClient, -# AppMethodCallParams, -# AssetCreateParams, -# AssetOptInParams, -# PaymentParams, -# ) -# from algosdk.abi import Contract -# from algosdk.atomic_transaction_composer import AtomicTransactionComposer - - -# @pytest.fixture() -# def algorand(funded_account: Account) -> AlgorandClient: -# client = AlgorandClient.default_local_net() -# client.set_signer(sender=funded_account.address, signer=funded_account.signer) -# return client - - -# @pytest.fixture() -# def alice(algorand: AlgorandClient, funded_account: Account) -> TransactionSignerAccount: -# acct = algorand.account.random() -# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) -# return acct - - -# @pytest.fixture() -# def bob(algorand: AlgorandClient, funded_account: Account) -> TransactionSignerAccount: -# acct = algorand.account.random() -# algorand.send.payment(PaymentParams(sender=funded_account.address, receiver=acct.address, amount=1_000_000)) -# return acct - - -# @pytest.fixture() -# def app_client(algorand: AlgorandClient, alice: TransactionSignerAccount) -> ApplicationClient: -# client = ApplicationClient( -# algorand.client.algod, -# Path(__file__).parent / "app_algorand_client.json", -# sender=alice.address, -# signer=alice.signer, -# ) -# client.create(call_abi_method="createApplication") -# return client - - -# @pytest.fixture() -# def contract() -> Contract: -# with Path.open(Path(__file__).parent / "app_algorand_client.json") as f: -# return Contract.from_json(json.dumps(json.load(f)["contract"])) - - -# def test_send_payment(algorand: AlgorandClient, alice: TransactionSignerAccount, bob: TransactionSignerAccount) -> None: -# amount = 100_000 - -# alice_pre_balance = algorand.account.get_information(alice.address)["amount"] -# bob_pre_balance = algorand.account.get_information(bob.address)["amount"] -# result = algorand.send.payment(PaymentParams(sender=alice.address, receiver=bob.address, amount=amount)) -# alice_post_balance = algorand.account.get_information(alice.address)["amount"] -# bob_post_balance = algorand.account.get_information(bob.address)["amount"] - -# assert result["confirmation"] is not None -# assert alice_post_balance == alice_pre_balance - 1000 - amount -# assert bob_post_balance == bob_pre_balance + amount - - -# def test_send_asset_create(algorand: AlgorandClient, alice: TransactionSignerAccount) -> None: -# total = 100 - -# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) -# asset_index = result["confirmation"]["asset-index"] - -# assert asset_index > 0 - - -# def test_asset_opt_in(algorand: AlgorandClient, alice: TransactionSignerAccount, bob: TransactionSignerAccount) -> None: -# total = 100 - -# result = algorand.send.asset_create(AssetCreateParams(sender=alice.address, total=total)) -# asset_index = result["confirmation"]["asset-index"] - -# algorand.send.asset_opt_in(AssetOptInParams(sender=bob.address, asset_id=asset_index)) - -# assert algorand.account.get_asset_information(bob.address, asset_index) is not None - - -# DO_MATH_VALUE = 3 - - -# def test_add_atc(algorand: AlgorandClient, app_client: ApplicationClient, alice: TransactionSignerAccount) -> None: -# atc = AtomicTransactionComposer() -# app_client.compose_call(atc, call_abi_method="doMath", a=1, b=2, operation="sum") - -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_atc(atc) -# .execute() -# ) -# assert result.abi_results[0].return_value == DO_MATH_VALUE - - -# def test_add_method_call( -# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient -# ) -> None: -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("doMath"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[1, 2, "sum"], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == DO_MATH_VALUE - - -# def test_add_method_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient -# ) -> None: -# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# result = ( -# algorand.new_group() -# .add_payment(PaymentParams(sender=alice.address, amount=0, receiver=alice.address)) -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[pay_arg], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address - - -# def test_add_method_call_with_method_call_arg( -# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient -# ) -> None: -# hello_world_call = AppMethodCallParams( -# method=contract.get_method_by_name("helloWorld"), sender=alice.address, app_id=app_client.app_id -# ) -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("methodArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[hello_world_call], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == "Hello, World!" -# assert result.abi_results[1].return_value == app_client.app_id - - -# def test_add_method_call_with_method_call_arg_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient -# ) -> None: -# pay_arg = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# txn_arg_call = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg] -# ) -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("nestedTxnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[txn_arg_call], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address -# assert result.abi_results[1].return_value == app_client.app_id - - -# def test_add_method_call_with_two_method_call_args_with_txn_arg( -# algorand: AlgorandClient, contract: Contract, alice: TransactionSignerAccount, app_client: ApplicationClient -# ) -> None: -# pay_arg_1 = PaymentParams(sender=alice.address, receiver=alice.address, amount=1) -# txn_arg_call_1 = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[pay_arg_1], -# note=b"1", -# ) - -# pay_arg_2 = PaymentParams(sender=alice.address, receiver=alice.address, amount=2) -# txn_arg_call_2 = AppMethodCallParams( -# method=contract.get_method_by_name("txnArg"), sender=alice.address, app_id=app_client.app_id, args=[pay_arg_2] -# ) - -# result = ( -# algorand.new_group() -# .add_method_call( -# AppMethodCallParams( -# method=contract.get_method_by_name("doubleNestedTxnArg"), -# sender=alice.address, -# app_id=app_client.app_id, -# args=[txn_arg_call_1, txn_arg_call_2], -# ) -# ) -# .execute() -# ) -# assert result.abi_results[0].return_value == alice.address -# assert result.abi_results[1].return_value == alice.address -# assert result.abi_results[2].return_value == app_client.app_id diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index dd207bc..52df797 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -95,11 +95,11 @@ def test_hello_world_arc32_app_id( approval_program=test_hello_world_arc32_app_spec.approval_program, clear_state_program=test_hello_world_arc32_app_spec.clear_program, schema={ - "global_ints": global_schema.num_uints, - "global_bytes": global_schema.num_byte_slices, - "local_ints": local_schema.num_uints, - "local_bytes": local_schema.num_byte_slices, - }, # type: ignore[arg-type] + "global_ints": int(global_schema.num_uints) if global_schema.num_uints else 0, + "global_bytes": int(global_schema.num_byte_slices) if global_schema.num_byte_slices else 0, + "local_ints": int(local_schema.num_uints) if local_schema.num_uints else 0, + "local_bytes": int(local_schema.num_byte_slices) if local_schema.num_byte_slices else 0, + }, ) ) return response.app_id @@ -377,9 +377,6 @@ def test_asset_opt_out(transaction_sender: AlgorandClientTransactionSender, send assert txn.close_assets_to == sender.address -# TODO: add remaining tests for app_update, app_delete, app_create_method_call, app_update method call, app_delete method call - - def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: Account) -> None: approval_program = "#pragma version 6\nint 1" clear_state_program = "#pragma version 6\nint 1" From 415c6ab33c4393cbf88086b7cfea5d047aeda6d3 Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Tue, 10 Dec 2024 03:22:12 +0100 Subject: [PATCH 18/19] chore: make some asset param fields optional; add subtraction dunder to AlgoAmount --- src/algokit_utils/models/amount.py | 24 +++++++++++++++++++ .../transactions/transaction_composer.py | 6 ++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/algokit_utils/models/amount.py b/src/algokit_utils/models/amount.py index ac86cd3..adb7ffa 100644 --- a/src/algokit_utils/models/amount.py +++ b/src/algokit_utils/models/amount.py @@ -121,3 +121,27 @@ def __ge__(self, other: object) -> bool: elif isinstance(other, int | Decimal): return self.amount_in_micro_algo >= int(other) raise TypeError(f"Unsupported operand type(s) for >=: 'AlgoAmount' and '{type(other).__name__}'") + + def __sub__(self, other: int | Decimal | AlgoAmount) -> AlgoAmount: + if isinstance(other, AlgoAmount): + total_micro_algos = self.micro_algos - other.micro_algos + elif isinstance(other, (int | Decimal)): + total_micro_algos = self.micro_algos - int(other) + else: + raise TypeError(f"Unsupported operand type(s) for -: 'AlgoAmount' and '{type(other).__name__}'") + return AlgoAmount.from_micro_algos(total_micro_algos) + + def __rsub__(self, other: int | Decimal) -> AlgoAmount: + if isinstance(other, (int | Decimal)): + total_micro_algos = int(other) - self.micro_algos + return AlgoAmount.from_micro_algos(total_micro_algos) + raise TypeError(f"Unsupported operand type(s) for -: '{type(other).__name__}' and 'AlgoAmount'") + + def __isub__(self, other: int | Decimal | AlgoAmount) -> Self: + if isinstance(other, AlgoAmount): + self.amount_in_micro_algo -= other.micro_algos + elif isinstance(other, (int | Decimal)): + self.amount_in_micro_algo -= int(other) + else: + raise TypeError(f"Unsupported operand type(s) for -: 'AlgoAmount' and '{type(other).__name__}'") + return self diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 8f9a18b..0ea7067 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -117,9 +117,9 @@ class AssetCreateParams( """ total: int - asset_name: str - unit_name: str - url: str + asset_name: str | None = None + unit_name: str | None = None + url: str | None = None decimals: int | None = None default_frozen: bool | None = None manager: str | None = None From dc0bbede6b0561415ccd31e67a6300ff37ba823b Mon Sep 17 00:00:00 2001 From: Altynbek Orumbayev Date: Wed, 11 Dec 2024 18:15:00 +0100 Subject: [PATCH 19/19] chore: more tests; updating deprecation decorators; initial tweaks for ruff --- .github/workflows/check-python.yaml | 8 +- .pre-commit-config.yaml | 1 + legacy_v2_tests/test_app_client_call.py | 3 +- pyproject.toml | 4 + src/algokit_utils/_debugging.py | 10 +- .../_legacy_v2/_ensure_funded.py | 5 +- src/algokit_utils/_legacy_v2/account.py | 31 +- .../_legacy_v2/application_client.py | 16 +- .../_legacy_v2/application_specification.py | 5 +- src/algokit_utils/_legacy_v2/asset.py | 10 +- .../_legacy_v2/network_clients.py | 18 +- src/algokit_utils/accounts/account_manager.py | 109 ++++- .../accounts/kmd_account_manager.py | 4 +- .../applications/app_deployer.py | 9 +- src/algokit_utils/applications/app_factory.py | 15 +- src/algokit_utils/applications/utils.py | 5 +- src/algokit_utils/clients/client_manager.py | 11 +- .../clients/dispenser_api_client.py | 5 +- src/algokit_utils/config.py | 4 + src/algokit_utils/transactions/models.py | 52 ++- .../transactions/transaction_composer.py | 27 +- .../transactions/transaction_sender.py | 12 +- src/algokit_utils/transactions/utils.py | 31 ++ tests/accounts/test_account_manager.py | 2 +- tests/applications/test_app_client.py | 16 +- tests/applications/test_app_factory.py | 32 +- .../__init__.py} | 0 .../clients/algorand_client/test_transfer.py | 427 ++++++++++++++++++ tests/conftest.py | 47 +- tests/transactions/test_transaction_sender.py | 50 +- 30 files changed, 803 insertions(+), 166 deletions(-) rename tests/clients/{test_algorand_client.py => algorand_client/__init__.py} (100%) create mode 100644 tests/clients/algorand_client/test_transfer.py diff --git a/.github/workflows/check-python.yaml b/.github/workflows/check-python.yaml index 0fc454d..e8464b7 100644 --- a/.github/workflows/check-python.yaml +++ b/.github/workflows/check-python.yaml @@ -37,10 +37,10 @@ jobs: # stop the build if there are files that don't meet formatting requirements poetry run ruff format --check . - # - name: Check linting with Ruff - # run: | - # # stop the build if there are Python syntax errors or undefined names - # poetry run ruff check . + - name: Check linting with Ruff + run: | + # stop the build if there are Python syntax errors or undefined names + poetry run ruff check . - name: Check types with mypy run: poetry run mypy diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 74c3fe4..fdfd6d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,6 +23,7 @@ repos: additional_dependencies: [] minimum_pre_commit_version: "0" files: "^(src|tests)/" + exclude: "^tests/artifacts/" - id: mypy name: mypy description: "`mypy` will check Python types for correctness" diff --git a/legacy_v2_tests/test_app_client_call.py b/legacy_v2_tests/test_app_client_call.py index 78a7165..14933f1 100644 --- a/legacy_v2_tests/test_app_client_call.py +++ b/legacy_v2_tests/test_app_client_call.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from hashlib import sha256 from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock, patch @@ -86,7 +87,7 @@ def test_abi_call_with_transaction_arg(client_fixture: ApplicationClient, funded sender=funded_account.address, receiver=client_fixture.app_address, amt=1_000_000, - note=b"Payment", + note=sha256(b"self-payment").digest(), sp=client_fixture.algod_client.suggested_params(), ) # type: ignore[no-untyped-call] payment_with_signer = TransactionWithSigner(payment, AccountTransactionSigner(funded_account.private_key)) diff --git a/pyproject.toml b/pyproject.toml index 31e0784..f5efc25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,12 +127,16 @@ suppress-none-returning = true [tool.ruff.lint.per-file-ignores] "src/algokit_utils/beta/*" = ["ERA001", "E501", "PLR0911"] "src/algokit_utils/applications/app_client.py" = ["SLF001"] +"src/algokit_utils/applications/app_factory.py" = ["SLF001"] "tests/clients/test_algorand_client.py" = ["ERA001"] +"src/algokit_utils/_legacy_v2/**/*" = ["E501"] +"tests/**/*" = ["PLR2004"] [tool.poe.tasks] docs = ["docs-html-only", "docs-md-only"] docs-md-only = "sphinx-build docs/source docs/markdown -b markdown" docs-html-only = "sphinx-build docs/source docs/html" +"tests/**/*" = ["PLR2004"] [tool.pytest.ini_options] pythonpath = ["src", "tests"] diff --git a/src/algokit_utils/_debugging.py b/src/algokit_utils/_debugging.py index 2ab2db5..0b9f798 100644 --- a/src/algokit_utils/_debugging.py +++ b/src/algokit_utils/_debugging.py @@ -209,9 +209,9 @@ def simulate_response( allow_unnamed_resources: bool | None = None, extra_opcode_budget: int | None = None, exec_trace_config: SimulateTraceConfig | None = None, - round: int | None = None, - skip_signatures: int | None = None, - fix_signers: bool | None = None, + round: int | None = None, # noqa: A002 TODO: revisit + skip_signatures: int | None = None, # noqa: ARG001 TODO: revisit + fix_signers: bool | None = None, # noqa: ARG001 TODO: revisit ) -> SimulateAtomicTransactionResponse: """ Simulate and fetch response for the given AtomicTransactionComposer and AlgodClient. @@ -244,7 +244,7 @@ def simulate_response( return atc.simulate(algod_client, simulate_request) -def simulate_and_persist_response( +def simulate_and_persist_response( # noqa: PLR0913 TODO: revisit atc: AtomicTransactionComposer, project_root: Path, algod_client: "AlgodClient", @@ -254,7 +254,7 @@ def simulate_and_persist_response( allow_unnamed_resources: bool | None = None, extra_opcode_budget: int | None = None, exec_trace_config: SimulateTraceConfig | None = None, - round: int | None = None, + round: int | None = None, # noqa: A002 TODO: revisit skip_signatures: int | None = None, fix_signers: bool | None = None, ) -> SimulateAtomicTransactionResponse: diff --git a/src/algokit_utils/_legacy_v2/_ensure_funded.py b/src/algokit_utils/_legacy_v2/_ensure_funded.py index 7f9d662..99409b3 100644 --- a/src/algokit_utils/_legacy_v2/_ensure_funded.py +++ b/src/algokit_utils/_legacy_v2/_ensure_funded.py @@ -116,7 +116,10 @@ def _fund_using_transfer( return EnsureFundedResponse(transaction_id=transaction_id, amount=response.amt) -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.ensure_funded()`, `algorand.account.ensure_funded_from_environment()`, " + "or `algorand.account.ensure_funded_from_testnet_dispenser_api()` instead" +) def ensure_funded( client: AlgodClient, parameters: EnsureBalanceParameters, diff --git a/src/algokit_utils/_legacy_v2/account.py b/src/algokit_utils/_legacy_v2/account.py index 0fef469..9da21ca 100644 --- a/src/algokit_utils/_legacy_v2/account.py +++ b/src/algokit_utils/_legacy_v2/account.py @@ -31,7 +31,9 @@ _DEFAULT_ACCOUNT_MINIMUM_BALANCE = 1_000_000_000 -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.from_mnemonic()` instead. Example: " "`account = algorand.account.from_mnemonic(mnemonic)`" +) def get_account_from_mnemonic(mnemonic: str) -> Account: """Convert a mnemonic (25 word passphrase) into an Account""" private_key = to_private_key(mnemonic) @@ -39,7 +41,7 @@ def get_account_from_mnemonic(mnemonic: str) -> Account: return Account(private_key=private_key, address=address) -@deprecated("Deprecated") +@deprecated("Use `algorand.account.from_kmd()` instead. Example: " "`account = algorand.account.from_kmd(name)`") def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: """Creates a wallet with specified name""" wallet_id = kmd_client.create_wallet(name, "")["id"] @@ -53,7 +55,10 @@ def create_kmd_wallet_account(kmd_client: "KMDClient", name: str) -> Account: return get_account_from_mnemonic(from_private_key(private_account_key)) -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " + "`account = algorand.account.from_kmd(name, fund_with=AlgoAmount.from_algo(1000))`" +) def get_or_create_kmd_wallet_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: @@ -94,7 +99,10 @@ def _is_default_account(account: dict[str, Any]) -> bool: return bool(account["status"] != "Offline" and account["amount"] > _DEFAULT_ACCOUNT_MINIMUM_BALANCE) -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " + "`account = algorand.account.from_kmd('unencrypted-default-wallet', lambda a: a['status'] != 'Offline' and a['amount'] > 1_000_000_000)`" +) def get_localnet_default_account(client: "AlgodClient") -> Account: """Returns the default Account in a LocalNet instance""" if not is_localnet(client): @@ -107,7 +115,10 @@ def get_localnet_default_account(client: "AlgodClient") -> Account: return account -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.dispenser_from_environment()` or `algorand.account.localnet_dispenser()` instead. " + "Example: `dispenser = algorand.account.dispenser_from_environment()`" +) def get_dispenser_account(client: "AlgodClient") -> Account: """Returns an Account based on DISPENSER_MNENOMIC environment variable or the default account on LocalNet""" if is_localnet(client): @@ -115,7 +126,9 @@ def get_dispenser_account(client: "AlgodClient") -> Account: return get_account(client, "DISPENSER") -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.from_kmd()` instead. Example: " "`account = algorand.account.from_kmd(name, predicate)`" +) def get_kmd_wallet_account( client: "AlgodClient", kmd_client: "KMDClient", @@ -149,7 +162,11 @@ def get_kmd_wallet_account( return get_account_from_mnemonic(from_private_key(private_account_key)) -@deprecated("Deprecated") +@deprecated( + "Use `algorand.account.from_environment()` or `algorand.account.from_kmd()` or `algorand.account.random()` instead. " + "Example: " + "`account = algorand.account.from_environment('ACCOUNT', AlgoAmount.from_algo(1000))`" +) def get_account( client: "AlgodClient", name: str, fund_with_algos: float = 1000, kmd_client: "KMDClient | None" = None ) -> Account: diff --git a/src/algokit_utils/_legacy_v2/application_client.py b/src/algokit_utils/_legacy_v2/application_client.py index 002fc0b..0334c83 100644 --- a/src/algokit_utils/_legacy_v2/application_client.py +++ b/src/algokit_utils/_legacy_v2/application_client.py @@ -84,7 +84,16 @@ def num_extra_program_pages(approval: bytes, clear: bytes) -> int: return ceil(((len(approval) + len(clear)) - APP_PAGE_MAX_SIZE) / APP_PAGE_MAX_SIZE) -@deprecated("Use AppClient from algokit_utils.applications instead") +@deprecated( + "Use AppClient from algokit_utils.applications instead. Example:\n" + "```python\n" + "from algokit_utils.clients import AlgorandClient\n" + "from algokit_utils.models.application import Arc56Contract\n" + "algorand_client = AlgorandClient.from_environment()\n" + "app_client = AppClient.from_network(app_spec=Arc56Contract.from_json(app_spec_json), " + "algorand=algorand_client, app_id=123)\n" + "```" +) class ApplicationClient: """A class that wraps an ARC-0032 app spec and provides high productivity methods to deploy and call the app""" @@ -1256,7 +1265,10 @@ def _try_convert_to_logic_error( return None -@deprecated("Deprecated") +@deprecated( + "The execute_atc_with_logic_error function is deprecated; use AppClient's error handling and TransactionComposer's " + "send method for equivalent functionality and improved error management." +) def execute_atc_with_logic_error( atc: AtomicTransactionComposer, algod_client: "AlgodClient", diff --git a/src/algokit_utils/_legacy_v2/application_specification.py b/src/algokit_utils/_legacy_v2/application_specification.py index ae192d3..5b03492 100644 --- a/src/algokit_utils/_legacy_v2/application_specification.py +++ b/src/algokit_utils/_legacy_v2/application_specification.py @@ -137,7 +137,10 @@ def _decode_state_schema(data: dict[str, int]) -> StateSchema: ) -@deprecated("Deprecated") +@deprecated( + "The ApplicationSpecification class is deprecated. Use Arc56Contract and the TransactionComposer and AppClient " + "classes for modern application development." +) @dataclasses.dataclass(kw_only=True) class ApplicationSpecification: """ARC-0032 application specification diff --git a/src/algokit_utils/_legacy_v2/asset.py b/src/algokit_utils/_legacy_v2/asset.py index bc001ce..409523c 100644 --- a/src/algokit_utils/_legacy_v2/asset.py +++ b/src/algokit_utils/_legacy_v2/asset.py @@ -69,7 +69,10 @@ def _ensure_asset_balance_conditions( raise ValueError(error_message) -@deprecated("Deprecated") +@deprecated( + "Use TransactionComposer.add_asset_opt_in() or AlgorandClient.asset.opt_in() instead. " + "Example: composer.add_asset_opt_in(AssetOptInParams(sender=account.address, asset_id=123))" +) def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt-in to a list of assets on the Algorand blockchain. Before an account can receive a specific asset, @@ -118,7 +121,10 @@ def opt_in(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) return result -@deprecated("Deprecated") +@deprecated( + "Use TransactionComposer.add_asset_opt_out() or AlgorandClient.asset.opt_out() instead. " + "Example: composer.add_asset_opt_out(AssetOptOutParams(sender=account.address, asset_id=123, creator=creator_address))" +) def opt_out(algod_client: "AlgodClient", account: Account, asset_ids: list[int]) -> dict[int, str]: """ Opt out from a list of Algorand Standard Assets (ASAs) by transferring them back to their creators. diff --git a/src/algokit_utils/_legacy_v2/network_clients.py b/src/algokit_utils/_legacy_v2/network_clients.py index 9db2c17..4d1341b 100644 --- a/src/algokit_utils/_legacy_v2/network_clients.py +++ b/src/algokit_utils/_legacy_v2/network_clients.py @@ -41,14 +41,14 @@ class AlgoClientConfigs: kmd_config: AlgoClientConfig | None -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.algod") def get_default_localnet_config(config: Literal["algod", "indexer", "kmd"]) -> AlgoClientConfig: """Returns the client configuration to point to the default LocalNet""" port = {"algod": 4001, "indexer": 8980, "kmd": 4002}[config] return AlgoClientConfig(server=f"http://localhost:{port}", token="a" * 64) -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.test_net() or AlgorandClient.main_net() instead") def get_algonode_config( network: Literal["testnet", "mainnet"], config: Literal["algod", "indexer"], token: str ) -> AlgoClientConfig: @@ -59,7 +59,7 @@ def get_algonode_config( ) -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.from_environment() instead. Example: client = AlgorandClient.from_environment()") def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: """Returns an {py:class}`algosdk.v2client.algod.AlgodClient` from `config` or environment @@ -69,7 +69,7 @@ def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: return AlgodClient(config.token, config.server, headers) -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.default_local_net().kmd instead") def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from `config` or environment @@ -78,7 +78,7 @@ def get_kmd_client(config: AlgoClientConfig | None = None) -> KMDClient: return KMDClient(config.token, config.server) -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.from_environment().indexer instead") def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. @@ -88,28 +88,28 @@ def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: return IndexerClient(config.token, config.server, headers) -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.is_local_net() instead") def is_localnet(client: AlgodClient) -> bool: """Returns True if client genesis is `devnet-v1` or `sandnet-v1`""" params = client.suggested_params() return params.gen in ["devnet-v1", "sandnet-v1", "dockernet-v1"] -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.is_main_net() instead") def is_mainnet(client: AlgodClient) -> bool: """Returns True if client genesis is `mainnet-v1`""" params = client.suggested_params() return params.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"] -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.is_test_net() instead") def is_testnet(client: AlgodClient) -> bool: """Returns True if client genesis is `testnet-v1`""" params = client.suggested_params() return params.gen in ["testnet-v1.0", "testnet-v1", "testnet"] -@deprecated("Deprecated") +@deprecated("Use AlgorandClient.client.default_local_net().kmd instead") def get_kmd_client_from_algod_client(client: AlgodClient) -> KMDClient: """Returns an {py:class}`algosdk.kmd.KMDClient` from supplied `client` diff --git a/src/algokit_utils/accounts/account_manager.py b/src/algokit_utils/accounts/account_manager.py index ed4d045..d997a21 100644 --- a/src/algokit_utils/accounts/account_manager.py +++ b/src/algokit_utils/accounts/account_manager.py @@ -12,11 +12,18 @@ from algokit_utils.accounts.kmd_account_manager import KmdAccountManager from algokit_utils.clients.client_manager import ClientManager from algokit_utils.clients.dispenser_api_client import DispenserAssetName, TestNetDispenserApiClient +from algokit_utils.config import config from algokit_utils.models.account import DISPENSER_ACCOUNT_NAME, Account from algokit_utils.models.amount import AlgoAmount -from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer +from algokit_utils.transactions.transaction_composer import ( + PaymentParams, + SendAtomicTransactionComposerResults, + TransactionComposer, +) from algokit_utils.transactions.transaction_sender import SendSingleTransactionResult +logger = config.logger + @dataclass(frozen=True, kw_only=True) class _CommonEnsureFundedParams: @@ -76,7 +83,7 @@ def get_account(self, sender: str) -> Account: raise ValueError(f"No account found for address {sender}") return account - def get_signer(self, sender: str) -> TransactionSigner: + def get_signer(self, sender: str | Account) -> TransactionSigner: """ Returns the `TransactionSigner` for the given sender address. @@ -85,20 +92,20 @@ def get_signer(self, sender: str) -> TransactionSigner: :param sender: The sender address :return: The `TransactionSigner` or throws an error if not found """ - account = self._accounts.get(sender) + account = self._accounts.get(self._get_address(sender)) signer = account.signer if account else self._default_signer if not signer: raise ValueError(f"No signer found for address {sender}") return signer - def get_information(self, sender: str) -> dict[str, Any]: + def get_information(self, sender: str | Account) -> dict[str, Any]: """ Returns the given sender account's current status, balance and spendable amounts. :param sender: The address of the sender/account to look up :return: The account information """ - info = self._client_manager.algod.account_info(sender) + info = self._client_manager.algod.account_info(self._get_address(sender)) assert isinstance(info, dict) return info @@ -145,6 +152,77 @@ def rekeyed(self, sender: Account | str, account: Account) -> Account: self._accounts[sender_address] = account return Account(address=sender_address, private_key=account.private_key) + def rekey_account( # noqa: PLR0913 + self, + account: str | Account, + rekey_to: str | Account, + *, + # Common transaction parameters + signer: TransactionSigner | None = None, + note: bytes | None = None, + lease: bytes | None = None, + static_fee: AlgoAmount | None = None, + extra_fee: AlgoAmount | None = None, + max_fee: AlgoAmount | None = None, + validity_window: int | None = None, + first_valid_round: int | None = None, + last_valid_round: int | None = None, + suppress_log: bool | None = None, + ) -> SendAtomicTransactionComposerResults: + """Rekey an account to a new address. + + Args: + account: The account to rekey + rekey_to: The address or account to rekey to + signer: Optional transaction signer + note: Optional transaction note + lease: Optional transaction lease + static_fee: Optional static fee + extra_fee: Optional extra fee + max_fee: Optional max fee + validity_window: Optional validity window + first_valid_round: Optional first valid round + last_valid_round: Optional last valid round + suppress_log: Optional flag to suppress logging + + Returns: + The transaction result + """ + sender_address = self._get_address(account) + rekey_address = self._get_address(rekey_to) + + result = ( + self._get_composer() + .add_payment( + PaymentParams( + sender=sender_address, + receiver=sender_address, + amount=AlgoAmount.from_micro_algo(0), + rekey_to=rekey_address, + signer=signer, + note=note, + lease=lease, + static_fee=static_fee, + extra_fee=extra_fee, + max_fee=max_fee, + validity_window=validity_window, + first_valid_round=first_valid_round, + last_valid_round=last_valid_round, + suppress_log=suppress_log, + ) + ) + .send() + ) + + # If rekey_to is a signing account, set it as the signer for this account + if isinstance(rekey_to, Account): + self.rekeyed(account, rekey_to) + + if not suppress_log: + logger.info(f"Rekeyed {account} to {rekey_to} via transaction {result.tx_ids[-1]}") + + return result + def random(self) -> Account: """ Tracks and returns a new, random Algorand account. @@ -171,10 +249,10 @@ def dispenser_from_environment(self) -> Account: def ensure_funded( # noqa: PLR0913 self, - account_fo_fund: str | Account, + account_to_fund: str | Account, dispenser_account: str | Account, min_spending_balance: AlgoAmount, - min_funding_increment: AlgoAmount, + min_funding_increment: AlgoAmount | None = None, # Sender params max_rounds_to_wait: int | None = None, suppress_log: bool | None = None, @@ -191,9 +269,9 @@ def ensure_funded( # noqa: PLR0913 first_valid_round: int | None = None, last_valid_round: int | None = None, ) -> EnsureFundedResponse | None: - account_fo_fund = account_fo_fund.address if isinstance(account_fo_fund, Account) else account_fo_fund - dispenser_account = dispenser_account.address if isinstance(dispenser_account, Account) else dispenser_account - amount_funded = self._get_ensure_funded_amount(account_fo_fund, min_spending_balance, min_funding_increment) + account_to_fund = self._get_address(account_to_fund) + dispenser_account = self._get_address(dispenser_account) + amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) if not amount_funded: return None @@ -203,7 +281,7 @@ def ensure_funded( # noqa: PLR0913 .add_payment( PaymentParams( sender=dispenser_account, - receiver=account_fo_fund, + receiver=account_to_fund, amount=amount_funded, signer=signer, rekey_to=rekey_to, @@ -281,7 +359,7 @@ def ensure_funded_from_environment( # noqa: PLR0913 Returns: EnsureFundedResponse if funding was needed, None otherwise """ - account_to_fund = account_to_fund.address if isinstance(account_to_fund, Account) else account_to_fund + account_to_fund = self._get_address(account_to_fund) dispenser_account = self.dispenser_from_environment() amount_funded = self._get_ensure_funded_amount(account_to_fund, min_spending_balance, min_funding_increment) @@ -349,7 +427,7 @@ def ensure_funded_from_testnet_dispenser_api( Raises: ValueError: If attempting to fund on non-TestNet network """ - account_to_fund = account_to_fund.address if isinstance(account_to_fund, Account) else account_to_fund + account_to_fund = self._get_address(account_to_fund) if not self._client_manager.is_test_net(): raise ValueError("Attempt to fund using TestNet dispenser API on non TestNet network.") @@ -367,9 +445,12 @@ def ensure_funded_from_testnet_dispenser_api( return EnsureFundedFromTestnetDispenserApiResponse( transaction_id=result.tx_id, - amount_funded=amount_funded, + amount_funded=AlgoAmount.from_micro_algo(result.amount), ) + def _get_address(self, sender: str | Account) -> str: + return sender.address if isinstance(sender, Account) else sender + def _get_composer(self, get_suggested_params: Callable[[], SuggestedParams] | None = None) -> TransactionComposer: if get_suggested_params is None: diff --git a/src/algokit_utils/accounts/kmd_account_manager.py b/src/algokit_utils/accounts/kmd_account_manager.py index 9af13dd..6ac08c2 100644 --- a/src/algokit_utils/accounts/kmd_account_manager.py +++ b/src/algokit_utils/accounts/kmd_account_manager.py @@ -1,15 +1,15 @@ -import logging from collections.abc import Callable from typing import Any, cast from algosdk.kmd import KMDClient from algokit_utils.clients.client_manager import ClientManager +from algokit_utils.config import config from algokit_utils.models.account import Account from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.transaction_composer import PaymentParams, TransactionComposer -logger = logging.getLogger(__name__) +logger = config.logger class KmdAccount(Account): diff --git a/src/algokit_utils/applications/app_deployer.py b/src/algokit_utils/applications/app_deployer.py index 8ef03a3..357d808 100644 --- a/src/algokit_utils/applications/app_deployer.py +++ b/src/algokit_utils/applications/app_deployer.py @@ -7,7 +7,7 @@ import algosdk from algosdk.atomic_transaction_composer import ABIResult, TransactionSigner from algosdk.logic import get_application_address -from algosdk.transaction import OnComplete, Transaction +from algosdk.transaction import OnComplete from algosdk.v2client.indexer import IndexerClient from algokit_utils._legacy_v2.deploy import ( @@ -20,6 +20,7 @@ ) from algokit_utils.applications.app_manager import AppManager, BoxReference, TealTemplateParams from algokit_utils.config import config +from algokit_utils.transactions.models import TransactionWrapper from algokit_utils.transactions.transaction_composer import ( AppCreateMethodCall, AppCreateParams, @@ -90,7 +91,7 @@ class AppDeployParams: @dataclass(kw_only=True, frozen=True) class ConfirmedTransactionResult: - transaction: algosdk.transaction.Transaction + transaction: TransactionWrapper confirmation: algosdk.v2client.algod.AlgodResponseType confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None @@ -111,9 +112,9 @@ class AppDeployResult: app_id: int | None = None app_address: str | None = None - transaction: Transaction | None = None + transaction: TransactionWrapper | None = None tx_id: str | None = None - transactions: list[Transaction] | None = None + transactions: list[TransactionWrapper] | None = None tx_ids: list[str] | None = None confirmation: algosdk.v2client.algod.AlgodResponseType | None = None confirmations: list[algosdk.v2client.algod.AlgodResponseType] | None = None diff --git a/src/algokit_utils/applications/app_factory.py b/src/algokit_utils/applications/app_factory.py index ab4ecc0..2c8c3a9 100644 --- a/src/algokit_utils/applications/app_factory.py +++ b/src/algokit_utils/applications/app_factory.py @@ -46,6 +46,7 @@ ) from algokit_utils.models.transaction import SendParams from algokit_utils.protocols.application import AlgorandClientProtocol +from algokit_utils.transactions.models import TransactionWrapper from algokit_utils.transactions.transaction_composer import ( AppCreateMethodCall, AppCreateParams, @@ -132,8 +133,8 @@ class AppFactoryDeployResult: operation_performed: OperationPerformed return_value: ABIValue | ABIStruct | None = None returns: list[Any] | None = None - transaction: Transaction - transactions: list[Transaction] + transaction: TransactionWrapper + transactions: list[TransactionWrapper] tx_id: str tx_ids: list[str] updatable: bool @@ -401,7 +402,7 @@ def send(self) -> _AppFactorySendAccessor: def create_transaction(self) -> _AppFactoryCreateTransactionAccessor: return self._create_transaction_accessor - def deploy( + def deploy( # noqa: PLR0913 self, *, deploy_time_params: TealTemplateParams | None = None, @@ -415,9 +416,9 @@ def deploy( updatable: bool | None = None, deletable: bool | None = None, app_name: str | None = None, - max_rounds_to_wait: int | None = None, - suppress_log: bool = False, - populate_app_call_resources: bool = False, + max_rounds_to_wait: int | None = None, # noqa: ARG002 TODO: revisit + suppress_log: bool = False, # noqa: ARG002 TODO: revisit + populate_app_call_resources: bool = False, # noqa: ARG002 TODO: revisit ) -> tuple[AppClient, AppFactoryDeployResult]: updatable = ( updatable if updatable is not None else self._updatable or self._get_deploy_time_control("updatable") @@ -551,7 +552,7 @@ def get_app_client_by_id( ) ) - def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: + def expose_logic_error(self, e: Exception, is_clear_state_program: bool = False) -> Exception: # noqa: FBT002 FBT001 TODO: revisit return AppClient.expose_logic_error_static( e, self._app_spec, diff --git a/src/algokit_utils/applications/utils.py b/src/algokit_utils/applications/utils.py index c226ef6..05bc465 100644 --- a/src/algokit_utils/applications/utils.py +++ b/src/algokit_utils/applications/utils.py @@ -249,7 +249,7 @@ def get_hint(method: AlgorandABIMethod) -> MethodHints | None: return app_spec.hints.get(sig) def get_default_value( - type: str | ABIType, + type: str | ABIType, # noqa: A002 TODO: revisit default_arg: DefaultArgumentDict, ) -> DefaultValue | None: if not default_arg or default_arg["source"] == "abi-method": @@ -339,7 +339,8 @@ def convert_actions( Converts method configuration into a list of on-complete action literals. Args: - call_config (CallConfig | MethodConfigDict): Configuration dictionary or CallConfig object for method actions. + call_config (CallConfig | MethodConfigDict): Configuration dictionary or CallConfig object for method + actions. action_type (Literal["CREATE", "CALL"]): The type of action to convert. Returns: diff --git a/src/algokit_utils/clients/client_manager.py b/src/algokit_utils/clients/client_manager.py index d7fd916..ece39c6 100644 --- a/src/algokit_utils/clients/client_manager.py +++ b/src/algokit_utils/clients/client_manager.py @@ -98,7 +98,7 @@ def kmd(self) -> KMDClient: return self._kmd def network(self) -> NetworkDetail: - sp = self.algod.suggested_params() # TODO: cache it + sp = self._algod.suggested_params() # TODO: cache it return NetworkDetail( is_test_net=sp.gen in ["testnet-v1.0", "testnet-v1", "testnet"], is_main_net=sp.gen in ["mainnet-v1.0", "mainnet-v1", "mainnet"], @@ -159,7 +159,7 @@ def get_algod_client(config: AlgoClientConfig | None = None) -> AlgodClient: If no configuration provided will use environment variables `ALGOD_SERVER`, `ALGOD_PORT` and `ALGOD_TOKEN`""" config = config or _get_config_from_environment("ALGOD") headers = {"X-Algo-API-Token": config.token or ""} - return AlgodClient(config.token or "", config.server, headers) + return AlgodClient(algod_token=config.token or "", algod_address=config.server, headers=headers) @staticmethod def get_algod_client_from_environment() -> AlgodClient: @@ -181,10 +181,11 @@ def get_kmd_client_from_environment() -> KMDClient: def get_indexer_client(config: AlgoClientConfig | None = None) -> IndexerClient: """Returns an {py:class}`algosdk.v2client.indexer.IndexerClient` from `config` or environment. - If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and `INDEXER_TOKEN`""" + If no configuration provided will use environment variables `INDEXER_SERVER`, `INDEXER_PORT` and + `INDEXER_TOKEN`""" config = config or _get_config_from_environment("INDEXER") headers = {"X-Indexer-API-Token": config.token} - return IndexerClient(config.token, config.server, headers) + return IndexerClient(indexer_token=config.token, indexer_address=config.server, headers=headers) @staticmethod def get_indexer_client_from_environment() -> IndexerClient: @@ -291,6 +292,6 @@ def get_algonode_config( """ service_type = "api" if config == "algod" else "idx" return AlgoClientConfig( - server=f"https://{network}-{service_type}.algonode.cloud/", + server=f"https://{network}-{service_type}.algonode.cloud", port=443, ) diff --git a/src/algokit_utils/clients/dispenser_api_client.py b/src/algokit_utils/clients/dispenser_api_client.py index 66593e8..b8a3ef7 100644 --- a/src/algokit_utils/clients/dispenser_api_client.py +++ b/src/algokit_utils/clients/dispenser_api_client.py @@ -1,12 +1,13 @@ import contextlib import enum -import logging import os from dataclasses import dataclass import httpx -logger = logging.getLogger(__name__) +from algokit_utils.config import config + +logger = config.logger class DispenserApiConfig: diff --git a/src/algokit_utils/config.py b/src/algokit_utils/config.py index 8c271e9..f76704c 100644 --- a/src/algokit_utils/config.py +++ b/src/algokit_utils/config.py @@ -34,6 +34,10 @@ def error(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: """Log an error message, optionally suppressing output""" self._get_logger(suppress_log=suppress_log).error(message, *args, **kwargs) + def exception(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: + """Log an exception message, optionally suppressing output""" + self._get_logger(suppress_log=suppress_log).exception(message, *args, **kwargs) + def warning(self, message: str, *args: Any, suppress_log: bool = False, **kwargs: Any) -> None: """Log a warning message, optionally suppressing output""" self._get_logger(suppress_log=suppress_log).warning(message, *args, **kwargs) diff --git a/src/algokit_utils/transactions/models.py b/src/algokit_utils/transactions/models.py index 251bbf9..33edd94 100644 --- a/src/algokit_utils/transactions/models.py +++ b/src/algokit_utils/transactions/models.py @@ -1,4 +1,6 @@ -from typing import Any, Literal, TypedDict +from typing import Any, Literal, TypedDict, TypeVar, cast + +import algosdk # Define specific types for different formats @@ -28,3 +30,51 @@ class JsonFormatArc2Note(BaseArc2Note): TransactionNoteData = str | None | int | list[Any] | dict[str, Any] TransactionNote = bytes | TransactionNoteData | Arc2TransactionNote + +T = TypeVar("T") + + +class TransactionWrapper(algosdk.transaction.Transaction): + """Wrapper around algosdk.transaction.Transaction with optional property validators""" + + def __init__(self, transaction: algosdk.transaction.Transaction) -> None: + self._raw = transaction + + @property + def raw(self) -> algosdk.transaction.Transaction: + return self._raw + + @property + def payment(self) -> algosdk.transaction.PaymentTxn | None: + return self._return_if_type( + algosdk.transaction.PaymentTxn, + ) + + @property + def keyreg(self) -> algosdk.transaction.KeyregTxn | None: + return self._return_if_type(algosdk.transaction.KeyregTxn) + + @property + def asset_config(self) -> algosdk.transaction.AssetConfigTxn | None: + return self._return_if_type(algosdk.transaction.AssetConfigTxn) + + @property + def asset_transfer(self) -> algosdk.transaction.AssetTransferTxn | None: + return self._return_if_type(algosdk.transaction.AssetTransferTxn) + + @property + def asset_freeze(self) -> algosdk.transaction.AssetFreezeTxn | None: + return self._return_if_type(algosdk.transaction.AssetFreezeTxn) + + @property + def application_call(self) -> algosdk.transaction.ApplicationCallTxn | None: + return self._return_if_type(algosdk.transaction.ApplicationCallTxn) + + @property + def state_proof(self) -> algosdk.transaction.StateProofTxn | None: + return self._return_if_type(algosdk.transaction.StateProofTxn) + + def _return_if_type(self, txn_type: type[T]) -> T | None: + if isinstance(self._raw, txn_type): + return cast(T, self._raw) + return None diff --git a/src/algokit_utils/transactions/transaction_composer.py b/src/algokit_utils/transactions/transaction_composer.py index 0ea7067..7d66c93 100644 --- a/src/algokit_utils/transactions/transaction_composer.py +++ b/src/algokit_utils/transactions/transaction_composer.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import math from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Union @@ -14,7 +13,7 @@ TransactionWithSigner, ) from algosdk.error import AlgodHTTPError -from algosdk.transaction import OnComplete, Transaction +from algosdk.transaction import OnComplete from algosdk.v2client.algod import AlgodClient from typing_extensions import deprecated @@ -22,7 +21,8 @@ from algokit_utils.applications.app_manager import AppManager from algokit_utils.config import config from algokit_utils.models.transaction import SendParams -from algokit_utils.transactions.utils import populate_app_call_resources +from algokit_utils.transactions.models import TransactionWrapper +from algokit_utils.transactions.utils import encode_lease, populate_app_call_resources if TYPE_CHECKING: from collections.abc import Callable @@ -36,7 +36,8 @@ from algokit_utils.models.amount import AlgoAmount from algokit_utils.transactions.models import Arc2TransactionNote -logger = logging.getLogger(__name__) + +logger = config.logger @dataclass(kw_only=True, frozen=True) @@ -521,7 +522,7 @@ class SendAtomicTransactionComposerResults: """The confirmation info for each transaction""" tx_ids: list[str] """The transaction IDs that were sent""" - transactions: list[Transaction] + transactions: list[TransactionWrapper] """The transactions that were sent""" returns: list[Any] | list[algosdk.atomic_transaction_composer.ABIResult] """The ABI return values from any ABI method calls""" @@ -604,7 +605,7 @@ def send_atomic_transaction_composer( # noqa: C901, PLR0912 group_id=group_id or "", confirmations=confirmations or [], tx_ids=[t.get_txid() for t in transactions_to_send], - transactions=transactions_to_send, + transactions=[TransactionWrapper(t) for t in transactions_to_send], returns=result.abi_results, ) @@ -876,7 +877,7 @@ def simulate( allow_unnamed_resources: bool | None = None, extra_opcode_budget: int | None = None, exec_trace_config: SimulateTraceConfig | None = None, - round: int | None = None, + round: int | None = None, # noqa: A002 TODO: revisit skip_signatures: int | None = None, fix_signers: bool | None = None, ) -> SendAtomicTransactionComposerResults: @@ -910,7 +911,7 @@ def simulate( return SendAtomicTransactionComposerResults( confirmations=[], # TODO: extract confirmations, - transactions=[txn.txn for txn in atc.txn_list], + transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list], tx_ids=response.tx_ids, group_id=atc.txn_list[-1].txn.group or "", simulate_response=response.simulate_response, @@ -936,7 +937,7 @@ def simulate( return SendAtomicTransactionComposerResults( confirmations=[txn["txn-result"] for txn in confirmation_results], - transactions=[txn.txn for txn in atc.txn_list], + transactions=[TransactionWrapper(txn.txn) for txn in atc.txn_list], tx_ids=response.tx_ids, group_id=atc.txn_list[-1].txn.group or "", simulate_response=response.simulate_response, @@ -975,7 +976,7 @@ def _common_txn_build_step( suggested_params: algosdk.transaction.SuggestedParams, ) -> algosdk.transaction.Transaction: if params.lease: - txn.lease = params.lease + txn.lease = encode_lease(params.lease) if params.rekey_to: txn.rekey_to = params.rekey_to if params.note: @@ -1107,13 +1108,13 @@ def _build_asset_create( sp=suggested_params, total=params.total, default_frozen=params.default_frozen or False, - unit_name=params.unit_name, - asset_name=params.asset_name, + unit_name=params.unit_name or "", + asset_name=params.asset_name or "", manager=params.manager, reserve=params.reserve, freeze=params.freeze, clawback=params.clawback, - url=params.url, + url=params.url or "", metadata_hash=params.metadata_hash, decimals=params.decimals or 0, ) diff --git a/src/algokit_utils/transactions/transaction_sender.py b/src/algokit_utils/transactions/transaction_sender.py index cdc2b01..831100d 100644 --- a/src/algokit_utils/transactions/transaction_sender.py +++ b/src/algokit_utils/transactions/transaction_sender.py @@ -1,6 +1,5 @@ from collections.abc import Callable from dataclasses import dataclass -from logging import getLogger from typing import Any, TypedDict, TypeVar import algosdk @@ -10,6 +9,8 @@ from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager +from algokit_utils.config import config +from algokit_utils.transactions.models import TransactionWrapper from algokit_utils.transactions.transaction_composer import ( AppCallMethodCall, AppCallParams, @@ -32,19 +33,19 @@ TxnParams, ) -logger = getLogger(__name__) +logger = config.logger @dataclass(frozen=True, kw_only=True) class SendSingleTransactionResult: - transaction: Transaction # Last transaction + transaction: TransactionWrapper # Last transaction confirmation: algosdk.v2client.algod.AlgodResponseType # Last confirmation # Fields from SendAtomicTransactionComposerResults group_id: str tx_id: str | None = None tx_ids: list[str] # Full array of transaction IDs - transactions: list[Transaction] + transactions: list[TransactionWrapper] confirmations: list[algosdk.v2client.algod.AlgodResponseType] returns: list[algosdk.atomic_transaction_composer.ABIResult] | None = None @@ -113,12 +114,13 @@ def send_transaction(params: T) -> SendSingleTransactionResult: raw_result = composer.send() raw_result_dict = raw_result.__dict__.copy() + raw_result_dict["transactions"] = raw_result.transactions del raw_result_dict["simulate_response"] result = SendSingleTransactionResult( **raw_result_dict, confirmation=raw_result.confirmations[-1], - transaction=raw_result.transactions[-1], + transaction=raw_result_dict["transactions"][-1], tx_id=raw_result.tx_ids[-1], ) diff --git a/src/algokit_utils/transactions/utils.py b/src/algokit_utils/transactions/utils.py index de1f2ef..216db26 100644 --- a/src/algokit_utils/transactions/utils.py +++ b/src/algokit_utils/transactions/utils.py @@ -269,3 +269,34 @@ def get_unnamed_app_call_resources_accessed(atc: AtomicTransactionComposer, algo "group": group_response.get("unnamed-resources-accessed", {}), "txns": [txn.get("unnamed-resources-accessed", {}) for txn in group_response.get("txn-results", [])], } + + +MAX_LEASE_LENGTH = 32 + + +def encode_lease(lease: str | bytes | None) -> bytes | None: + if lease is None: + return None + elif isinstance(lease, bytes): + if not (1 <= len(lease) <= MAX_LEASE_LENGTH): + raise ValueError( + f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, " + f"but received bytes with length {len(lease)}" + ) + if len(lease) == MAX_LEASE_LENGTH: + return lease + lease32 = bytearray(32) + lease32[: len(lease)] = lease + return bytes(lease32) + elif isinstance(lease, str): + encoded = lease.encode("utf-8") + if not (1 <= len(encoded) <= MAX_LEASE_LENGTH): + raise ValueError( + f"Received invalid lease; expected something with length between 1 and {MAX_LEASE_LENGTH}, " + f"but received '{lease}' with length {len(lease)}" + ) + lease32 = bytearray(MAX_LEASE_LENGTH) + lease32[: len(encoded)] = encoded + return bytes(lease32) + else: + raise TypeError(f"Unknown lease type received of {type(lease)}") diff --git a/tests/accounts/test_account_manager.py b/tests/accounts/test_account_manager.py index 80902fb..ec56a00 100644 --- a/tests/accounts/test_account_manager.py +++ b/tests/accounts/test_account_manager.py @@ -72,7 +72,7 @@ def test_random_account_creation(algorand: AlgorandClient) -> None: # Assert assert account.address assert account.private_key - assert len(account.public_key) == 32 # noqa: PLR2004 + assert len(account.public_key) == 32 def test_ensure_funded_from_environment(algorand: AlgorandClient) -> None: diff --git a/tests/applications/test_app_client.py b/tests/applications/test_app_client.py index 7b30dc3..c246924 100644 --- a/tests/applications/test_app_client.py +++ b/tests/applications/test_app_client.py @@ -351,7 +351,7 @@ def test_construct_transaction_with_abi_encoding_including_transaction( ) assert result.confirmation - assert len(result.transactions) == 2 # noqa: PLR2004 + assert len(result.transactions) == 2 return_value = AppManager.get_abi_return( result.confirmation, get_arc56_method("call_abi_txn", test_app_client.app_spec) ) @@ -403,7 +403,7 @@ def test_sign_transaction_in_group_with_different_signer_if_provided( # Generate a new account test_account = algorand.account.random() algorand.account.ensure_funded( - account_fo_fund=test_account, + account_to_fund=test_account, dispenser_account=funded_account, min_spending_balance=AlgoAmount.from_algos(10), min_funding_increment=AlgoAmount.from_algos(1), @@ -432,7 +432,7 @@ def test_construct_transaction_with_abi_encoding_including_foreign_references_no ) -> None: test_account = algorand.account.random() algorand.account.ensure_funded( - account_fo_fund=test_account, + account_to_fund=test_account, dispenser_account=funded_account, min_spending_balance=AlgoAmount.from_algos(10), min_funding_increment=AlgoAmount.from_algos(1), @@ -472,7 +472,7 @@ def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> assert hasattr(global_state["bytes2"], "value_raw") assert sorted(global_state.keys()) == ["bytes1", "bytes2", "int1", "int2", "value"] assert global_state["int1"].value == 1 - assert global_state["int2"].value == 2 # noqa: PLR2004 + assert global_state["int2"].value == 2 assert global_state["bytes1"].value == "asdf" assert global_state["bytes2"].value_raw == bytes([1, 2, 3, 4]) @@ -489,7 +489,7 @@ def test_retrieve_state(test_app_client: AppClient, funded_account: Account) -> assert "local_bytes2" in local_state assert sorted(local_state.keys()) == ["local_bytes1", "local_bytes2", "local_int1", "local_int2"] assert local_state["local_int1"].value == 1 - assert local_state["local_int2"].value == 2 # noqa: PLR2004 + assert local_state["local_int2"].value == 2 assert local_state["local_bytes1"].value == "asdf" assert local_state["local_bytes2"].value_raw == bytes([1, 2, 3, 4]) @@ -727,7 +727,7 @@ def test_exposing_logic_error(test_app_client_with_sourcemaps: AppClient) -> Non test_app_client_with_sourcemaps.send.call(AppClientMethodCallWithSendParams(method="error")) error = exc_info.value - assert error.pc == 885 # noqa: PLR2004 + assert error.pc == 885 assert "assert failed pc=885" in str(error) - assert len(error.transaction_id) == 52 # noqa: PLR2004 - assert error.line_no == 469 # noqa: PLR2004 + assert len(error.transaction_id) == 52 + assert error.line_no == 469 diff --git a/tests/applications/test_app_factory.py b/tests/applications/test_app_factory.py index 739fc24..8cf9e75 100644 --- a/tests/applications/test_app_factory.py +++ b/tests/applications/test_app_factory.py @@ -3,7 +3,7 @@ import algosdk import pytest from algosdk.logic import get_application_address -from algosdk.transaction import ApplicationCallTxn, ApplicationCreateTxn, OnComplete +from algosdk.transaction import OnComplete from algokit_utils import OnSchemaBreak, OnUpdate, OperationPerformed from algokit_utils.applications.app_client import ( @@ -90,7 +90,7 @@ def test_create_app_with_constructor_deploy_time_params(algorand: AlgorandClient random_account = algorand.account.random() dispenser_account = algorand.account.localnet_dispenser() algorand.account.ensure_funded( - account_fo_fund=random_account, + account_to_fund=random_account, dispenser_account=dispenser_account.address, min_spending_balance=AlgoAmount.from_algo(10), min_funding_increment=AlgoAmount.from_algo(1), @@ -125,8 +125,8 @@ def test_create_app_with_oncomplete_overload(factory: AppFactory) -> None: ) ) - assert isinstance(result.transaction, ApplicationCreateTxn) - assert result.transaction.on_complete == OnComplete.OptInOC + assert result.transaction.application_call + assert result.transaction.application_call.on_complete == OnComplete.OptInOC assert app_client.app_id > 0 assert app_client.app_address == get_application_address(app_client.app_id) assert isinstance(result.confirmation, dict) @@ -221,8 +221,8 @@ def test_deploy_app_update_abi(factory: AppFactory) -> None: assert updated_app.created_round == created_app.created_round assert updated_app.updated_round != updated_app.created_round assert updated_app.updated_round == updated_app.confirmation["confirmed-round"] # type: ignore[call-overload] - assert isinstance(updated_app.transaction, ApplicationCallTxn) - assert updated_app.transaction.on_complete == OnComplete.UpdateApplicationOC + assert updated_app.transaction.application_call + assert updated_app.transaction.application_call.on_complete == OnComplete.UpdateApplicationOC assert updated_app.return_value == "args_io" @@ -247,10 +247,10 @@ def test_deploy_app_replace(factory: AppFactory) -> None: assert replaced_app.confirmation is not None assert replaced_app.delete_result is not None assert replaced_app.delete_result.confirmation is not None - assert len(replaced_app.transactions) == 2 # noqa: PLR2004 - assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) - assert replaced_app.delete_result.transaction.index == created_app.app_id - assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC + assert len(replaced_app.transactions) == 2 + assert replaced_app.delete_result.transaction.application_call + assert replaced_app.delete_result.transaction.application_call.index == created_app.app_id + assert replaced_app.delete_result.transaction.application_call.on_complete == OnComplete.DeleteApplicationOC def test_deploy_app_replace_abi(factory: AppFactory) -> None: @@ -277,10 +277,10 @@ def test_deploy_app_replace_abi(factory: AppFactory) -> None: assert replaced_app.confirmation is not None assert replaced_app.delete_result is not None assert replaced_app.delete_result.confirmation is not None - assert len(replaced_app.transactions) == 2 # noqa: PLR2004 - assert isinstance(replaced_app.delete_result.transaction, ApplicationCallTxn) - assert replaced_app.delete_result.transaction.index == created_app.app_id - assert replaced_app.delete_result.transaction.on_complete == OnComplete.DeleteApplicationOC + assert len(replaced_app.transactions) == 2 + assert replaced_app.delete_result.transaction.application_call + assert replaced_app.delete_result.transaction.application_call.index == created_app.app_id + assert replaced_app.delete_result.transaction.application_call.on_complete == OnComplete.DeleteApplicationOC assert replaced_app.return_value == "arg_io" assert replaced_app.delete_return_value == "arg2_io" @@ -428,9 +428,9 @@ def test_export_import_sourcemaps( error.trace().strip() == "// error\n\terror_7:\n\tproto 0 0\n\tintc_0 // 0\n\t// Deliberate error\n\tassert\t\t<-- Error\n\tretsub\n\t\n\t// create\n\tcreate_8:" # noqa: E501 ) - assert error.pc == 885 # noqa: PLR2004 + assert error.pc == 885 assert error.message == "assert failed pc=885" - assert len(error.transaction_id) == 52 # noqa: PLR2004 + assert len(error.transaction_id) == 52 def test_arc56_error_messages_with_dynamic_template_vars_cblock_offset( diff --git a/tests/clients/test_algorand_client.py b/tests/clients/algorand_client/__init__.py similarity index 100% rename from tests/clients/test_algorand_client.py rename to tests/clients/algorand_client/__init__.py diff --git a/tests/clients/algorand_client/test_transfer.py b/tests/clients/algorand_client/test_transfer.py new file mode 100644 index 0000000..a7637f5 --- /dev/null +++ b/tests/clients/algorand_client/test_transfer.py @@ -0,0 +1,427 @@ +import httpx +import pytest +from pytest_httpx._httpx_mock import HTTPXMock + +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.clients.dispenser_api_client import DispenserApiConfig, TestNetDispenserApiClient +from algokit_utils.models.account import Account +from algokit_utils.models.amount import AlgoAmount +from algokit_utils.transactions.transaction_composer import ( + AssetOptInParams, + AssetTransferParams, + PaymentParams, +) +from tests.conftest import generate_test_asset + + +@pytest.fixture +def algorand() -> AlgorandClient: + return AlgorandClient.default_local_net() + + +@pytest.fixture +def funded_account(algorand: AlgorandClient) -> Account: + new_account = algorand.account.random() + dispenser = algorand.account.localnet_dispenser() + algorand.account.ensure_funded( + new_account, dispenser, AlgoAmount.from_algos(100), min_funding_increment=AlgoAmount.from_algos(1) + ) + algorand.set_signer(sender=new_account.address, signer=new_account.signer) + return new_account + + +def test_transfer_algo_is_sent_and_waited_for(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + result = algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(5), + note=b"Transfer 5 Algos", + ) + ) + + account_info = algorand.account.get_information(second_account) + + assert result.transaction.payment + assert result.transaction.payment.amt == 5_000_000 + + assert result.transaction.payment.sender == funded_account.address == result.confirmation["txn"]["txn"]["snd"] # type: ignore # noqa: PGH003 + assert account_info["amount"] == 5_000_000 + + +def test_transfer_algo_respects_string_lease(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(1), + lease=b"test", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(2), + lease=b"test", + ) + ) + + +def test_transfer_algo_respects_byte_array_lease(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(1), + lease=b"\x01\x02\x03\x04", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=second_account.address, + amount=AlgoAmount.from_algos(2), + lease=b"\x01\x02\x03\x04", + ) + ) + + +def test_transfer_asa_respects_lease(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=1, + lease=b"test", + ) + ) + + with pytest.raises(Exception, match="overlapping lease"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=2, + lease=b"test", + ) + ) + + +def test_transfer_asa_receiver_not_opted_in( + algorand: AlgorandClient, + funded_account: Account, +) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + + with pytest.raises(Exception, match="receiver error: must optin"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=1, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + +def test_transfer_asa_sender_not_opted_in(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + with pytest.raises(Exception, match=f"asset {test_asset_id} missing from {second_account.address}"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=second_account.address, + receiver=funded_account.address, + asset_id=test_asset_id, + amount=1, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + +def test_transfer_asa_asset_doesnt_exist(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + with pytest.raises(Exception, match=f"asset 123123 missing from {funded_account.address}"): + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=123123, + amount=5, + note=b"Transfer asset with wrong id", + ) + ) + + +def test_transfer_asa_to_another_account(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + second_account_info = algorand.asset.get_account_information(second_account, test_asset_id) + assert second_account_info.balance == 5 + + test_account_info = algorand.asset.get_account_information(funded_account, test_asset_id) + assert test_account_info.balance == 95 + + +def test_transfer_asa_from_revocation_target(algorand: AlgorandClient, funded_account: Account) -> None: + test_asset_id = generate_test_asset(algorand, funded_account, 100) + second_account = algorand.account.random() + clawback_account = algorand.account.random() + + algorand.account.ensure_funded( + account_to_fund=second_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + algorand.account.ensure_funded( + account_to_fund=clawback_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=second_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_opt_in( + AssetOptInParams( + sender=clawback_account.address, + asset_id=test_asset_id, + ) + ) + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=clawback_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + ) + ) + + clawback_from_info = algorand.asset.get_account_information(clawback_account, test_asset_id) + assert clawback_from_info.balance == 5 + + algorand.send.asset_transfer( + AssetTransferParams( + sender=funded_account.address, + receiver=second_account.address, + asset_id=test_asset_id, + amount=5, + note=b"Transfer 5 assets with id %d" % test_asset_id, + clawback_target=clawback_account.address, + ) + ) + + second_account_info = algorand.asset.get_account_information(second_account, test_asset_id) + assert second_account_info.balance == 5 + + clawback_account_info = algorand.asset.get_account_information(clawback_account, test_asset_id) + assert clawback_account_info.balance == 0 + + test_account_info = algorand.asset.get_account_information(funded_account, test_asset_id) + assert test_account_info.balance == 95 + + +MINIMUM_BALANCE = AlgoAmount.from_micro_algos( + 100_000 +) # see https://developer.algorand.org/docs/get-details/accounts/#minimum-balance + + +def test_ensure_funded(algorand: AlgorandClient, funded_account: Account) -> None: + test_account = algorand.account.random() + response = algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_algos(1), + ) + assert response is not None + + to_account_info = algorand.account.get_information(test_account) + assert isinstance(to_account_info, dict) + actual_amount = to_account_info.get("amount") + assert actual_amount == MINIMUM_BALANCE + AlgoAmount.from_algos(1) + + +def test_ensure_funded_uses_dispenser_by_default( + algorand: AlgorandClient, +) -> None: + second_account = algorand.account.random() + dispenser = algorand.account.dispenser_from_environment() + + result = algorand.account.ensure_funded_from_environment( + account_to_fund=second_account, + min_spending_balance=AlgoAmount.from_algos(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + + assert result is not None + assert result.transaction.payment is not None + assert result.transaction.payment.sender == dispenser.address + + account_info = algorand.account.get_information(second_account) + assert account_info["amount"] == MINIMUM_BALANCE + AlgoAmount.from_algos(1) + + +def test_ensure_funded_respects_minimum_funding_increment(algorand: AlgorandClient, funded_account: Account) -> None: + test_account = algorand.account.random() + response = algorand.account.ensure_funded( + account_to_fund=test_account, + dispenser_account=funded_account, + min_spending_balance=AlgoAmount.from_micro_algo(1), + min_funding_increment=AlgoAmount.from_algos(1), + ) + assert response is not None + + to_account_info = algorand.account.get_information(test_account) + assert isinstance(to_account_info, dict) + actual_amount = to_account_info.get("amount") + assert actual_amount == AlgoAmount.from_algos(1) + + +def test_ensure_funded_testnet_api_success(monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock) -> None: + algorand = AlgorandClient.test_net() + account_to_fund = algorand.account.random() + monkeypatch.setenv( + "ALGOKIT_DISPENSER_ACCESS_TOKEN", + "dummy", + ) + httpx_mock.add_response( + url=f"{DispenserApiConfig.BASE_URL}/fund/0", + method="POST", + json={"amount": 1, "txID": "dummy_tx_id"}, + ) + + result = algorand.account.ensure_funded_from_testnet_dispenser_api( + account_to_fund=account_to_fund, + dispenser_client=TestNetDispenserApiClient(), + min_spending_balance=AlgoAmount.from_micro_algo(1), + ) + assert result is not None + assert result.transaction_id == "dummy_tx_id" + assert result.amount_funded == AlgoAmount.from_micro_algo(1) + + +def test_ensure_funded_testnet_api_bad_response(monkeypatch: pytest.MonkeyPatch, httpx_mock: HTTPXMock) -> None: + algorand = AlgorandClient.test_net() + account_to_fund = algorand.account.random() + monkeypatch.setenv( + "ALGOKIT_DISPENSER_ACCESS_TOKEN", + "dummy", + ) + httpx_mock.add_exception( + httpx.HTTPStatusError( + "Limit exceeded", + request=httpx.Request("POST", f"{DispenserApiConfig.BASE_URL}/fund"), + response=httpx.Response( + 400, + request=httpx.Request("POST", f"{DispenserApiConfig.BASE_URL}/fund"), + json={ + "code": "fund_limit_exceeded", + "limit": 10_000_000, + "resetsAt": "2023-09-19T10:07:34.024Z", + }, + ), + ), + url=f"{DispenserApiConfig.BASE_URL}/fund/0", + method="POST", + ) + + with pytest.raises(Exception, match="fund_limit_exceeded"): + algorand.account.ensure_funded_from_testnet_dispenser_api( + account_to_fund=account_to_fund, + dispenser_client=TestNetDispenserApiClient(), + min_spending_balance=AlgoAmount.from_micro_algo(1), + ) + + +def test_rekey_works(algorand: AlgorandClient, funded_account: Account) -> None: + second_account = algorand.account.random() + + algorand.account.rekey_account(funded_account, second_account, note=b"rekey") + + # This will throw if the rekey wasn't successful + algorand.send.payment( + PaymentParams( + sender=funded_account.address, + receiver=funded_account.address, + amount=AlgoAmount.from_micro_algos(1), + signer=second_account.signer, + ) + ) diff --git a/tests/conftest.py b/tests/conftest.py index 231828d..9499465 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from uuid import uuid4 -import algosdk.transaction import pytest from dotenv import load_dotenv @@ -20,6 +19,8 @@ ensure_funded, replace_template_variables, ) +from algokit_utils.clients.algorand_client import AlgorandClient +from algokit_utils.transactions.transaction_composer import AssetCreateParams if TYPE_CHECKING: from algosdk.v2client.algod import AlgodClient @@ -120,42 +121,30 @@ def is_opted_in(client_fixture: ApplicationClient) -> bool: return any(x for x in apps_local_state if x["id"] == client_fixture.app_id) -def generate_test_asset(algod_client: "AlgodClient", sender: Account, total: int | None) -> int: +def generate_test_asset(algorand: AlgorandClient, sender: Account, total: int | None) -> int: if total is None: total = math.floor(random.random() * 100) + 20 decimals = 0 asset_name = f"ASA ${math.floor(random.random() * 100) + 1}_${math.floor(random.random() * 100) + 1}_${total}" - params = algod_client.suggested_params() - - txn = algosdk.transaction.AssetConfigTxn( - sender=sender.address, - sp=params, - total=total * 10**decimals, - decimals=decimals, - default_frozen=False, - unit_name="", - asset_name=asset_name, - manager=sender.address, - reserve=sender.address, - freeze=sender.address, - clawback=sender.address, - url="https://path/to/my/asset/details", - metadata_hash=None, - note=None, - lease=None, - rekey_to=None, + create_result = algorand.send.asset_create( + AssetCreateParams( + sender=sender.address, + total=total, + decimals=decimals, + default_frozen=False, + unit_name="CFG", + asset_name=asset_name, + url="https://example.com", + manager=sender.address, + reserve=sender.address, + freeze=sender.address, + clawback=sender.address, + ) ) - signed_transaction = txn.sign(sender.private_key) - algod_client.send_transaction(signed_transaction) - ptx = algod_client.pending_transaction_info(txn.get_txid()) - - if isinstance(ptx, dict) and "asset-index" in ptx and isinstance(ptx["asset-index"], int): - return ptx["asset-index"] - else: - raise ValueError("Unexpected response from pending_transaction_info") + return int(create_result.confirmation["asset-index"]) # type: ignore[call-overload] def assure_funds(algod_client: "AlgodClient", account: Account) -> None: diff --git a/tests/transactions/test_transaction_sender.py b/tests/transactions/test_transaction_sender.py index 52df797..def636f 100644 --- a/tests/transactions/test_transaction_sender.py +++ b/tests/transactions/test_transaction_sender.py @@ -1,22 +1,10 @@ from pathlib import Path -from typing import cast from unittest.mock import MagicMock, patch import algosdk import pytest -from algosdk.transaction import ( - ApplicationCreateTxn, - AssetConfigTxn, - AssetCreateTxn, - AssetDestroyTxn, - AssetFreezeTxn, - AssetTransferTxn, - PaymentTxn, -) -from algokit_utils import ( - Account, -) +from algokit_utils import Account from algokit_utils._legacy_v2.application_specification import ApplicationSpecification from algokit_utils.applications.app_manager import AppManager from algokit_utils.assets.asset_manager import AssetManager @@ -133,7 +121,8 @@ def test_payment(transaction_sender: AlgorandClientTransactionSender, sender: Ac assert len(result.tx_ids) == 1 assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - txn = cast(PaymentTxn, result.transaction) + txn = result.transaction.payment + assert txn assert txn.sender == sender.address assert txn.receiver == receiver.address assert txn.amt == amount.micro_algos @@ -154,7 +143,8 @@ def test_asset_create(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_create(params) assert len(result.tx_ids) == 1 assert result.confirmations[-1]["confirmed-round"] > 0 # type: ignore[call-overload] - txn = cast(AssetCreateTxn, result.transaction) + txn = result.transaction.asset_config + assert txn assert txn.sender == sender.address assert txn.total == total assert txn.decimals == 0 @@ -189,10 +179,12 @@ def test_asset_config(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_config(config_params) assert len(result.tx_ids) == 1 - assert isinstance(result.transaction, AssetConfigTxn) - assert result.transaction.sender == sender.address - assert result.transaction.index == asset_id - assert result.transaction.manager == receiver.address + assert result.transaction.asset_config + txn = result.transaction.asset_config + assert txn + assert txn.sender == sender.address + assert txn.index == asset_id + assert txn.manager == receiver.address def test_asset_freeze( @@ -225,7 +217,9 @@ def test_asset_freeze( result = transaction_sender.asset_freeze(freeze_params) assert len(result.tx_ids) == 1 - txn = cast(AssetFreezeTxn, result.transaction) + assert result.transaction.asset_freeze + txn = result.transaction.asset_freeze + assert txn assert txn.sender == sender.address assert txn.index == asset_id assert txn.target == sender.address @@ -256,7 +250,8 @@ def test_asset_destroy(transaction_sender: AlgorandClientTransactionSender, send result = transaction_sender.asset_destroy(destroy_params) assert len(result.tx_ids) == 1 - txn = cast(AssetDestroyTxn, result.transaction) + txn = result.transaction.asset_config + assert txn assert txn.sender == sender.address assert txn.index == asset_id @@ -298,7 +293,8 @@ def test_asset_transfer( result = transaction_sender.asset_transfer(transfer_params) assert len(result.tx_ids) == 1 - txn = cast(AssetTransferTxn, result.transaction) + txn = result.transaction.asset_transfer + assert txn assert txn.sender == sender.address assert txn.index == asset_id assert txn.receiver == receiver.address @@ -329,7 +325,8 @@ def test_asset_opt_in(transaction_sender: AlgorandClientTransactionSender, sende result = transaction_sender.asset_opt_in(opt_in_params) assert len(result.tx_ids) == 1 - txn = cast(AssetTransferTxn, result.transaction) + assert result.transaction.asset_transfer + txn = result.transaction.asset_transfer assert txn.sender == receiver.address assert txn.index == asset_id assert txn.amount == 0 @@ -369,7 +366,8 @@ def test_asset_opt_out(transaction_sender: AlgorandClientTransactionSender, send ) result = transaction_sender.asset_opt_out(params=opt_out_params) - txn = cast(AssetTransferTxn, result.transaction) + assert result.transaction.asset_transfer + txn = result.transaction.asset_transfer assert txn.sender == receiver.address assert txn.index == asset_id assert txn.amount == 0 @@ -390,7 +388,9 @@ def test_app_create(transaction_sender: AlgorandClientTransactionSender, sender: result = transaction_sender.app_create(params) assert result.app_id > 0 assert result.app_address - txn = cast(ApplicationCreateTxn, result.transaction) + + assert result.transaction.application_call + txn = result.transaction.application_call assert txn.sender == sender.address assert txn.approval_program == b"\x06\x81\x01" assert txn.clear_program == b"\x06\x81\x01"