algokit-utils 2.4.0__py3-none-any.whl → 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of algokit-utils might be problematic. Click here for more details.

Files changed (70) hide show
  1. algokit_utils/__init__.py +23 -181
  2. algokit_utils/_debugging.py +89 -45
  3. algokit_utils/_legacy_v2/__init__.py +177 -0
  4. algokit_utils/{_ensure_funded.py → _legacy_v2/_ensure_funded.py} +21 -24
  5. algokit_utils/{_transfer.py → _legacy_v2/_transfer.py} +26 -23
  6. algokit_utils/_legacy_v2/account.py +203 -0
  7. algokit_utils/_legacy_v2/application_client.py +1472 -0
  8. algokit_utils/_legacy_v2/application_specification.py +21 -0
  9. algokit_utils/_legacy_v2/asset.py +168 -0
  10. algokit_utils/_legacy_v2/common.py +28 -0
  11. algokit_utils/_legacy_v2/deploy.py +822 -0
  12. algokit_utils/_legacy_v2/logic_error.py +14 -0
  13. algokit_utils/{models.py → _legacy_v2/models.py} +16 -45
  14. algokit_utils/_legacy_v2/network_clients.py +144 -0
  15. algokit_utils/account.py +12 -183
  16. algokit_utils/accounts/__init__.py +2 -0
  17. algokit_utils/accounts/account_manager.py +912 -0
  18. algokit_utils/accounts/kmd_account_manager.py +161 -0
  19. algokit_utils/algorand.py +359 -0
  20. algokit_utils/application_client.py +9 -1447
  21. algokit_utils/application_specification.py +39 -197
  22. algokit_utils/applications/__init__.py +7 -0
  23. algokit_utils/applications/abi.py +275 -0
  24. algokit_utils/applications/app_client.py +2108 -0
  25. algokit_utils/applications/app_deployer.py +725 -0
  26. algokit_utils/applications/app_factory.py +1134 -0
  27. algokit_utils/applications/app_manager.py +578 -0
  28. algokit_utils/applications/app_spec/__init__.py +2 -0
  29. algokit_utils/applications/app_spec/arc32.py +207 -0
  30. algokit_utils/applications/app_spec/arc56.py +989 -0
  31. algokit_utils/applications/enums.py +40 -0
  32. algokit_utils/asset.py +32 -168
  33. algokit_utils/assets/__init__.py +1 -0
  34. algokit_utils/assets/asset_manager.py +336 -0
  35. algokit_utils/beta/_utils.py +36 -0
  36. algokit_utils/beta/account_manager.py +4 -195
  37. algokit_utils/beta/algorand_client.py +4 -314
  38. algokit_utils/beta/client_manager.py +5 -74
  39. algokit_utils/beta/composer.py +5 -712
  40. algokit_utils/clients/__init__.py +2 -0
  41. algokit_utils/clients/client_manager.py +738 -0
  42. algokit_utils/clients/dispenser_api_client.py +224 -0
  43. algokit_utils/common.py +8 -26
  44. algokit_utils/config.py +76 -29
  45. algokit_utils/deploy.py +7 -894
  46. algokit_utils/dispenser_api.py +8 -176
  47. algokit_utils/errors/__init__.py +1 -0
  48. algokit_utils/errors/logic_error.py +121 -0
  49. algokit_utils/logic_error.py +7 -82
  50. algokit_utils/models/__init__.py +8 -0
  51. algokit_utils/models/account.py +217 -0
  52. algokit_utils/models/amount.py +200 -0
  53. algokit_utils/models/application.py +91 -0
  54. algokit_utils/models/network.py +29 -0
  55. algokit_utils/models/simulate.py +11 -0
  56. algokit_utils/models/state.py +68 -0
  57. algokit_utils/models/transaction.py +100 -0
  58. algokit_utils/network_clients.py +7 -128
  59. algokit_utils/protocols/__init__.py +2 -0
  60. algokit_utils/protocols/account.py +22 -0
  61. algokit_utils/protocols/typed_clients.py +108 -0
  62. algokit_utils/transactions/__init__.py +3 -0
  63. algokit_utils/transactions/transaction_composer.py +2499 -0
  64. algokit_utils/transactions/transaction_creator.py +688 -0
  65. algokit_utils/transactions/transaction_sender.py +1219 -0
  66. {algokit_utils-2.4.0.dist-info → algokit_utils-3.0.0.dist-info}/METADATA +11 -7
  67. algokit_utils-3.0.0.dist-info/RECORD +70 -0
  68. {algokit_utils-2.4.0.dist-info → algokit_utils-3.0.0.dist-info}/WHEEL +1 -1
  69. algokit_utils-2.4.0.dist-info/RECORD +0 -24
  70. {algokit_utils-2.4.0.dist-info → algokit_utils-3.0.0.dist-info}/LICENSE +0 -0
@@ -0,0 +1,822 @@
1
+ import base64
2
+ import dataclasses
3
+ import json
4
+ import logging
5
+ import re
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from typing import TYPE_CHECKING, TypeAlias, TypedDict
8
+
9
+ import algosdk
10
+ from algosdk import transaction
11
+ from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionSigner
12
+ from algosdk.transaction import StateSchema
13
+ from typing_extensions import deprecated
14
+
15
+ from algokit_utils._legacy_v2.application_specification import (
16
+ ApplicationSpecification,
17
+ CallConfig,
18
+ MethodConfigDict,
19
+ OnCompleteActionName,
20
+ )
21
+ from algokit_utils._legacy_v2.models import (
22
+ ABIArgsDict,
23
+ ABIMethod,
24
+ Account,
25
+ CreateCallParameters,
26
+ TransactionResponse,
27
+ )
28
+ from algokit_utils.applications.app_manager import AppManager
29
+ from algokit_utils.applications.enums import OnSchemaBreak, OnUpdate, OperationPerformed
30
+
31
+ if TYPE_CHECKING:
32
+ from algosdk.v2client.algod import AlgodClient
33
+ from algosdk.v2client.indexer import IndexerClient
34
+
35
+ from algokit_utils._legacy_v2.application_client import ApplicationClient
36
+
37
+
38
+ __all__ = [
39
+ "DELETABLE_TEMPLATE_NAME",
40
+ "NOTE_PREFIX",
41
+ "UPDATABLE_TEMPLATE_NAME",
42
+ "ABICallArgs",
43
+ "ABICallArgsDict",
44
+ "ABICreateCallArgs",
45
+ "ABICreateCallArgsDict",
46
+ "AppDeployMetaData",
47
+ "AppLookup",
48
+ "AppMetaData",
49
+ "AppReference",
50
+ "DeployCallArgs",
51
+ "DeployCallArgsDict",
52
+ "DeployCreateCallArgs",
53
+ "DeployCreateCallArgsDict",
54
+ "DeployResponse",
55
+ "Deployer",
56
+ "DeploymentFailedError",
57
+ "OnSchemaBreak",
58
+ "OnUpdate",
59
+ "OperationPerformed",
60
+ "TemplateValueDict",
61
+ "TemplateValueMapping",
62
+ "get_app_id_from_tx_id",
63
+ "get_creator_apps",
64
+ "replace_template_variables",
65
+ ]
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+ DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT = 1000
70
+ _UPDATABLE = "UPDATABLE"
71
+ _DELETABLE = "DELETABLE"
72
+ UPDATABLE_TEMPLATE_NAME = f"TMPL_{_UPDATABLE}"
73
+ """Template variable name used to control if a smart contract is updatable or not at deployment"""
74
+ DELETABLE_TEMPLATE_NAME = f"TMPL_{_DELETABLE}"
75
+ """Template variable name used to control if a smart contract is deletable or not at deployment"""
76
+ _TOKEN_PATTERN = re.compile(r"TMPL_[A-Z_]+")
77
+ TemplateValue: TypeAlias = int | str | bytes
78
+ TemplateValueDict: TypeAlias = dict[str, TemplateValue]
79
+ """Dictionary of `dict[str, int | str | bytes]` representing template variable names and values"""
80
+ TemplateValueMapping: TypeAlias = Mapping[str, TemplateValue]
81
+ """Mapping of `str` to `int | str | bytes` representing template variable names and values"""
82
+
83
+ NOTE_PREFIX = "ALGOKIT_DEPLOYER:j"
84
+ """ARC-0002 compliant note prefix for algokit_utils deployed applications"""
85
+ # This prefix is also used to filter for parsable transaction notes in get_creator_apps.
86
+ # However, as the note is base64 encoded first we need to consider it's base64 representation.
87
+ # When base64 encoding bytes, 3 bytes are stored in every 4 characters.
88
+ # So then we don't need to worry about the padding/changing characters of the prefix if it was followed by
89
+ # additional characters, assert the NOTE_PREFIX length is a multiple of 3.
90
+ assert len(NOTE_PREFIX) % 3 == 0
91
+
92
+
93
+ class DeploymentFailedError(Exception):
94
+ pass
95
+
96
+
97
+ @dataclasses.dataclass
98
+ class AppReference:
99
+ """Information about an Algorand app"""
100
+
101
+ app_id: int
102
+ app_address: str
103
+
104
+
105
+ @dataclasses.dataclass
106
+ class AppDeployMetaData:
107
+ """Metadata about an application stored in a transaction note during creation.
108
+
109
+ The note is serialized as JSON and prefixed with {py:data}`NOTE_PREFIX` and stored in the transaction note field
110
+ as part of {py:meth}`ApplicationClient.deploy`
111
+ """
112
+
113
+ name: str
114
+ version: str
115
+ deletable: bool | None
116
+ updatable: bool | None
117
+
118
+ @staticmethod
119
+ def from_json(value: str) -> "AppDeployMetaData":
120
+ json_value: dict = json.loads(value)
121
+ json_value.setdefault("deletable", None)
122
+ json_value.setdefault("updatable", None)
123
+ return AppDeployMetaData(**json_value)
124
+
125
+ @classmethod
126
+ def from_b64(cls: type["AppDeployMetaData"], b64: str) -> "AppDeployMetaData":
127
+ return cls.decode(base64.b64decode(b64))
128
+
129
+ @classmethod
130
+ def decode(cls: type["AppDeployMetaData"], value: bytes) -> "AppDeployMetaData":
131
+ note = value.decode("utf-8")
132
+ assert note.startswith(NOTE_PREFIX)
133
+ return cls.from_json(note[len(NOTE_PREFIX) :])
134
+
135
+ def encode(self) -> bytes:
136
+ json_str = json.dumps(self.__dict__)
137
+ return f"{NOTE_PREFIX}{json_str}".encode()
138
+
139
+
140
+ @dataclasses.dataclass
141
+ class AppMetaData(AppReference, AppDeployMetaData):
142
+ """Metadata about a deployed app"""
143
+
144
+ created_round: int
145
+ updated_round: int
146
+ created_metadata: AppDeployMetaData
147
+ deleted: bool
148
+
149
+
150
+ @dataclasses.dataclass
151
+ class AppLookup:
152
+ """Cache of {py:class}`AppMetaData` for a specific `creator`
153
+
154
+ Can be used as an argument to {py:class}`ApplicationClient` to reduce the number of calls when deploying multiple
155
+ apps or discovering multiple app_ids
156
+ """
157
+
158
+ creator: str
159
+ apps: dict[str, AppMetaData] = dataclasses.field(default_factory=dict)
160
+
161
+
162
+ def _sort_by_round(txn: dict) -> tuple[int, int]:
163
+ confirmed = txn["confirmed-round"]
164
+ offset = txn["intra-round-offset"]
165
+ return confirmed, offset
166
+
167
+
168
+ def _parse_note(metadata_b64: str | None) -> AppDeployMetaData | None:
169
+ if not metadata_b64:
170
+ return None
171
+ # noinspection PyBroadException
172
+ try:
173
+ return AppDeployMetaData.from_b64(metadata_b64)
174
+ except Exception:
175
+ return None
176
+
177
+
178
+ @deprecated("Use algorand.app_deployer.get_creator_apps_by_name() instead. ")
179
+ def get_creator_apps(indexer: "IndexerClient", creator_account: Account | str) -> AppLookup:
180
+ """Returns a mapping of Application names to {py:class}`AppMetaData` for all Applications created by specified
181
+ creator that have a transaction note containing {py:class}`AppDeployMetaData`
182
+ """
183
+ apps: dict[str, AppMetaData] = {}
184
+
185
+ creator_address = creator_account if isinstance(creator_account, str) else creator_account.address
186
+ token = None
187
+ # TODO: paginated indexer call instead of N + 1 calls
188
+ while True:
189
+ response = indexer.lookup_account_application_by_creator(
190
+ creator_address, limit=DEFAULT_INDEXER_MAX_API_RESOURCES_PER_ACCOUNT, next_page=token
191
+ )
192
+ if "message" in response: # an error occurred
193
+ raise Exception(f"Error querying applications for {creator_address}: {response}")
194
+ for app in response["applications"]:
195
+ app_id = app["id"]
196
+ app_created_at_round = app["created-at-round"]
197
+ app_deleted = app.get("deleted", False)
198
+ search_transactions_response = indexer.search_transactions(
199
+ min_round=app_created_at_round,
200
+ txn_type="appl",
201
+ application_id=app_id,
202
+ address=creator_address,
203
+ address_role="sender",
204
+ note_prefix=NOTE_PREFIX.encode("utf-8"),
205
+ )
206
+ transactions: list[dict] = search_transactions_response["transactions"]
207
+ if not transactions:
208
+ continue
209
+
210
+ created_transaction = next(
211
+ t
212
+ for t in transactions
213
+ if t["application-transaction"]["application-id"] == 0 and t["sender"] == creator_address
214
+ )
215
+
216
+ transactions.sort(key=_sort_by_round, reverse=True)
217
+ latest_transaction = transactions[0]
218
+ app_updated_at_round = latest_transaction["confirmed-round"]
219
+
220
+ create_metadata = _parse_note(created_transaction.get("note"))
221
+ update_metadata = _parse_note(latest_transaction.get("note"))
222
+
223
+ if create_metadata and create_metadata.name:
224
+ apps[create_metadata.name] = AppMetaData(
225
+ app_id=app_id,
226
+ app_address=algosdk.logic.get_application_address(app_id),
227
+ created_metadata=create_metadata,
228
+ created_round=app_created_at_round,
229
+ **(update_metadata or create_metadata).__dict__,
230
+ updated_round=app_updated_at_round,
231
+ deleted=app_deleted,
232
+ )
233
+
234
+ token = response.get("next-token")
235
+ if not token:
236
+ break
237
+
238
+ return AppLookup(creator_address, apps)
239
+
240
+
241
+ def _state_schema(schema: dict[str, int]) -> StateSchema:
242
+ return StateSchema(schema.get("num-uint", 0), schema.get("num-byte-slice", 0))
243
+
244
+
245
+ def _describe_schema_breaks(prefix: str, from_schema: StateSchema, to_schema: StateSchema) -> Iterable[str]:
246
+ if to_schema.num_uints > from_schema.num_uints:
247
+ yield f"{prefix} uints increased from {from_schema.num_uints} to {to_schema.num_uints}"
248
+ if to_schema.num_byte_slices > from_schema.num_byte_slices:
249
+ yield f"{prefix} byte slices increased from {from_schema.num_byte_slices} to {to_schema.num_byte_slices}"
250
+
251
+
252
+ @dataclasses.dataclass(kw_only=True)
253
+ class AppChanges:
254
+ app_updated: bool
255
+ schema_breaking_change: bool
256
+ schema_change_description: str | None
257
+
258
+
259
+ @deprecated("The algokit_utils.AppDeployer now handles checking for app changes implicitly as part of `deploy` method")
260
+ def check_for_app_changes(
261
+ algod_client: "AlgodClient",
262
+ *,
263
+ new_approval: bytes,
264
+ new_clear: bytes,
265
+ new_global_schema: StateSchema,
266
+ new_local_schema: StateSchema,
267
+ app_id: int,
268
+ ) -> AppChanges:
269
+ application_info = algod_client.application_info(app_id)
270
+ assert isinstance(application_info, dict)
271
+ application_create_params = application_info["params"]
272
+
273
+ current_approval = base64.b64decode(application_create_params["approval-program"])
274
+ current_clear = base64.b64decode(application_create_params["clear-state-program"])
275
+ current_global_schema = _state_schema(application_create_params["global-state-schema"])
276
+ current_local_schema = _state_schema(application_create_params["local-state-schema"])
277
+
278
+ app_updated = current_approval != new_approval or current_clear != new_clear
279
+
280
+ schema_changes: list[str] = []
281
+ schema_changes.extend(_describe_schema_breaks("Global", current_global_schema, new_global_schema))
282
+ schema_changes.extend(_describe_schema_breaks("Local", current_local_schema, new_local_schema))
283
+
284
+ return AppChanges(
285
+ app_updated=app_updated,
286
+ schema_breaking_change=bool(schema_changes),
287
+ schema_change_description=", ".join(schema_changes),
288
+ )
289
+
290
+
291
+ def _is_valid_token_character(char: str) -> bool:
292
+ return char.isalnum() or char == "_"
293
+
294
+
295
+ def add_deploy_template_variables(
296
+ template_values: TemplateValueDict, allow_update: bool | None, allow_delete: bool | None
297
+ ) -> None:
298
+ if allow_update is not None:
299
+ template_values[_UPDATABLE] = int(allow_update)
300
+ if allow_delete is not None:
301
+ template_values[_DELETABLE] = int(allow_delete)
302
+
303
+
304
+ def _find_unquoted_string(line: str, token: str, start: int = 0, end: int = -1) -> int | None:
305
+ """Find the first string within a line of TEAL. Only matches outside of quotes and base64 are returned.
306
+ Returns None if not found"""
307
+
308
+ if end < 0:
309
+ end = len(line)
310
+ idx = start
311
+ in_quotes = in_base64 = False
312
+ while idx < end:
313
+ current_char = line[idx]
314
+ match current_char:
315
+ # enter base64
316
+ case " " | "(" if not in_quotes and _last_token_base64(line, idx):
317
+ in_base64 = True
318
+ # exit base64
319
+ case " " | ")" if not in_quotes and in_base64:
320
+ in_base64 = False
321
+ # escaped char
322
+ case "\\" if in_quotes:
323
+ # skip next character
324
+ idx += 1
325
+ # quote boundary
326
+ case '"':
327
+ in_quotes = not in_quotes
328
+ # can test for match
329
+ case _ if not in_quotes and not in_base64 and line.startswith(token, idx):
330
+ # only match if not in quotes and string matches
331
+ return idx
332
+ idx += 1
333
+ return None
334
+
335
+
336
+ def _last_token_base64(line: str, idx: int) -> bool:
337
+ try:
338
+ *_, last = line[:idx].split()
339
+ except ValueError:
340
+ return False
341
+ return last in ("base64", "b64")
342
+
343
+
344
+ def _find_template_token(line: str, token: str, start: int = 0, end: int = -1) -> int | None:
345
+ """Find the first template token within a line of TEAL. Only matches outside of quotes are returned.
346
+ Only full token matches are returned, i.e. TMPL_STR will not match against TMPL_STRING
347
+ Returns None if not found"""
348
+ if end < 0:
349
+ end = len(line)
350
+
351
+ idx = start
352
+ while idx < end:
353
+ token_idx = _find_unquoted_string(line, token, idx, end)
354
+ if token_idx is None:
355
+ break
356
+ trailing_idx = token_idx + len(token)
357
+ if (token_idx == 0 or not _is_valid_token_character(line[token_idx - 1])) and ( # word boundary at start
358
+ trailing_idx >= len(line) or not _is_valid_token_character(line[trailing_idx]) # word boundary at end
359
+ ):
360
+ return token_idx
361
+ idx = trailing_idx
362
+ return None
363
+
364
+
365
+ def _strip_comment(line: str) -> str:
366
+ comment_idx = _find_unquoted_string(line, "//")
367
+ if comment_idx is None:
368
+ return line
369
+ return line[:comment_idx].rstrip()
370
+
371
+
372
+ def strip_comments(program: str) -> str:
373
+ return "\n".join(_strip_comment(line) for line in program.splitlines())
374
+
375
+
376
+ def _has_token(program_without_comments: str, token: str) -> bool:
377
+ for line in program_without_comments.splitlines():
378
+ token_idx = _find_template_token(line, token)
379
+ if token_idx is not None:
380
+ return True
381
+ return False
382
+
383
+
384
+ def _find_tokens(stripped_approval_program: str) -> list[str]:
385
+ return _TOKEN_PATTERN.findall(stripped_approval_program)
386
+
387
+
388
+ def check_template_variables(approval_program: str, template_values: TemplateValueDict) -> None:
389
+ approval_program = strip_comments(approval_program)
390
+ if _has_token(approval_program, UPDATABLE_TEMPLATE_NAME) and _UPDATABLE not in template_values:
391
+ raise DeploymentFailedError(
392
+ "allow_update must be specified if deploy time configuration of update is being used"
393
+ )
394
+ if _has_token(approval_program, DELETABLE_TEMPLATE_NAME) and _DELETABLE not in template_values:
395
+ raise DeploymentFailedError(
396
+ "allow_delete must be specified if deploy time configuration of delete is being used"
397
+ )
398
+ all_tokens = _find_tokens(approval_program)
399
+ missing_values = [token for token in all_tokens if token[len("TMPL_") :] not in template_values]
400
+ if missing_values:
401
+ raise DeploymentFailedError(f"The following template values were not provided: {', '.join(missing_values)}")
402
+
403
+ for template_variable_name in template_values:
404
+ tmpl_variable = f"TMPL_{template_variable_name}"
405
+ if not _has_token(approval_program, tmpl_variable):
406
+ if template_variable_name == _UPDATABLE:
407
+ raise DeploymentFailedError(
408
+ "allow_update must only be specified if deploy time configuration of update is being used"
409
+ )
410
+ if template_variable_name == _DELETABLE:
411
+ raise DeploymentFailedError(
412
+ "allow_delete must only be specified if deploy time configuration of delete is being used"
413
+ )
414
+ logger.warning(f"{tmpl_variable} not found in approval program, but variable was provided")
415
+
416
+
417
+ @deprecated("Use `AppManager.replace_template_variables` instead")
418
+ def replace_template_variables(program: str, template_values: TemplateValueMapping) -> str:
419
+ """Replaces `TMPL_*` variables in `program` with `template_values`
420
+
421
+ ```{note}
422
+ `template_values` keys should *NOT* be prefixed with `TMPL_`
423
+ ```
424
+ """
425
+ return AppManager.replace_template_variables(program, template_values)
426
+
427
+
428
+ def has_template_vars(app_spec: ApplicationSpecification) -> bool:
429
+ return "TMPL_" in strip_comments(app_spec.approval_program) or "TMPL_" in strip_comments(app_spec.clear_program)
430
+
431
+
432
+ def get_deploy_control(
433
+ app_spec: ApplicationSpecification, template_var: str, on_complete: transaction.OnComplete
434
+ ) -> bool | None:
435
+ if template_var not in strip_comments(app_spec.approval_program):
436
+ return None
437
+ return get_call_config(app_spec.bare_call_config, on_complete) != CallConfig.NEVER or any(
438
+ h for h in app_spec.hints.values() if get_call_config(h.call_config, on_complete) != CallConfig.NEVER
439
+ )
440
+
441
+
442
+ def get_call_config(method_config: MethodConfigDict, on_complete: transaction.OnComplete) -> CallConfig:
443
+ def get(key: OnCompleteActionName) -> CallConfig:
444
+ return method_config.get(key, CallConfig.NEVER)
445
+
446
+ match on_complete:
447
+ case transaction.OnComplete.NoOpOC:
448
+ return get("no_op")
449
+ case transaction.OnComplete.UpdateApplicationOC:
450
+ return get("update_application")
451
+ case transaction.OnComplete.DeleteApplicationOC:
452
+ return get("delete_application")
453
+ case transaction.OnComplete.OptInOC:
454
+ return get("opt_in")
455
+ case transaction.OnComplete.CloseOutOC:
456
+ return get("close_out")
457
+ case transaction.OnComplete.ClearStateOC:
458
+ return get("clear_state")
459
+
460
+
461
+ @dataclasses.dataclass(kw_only=True)
462
+ class DeployResponse:
463
+ """Describes the action taken during deployment, related transactions and the {py:class}`AppMetaData`"""
464
+
465
+ app: AppMetaData
466
+ create_response: TransactionResponse | None = None
467
+ delete_response: TransactionResponse | None = None
468
+ update_response: TransactionResponse | None = None
469
+ action_taken: OperationPerformed = OperationPerformed.Nothing
470
+
471
+
472
+ @dataclasses.dataclass(kw_only=True)
473
+ class DeployCallArgs:
474
+ """Parameters used to update or delete an application when calling
475
+ {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
476
+
477
+ suggested_params: transaction.SuggestedParams | None = None
478
+ lease: bytes | str | None = None
479
+ accounts: list[str] | None = None
480
+ foreign_apps: list[int] | None = None
481
+ foreign_assets: list[int] | None = None
482
+ boxes: Sequence[tuple[int, bytes | bytearray | str | int]] | None = None
483
+ rekey_to: str | None = None
484
+
485
+
486
+ @dataclasses.dataclass(kw_only=True)
487
+ class ABICall:
488
+ method: ABIMethod | bool | None = None
489
+ args: ABIArgsDict = dataclasses.field(default_factory=dict)
490
+
491
+
492
+ @dataclasses.dataclass(kw_only=True)
493
+ class DeployCreateCallArgs(DeployCallArgs):
494
+ """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
495
+
496
+ extra_pages: int | None = None
497
+ on_complete: transaction.OnComplete | None = None
498
+
499
+
500
+ @dataclasses.dataclass(kw_only=True)
501
+ class ABICallArgs(DeployCallArgs, ABICall):
502
+ """ABI Parameters used to update or delete an application when calling
503
+ {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
504
+
505
+
506
+ @dataclasses.dataclass(kw_only=True)
507
+ class ABICreateCallArgs(DeployCreateCallArgs, ABICall):
508
+ """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
509
+
510
+
511
+ class DeployCallArgsDict(TypedDict, total=False):
512
+ """Parameters used to update or delete an application when calling
513
+ {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
514
+
515
+ suggested_params: transaction.SuggestedParams
516
+ lease: bytes | str
517
+ accounts: list[str]
518
+ foreign_apps: list[int]
519
+ foreign_assets: list[int]
520
+ boxes: Sequence[tuple[int, bytes | bytearray | str | int]]
521
+ rekey_to: str
522
+
523
+
524
+ class ABICallArgsDict(DeployCallArgsDict, TypedDict, total=False):
525
+ """ABI Parameters used to update or delete an application when calling
526
+ {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
527
+
528
+ method: ABIMethod | bool
529
+ args: ABIArgsDict
530
+
531
+
532
+ class DeployCreateCallArgsDict(DeployCallArgsDict, TypedDict, total=False):
533
+ """Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
534
+
535
+ extra_pages: int | None
536
+ on_complete: transaction.OnComplete
537
+
538
+
539
+ class ABICreateCallArgsDict(DeployCreateCallArgsDict, TypedDict, total=False):
540
+ """ABI Parameters used to create an application when calling {py:meth}`~algokit_utils.ApplicationClient.deploy`"""
541
+
542
+ method: ABIMethod | bool
543
+ args: ABIArgsDict
544
+
545
+
546
+ @dataclasses.dataclass(kw_only=True)
547
+ class Deployer:
548
+ app_client: "ApplicationClient"
549
+ creator: str
550
+ signer: TransactionSigner
551
+ sender: str
552
+ existing_app_metadata_or_reference: AppReference | AppMetaData
553
+ new_app_metadata: AppDeployMetaData
554
+ on_update: OnUpdate
555
+ on_schema_break: OnSchemaBreak
556
+ create_args: ABICreateCallArgs | ABICreateCallArgsDict | DeployCreateCallArgs | None
557
+ update_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None
558
+ delete_args: ABICallArgs | ABICallArgsDict | DeployCallArgs | None
559
+
560
+ def deploy(self) -> DeployResponse:
561
+ """Ensures app associated with app client's creator is present and up to date"""
562
+ assert self.app_client.approval
563
+ assert self.app_client.clear
564
+
565
+ if self.existing_app_metadata_or_reference.app_id == 0:
566
+ logger.info(f"{self.new_app_metadata.name} not found in {self.creator} account, deploying app.")
567
+ return self._create_app()
568
+
569
+ assert isinstance(self.existing_app_metadata_or_reference, AppMetaData)
570
+ logger.debug(
571
+ f"{self.existing_app_metadata_or_reference.name} found in {self.creator} account, "
572
+ f"with app id {self.existing_app_metadata_or_reference.app_id}, "
573
+ f"version={self.existing_app_metadata_or_reference.version}."
574
+ )
575
+
576
+ app_changes = check_for_app_changes(
577
+ self.app_client.algod_client,
578
+ new_approval=self.app_client.approval.raw_binary,
579
+ new_clear=self.app_client.clear.raw_binary,
580
+ new_global_schema=self.app_client.app_spec.global_state_schema,
581
+ new_local_schema=self.app_client.app_spec.local_state_schema,
582
+ app_id=self.existing_app_metadata_or_reference.app_id,
583
+ )
584
+
585
+ if app_changes.schema_breaking_change:
586
+ logger.warning(f"Detected a breaking app schema change: {app_changes.schema_change_description}")
587
+ return self._deploy_breaking_change()
588
+
589
+ if app_changes.app_updated:
590
+ logger.info(f"Detected a TEAL update in app id {self.existing_app_metadata_or_reference.app_id}")
591
+ return self._deploy_update()
592
+
593
+ logger.info("No detected changes in app, nothing to do.")
594
+ return DeployResponse(app=self.existing_app_metadata_or_reference)
595
+
596
+ def _deploy_breaking_change(self) -> DeployResponse:
597
+ assert isinstance(self.existing_app_metadata_or_reference, AppMetaData)
598
+ if self.on_schema_break == OnSchemaBreak.Fail:
599
+ raise DeploymentFailedError(
600
+ "Schema break detected and on_schema_break=OnSchemaBreak.Fail, stopping deployment. "
601
+ "If you want to try deleting and recreating the app then "
602
+ "re-run with on_schema_break=OnSchemaBreak.ReplaceApp"
603
+ )
604
+ if self.on_schema_break == OnSchemaBreak.AppendApp:
605
+ logger.info("Schema break detected and on_schema_break=AppendApp, will attempt to create new app")
606
+ return self._create_app()
607
+
608
+ if self.existing_app_metadata_or_reference.deletable:
609
+ logger.info(
610
+ "App is deletable and on_schema_break=ReplaceApp, will attempt to create new app and delete old app"
611
+ )
612
+ elif self.existing_app_metadata_or_reference.deletable is False:
613
+ logger.warning(
614
+ "App is not deletable but on_schema_break=ReplaceApp, "
615
+ "will attempt to delete app, delete will most likely fail"
616
+ )
617
+ else:
618
+ logger.warning(
619
+ "Cannot determine if App is deletable but on_schema_break=ReplaceApp, will attempt to delete app"
620
+ )
621
+ return self._create_and_delete_app()
622
+
623
+ def _deploy_update(self) -> DeployResponse:
624
+ assert isinstance(self.existing_app_metadata_or_reference, AppMetaData)
625
+ if self.on_update == OnUpdate.Fail:
626
+ raise DeploymentFailedError(
627
+ "Update detected and on_update=Fail, stopping deployment. "
628
+ "If you want to try updating the app then re-run with on_update=UpdateApp"
629
+ )
630
+ if self.on_update == OnUpdate.AppendApp:
631
+ logger.info("Update detected and on_update=AppendApp, will attempt to create new app")
632
+ return self._create_app()
633
+ elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.UpdateApp:
634
+ logger.info("App is updatable and on_update=UpdateApp, will update app")
635
+ return self._update_app()
636
+ elif self.existing_app_metadata_or_reference.updatable and self.on_update == OnUpdate.ReplaceApp:
637
+ logger.warning(
638
+ "App is updatable but on_update=ReplaceApp, will attempt to create new app and delete old app"
639
+ )
640
+ return self._create_and_delete_app()
641
+ elif self.on_update == OnUpdate.ReplaceApp:
642
+ if self.existing_app_metadata_or_reference.updatable is False:
643
+ logger.warning(
644
+ "App is not updatable and on_update=ReplaceApp, "
645
+ "will attempt to create new app and delete old app"
646
+ )
647
+ else:
648
+ logger.warning(
649
+ "Cannot determine if App is updatable and on_update=ReplaceApp, "
650
+ "will attempt to create new app and delete old app"
651
+ )
652
+ return self._create_and_delete_app()
653
+ else:
654
+ if self.existing_app_metadata_or_reference.updatable is False:
655
+ logger.warning(
656
+ "App is not updatable but on_update=UpdateApp, "
657
+ "will attempt to update app, update will most likely fail"
658
+ )
659
+ else:
660
+ logger.warning(
661
+ "Cannot determine if App is updatable and on_update=UpdateApp, will attempt to update app"
662
+ )
663
+ return self._update_app()
664
+
665
+ def _create_app(self) -> DeployResponse:
666
+ assert self.app_client.existing_deployments
667
+
668
+ method, abi_args, parameters = _convert_deploy_args(
669
+ self.create_args, self.new_app_metadata, self.signer, self.sender
670
+ )
671
+ create_response = self.app_client.create(
672
+ method,
673
+ parameters,
674
+ **abi_args,
675
+ )
676
+ logger.info(
677
+ f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, "
678
+ f"with app id {self.app_client.app_id}."
679
+ )
680
+ assert create_response.confirmed_round is not None
681
+ app_metadata = _create_metadata(self.new_app_metadata, self.app_client.app_id, create_response.confirmed_round)
682
+ self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata
683
+ return DeployResponse(app=app_metadata, create_response=create_response, action_taken=OperationPerformed.Create)
684
+
685
+ def _create_and_delete_app(self) -> DeployResponse:
686
+ assert self.app_client.existing_deployments
687
+ assert isinstance(self.existing_app_metadata_or_reference, AppMetaData)
688
+
689
+ logger.info(
690
+ f"Replacing {self.existing_app_metadata_or_reference.name} "
691
+ f"({self.existing_app_metadata_or_reference.version}) with "
692
+ f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) in {self.creator} account."
693
+ )
694
+ atc = AtomicTransactionComposer()
695
+ create_method, create_abi_args, create_parameters = _convert_deploy_args(
696
+ self.create_args, self.new_app_metadata, self.signer, self.sender
697
+ )
698
+ self.app_client.compose_create(
699
+ atc,
700
+ create_method,
701
+ create_parameters,
702
+ **create_abi_args,
703
+ )
704
+ create_txn_index = len(atc.txn_list) - 1
705
+ delete_method, delete_abi_args, delete_parameters = _convert_deploy_args(
706
+ self.delete_args, self.new_app_metadata, self.signer, self.sender
707
+ )
708
+ self.app_client.compose_delete(
709
+ atc,
710
+ delete_method,
711
+ delete_parameters,
712
+ **delete_abi_args,
713
+ )
714
+ delete_txn_index = len(atc.txn_list) - 1
715
+ create_delete_response = self.app_client.execute_atc(atc)
716
+ create_response = TransactionResponse.from_atr(create_delete_response, create_txn_index)
717
+ delete_response = TransactionResponse.from_atr(create_delete_response, delete_txn_index)
718
+ self.app_client.app_id = get_app_id_from_tx_id(self.app_client.algod_client, create_response.tx_id)
719
+ logger.info(
720
+ f"{self.new_app_metadata.name} ({self.new_app_metadata.version}) deployed successfully, "
721
+ f"with app id {self.app_client.app_id}."
722
+ )
723
+ logger.info(
724
+ f"{self.existing_app_metadata_or_reference.name} "
725
+ f"({self.existing_app_metadata_or_reference.version}) with app id "
726
+ f"{self.existing_app_metadata_or_reference.app_id}, deleted successfully."
727
+ )
728
+
729
+ app_metadata = _create_metadata(
730
+ self.new_app_metadata, self.app_client.app_id, create_delete_response.confirmed_round
731
+ )
732
+ self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata
733
+
734
+ return DeployResponse(
735
+ app=app_metadata,
736
+ create_response=create_response,
737
+ delete_response=delete_response,
738
+ action_taken=OperationPerformed.Replace,
739
+ )
740
+
741
+ def _update_app(self) -> DeployResponse:
742
+ assert self.app_client.existing_deployments
743
+ assert isinstance(self.existing_app_metadata_or_reference, AppMetaData)
744
+
745
+ logger.info(
746
+ f"Updating {self.existing_app_metadata_or_reference.name} to {self.new_app_metadata.version} in "
747
+ f"{self.creator} account, with app id {self.existing_app_metadata_or_reference.app_id}"
748
+ )
749
+ method, abi_args, parameters = _convert_deploy_args(
750
+ self.update_args, self.new_app_metadata, self.signer, self.sender
751
+ )
752
+ update_response = self.app_client.update(
753
+ method,
754
+ parameters,
755
+ **abi_args,
756
+ )
757
+ app_metadata = _create_metadata(
758
+ self.new_app_metadata,
759
+ self.app_client.app_id,
760
+ self.existing_app_metadata_or_reference.created_round,
761
+ updated_round=update_response.confirmed_round,
762
+ original_metadata=self.existing_app_metadata_or_reference.created_metadata,
763
+ )
764
+ self.app_client.existing_deployments.apps[self.new_app_metadata.name] = app_metadata
765
+ return DeployResponse(app=app_metadata, update_response=update_response, action_taken=OperationPerformed.Update)
766
+
767
+
768
+ def _create_metadata(
769
+ app_spec_note: AppDeployMetaData,
770
+ app_id: int,
771
+ created_round: int,
772
+ updated_round: int | None = None,
773
+ original_metadata: AppDeployMetaData | None = None,
774
+ ) -> AppMetaData:
775
+ return AppMetaData(
776
+ app_id=app_id,
777
+ app_address=algosdk.logic.get_application_address(app_id),
778
+ created_metadata=original_metadata or app_spec_note,
779
+ created_round=created_round,
780
+ updated_round=updated_round or created_round,
781
+ name=app_spec_note.name,
782
+ version=app_spec_note.version,
783
+ deletable=app_spec_note.deletable,
784
+ updatable=app_spec_note.updatable,
785
+ deleted=False,
786
+ )
787
+
788
+
789
+ def _convert_deploy_args(
790
+ _args: DeployCallArgs | DeployCallArgsDict | None,
791
+ note: AppDeployMetaData,
792
+ signer: TransactionSigner | None,
793
+ sender: str | None,
794
+ ) -> tuple[ABIMethod | bool | None, ABIArgsDict, CreateCallParameters]:
795
+ args = _args.__dict__ if isinstance(_args, DeployCallArgs) else dict(_args or {})
796
+
797
+ # return most derived type, unused parameters are ignored
798
+ parameters = CreateCallParameters(
799
+ note=note.encode(),
800
+ signer=signer,
801
+ sender=sender,
802
+ suggested_params=args.get("suggested_params"),
803
+ lease=args.get("lease"),
804
+ accounts=args.get("accounts"),
805
+ foreign_assets=args.get("foreign_assets"),
806
+ foreign_apps=args.get("foreign_apps"),
807
+ boxes=args.get("boxes"),
808
+ rekey_to=args.get("rekey_to"),
809
+ extra_pages=args.get("extra_pages"),
810
+ on_complete=args.get("on_complete"),
811
+ )
812
+
813
+ return args.get("method"), args.get("args") or {}, parameters
814
+
815
+
816
+ def get_app_id_from_tx_id(algod_client: "AlgodClient", tx_id: str) -> int:
817
+ """Finds the app_id for provided transaction id"""
818
+ result = algod_client.pending_transaction_info(tx_id)
819
+ assert isinstance(result, dict)
820
+ app_id = result["application-index"]
821
+ assert isinstance(app_id, int)
822
+ return app_id